smartpipe-cli 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,468 @@
1
+ """Shared verb helpers: outcome→exit-code, item-stream plumbing, embed batching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from typing import TYPE_CHECKING, TypeVar, assert_never
7
+
8
+ from smartpipe.core.errors import ExitCode, ItemError, TooManyFailures, UsageFault
9
+ from smartpipe.engine.runner import (
10
+ Done,
11
+ FailurePolicy,
12
+ ItemOutcome,
13
+ Skipped,
14
+ should_halt,
15
+ should_halt_consecutive,
16
+ )
17
+ from smartpipe.io import diagnostics
18
+ from smartpipe.models.base import AudioData, ImageData, MediaEmbeddingModel, VideoData
19
+ from smartpipe.verbs.convert import AUDIO_NEEDS_TEXT, Converter
20
+
21
+ if TYPE_CHECKING:
22
+ import asyncio
23
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence
24
+ from pathlib import Path
25
+
26
+ from smartpipe.io.items import Item
27
+ from smartpipe.models.base import EmbeddingModel
28
+
29
+ __all__ = [
30
+ "AUDIO_NEEDS_TEXT",
31
+ "EMBED_BATCH_SIZE",
32
+ "IMAGE_NEEDS_MAP",
33
+ "WindowGate",
34
+ "batched",
35
+ "embed_budget",
36
+ "embed_in_batches",
37
+ "ensure_text",
38
+ "interrupted_exit_code",
39
+ "outcome_exit_code",
40
+ "prepend",
41
+ "resolve_schema",
42
+ "transcribe",
43
+ ]
44
+
45
+ T = TypeVar("T")
46
+
47
+ EMBED_BATCH_SIZE = 64 # texts per embed call on finite corpora (plan/post-1.0/06)
48
+
49
+
50
+ def outcome_exit_code(*, done: int, skipped: int) -> ExitCode:
51
+ """0 = all ok · 1 = some skipped · 3 = every item failed (spec §12)."""
52
+ if skipped == 0:
53
+ return ExitCode.OK
54
+ if done == 0:
55
+ return ExitCode.ALL_FAILED
56
+ return ExitCode.PARTIAL
57
+
58
+
59
+ def interrupted_exit_code(*, done: int, skipped: int) -> ExitCode:
60
+ """After a drained Ctrl-C (ux.md §12): the run's normal outcome code — an
61
+ interrupt doesn't mask partiality — except 130 when nothing finished at all."""
62
+ if done == 0 and skipped == 0:
63
+ return ExitCode.INTERRUPTED
64
+ return outcome_exit_code(done=done, skipped=skipped)
65
+
66
+
67
+ async def prepend(first: Item, rest: AsyncIterator[Item]) -> AsyncIterator[Item]:
68
+ """Re-attach an item pulled for a first-item check (filter's brace fail-fast)."""
69
+ yield first
70
+ async for item in rest:
71
+ yield item
72
+
73
+
74
+ IMAGE_NEEDS_MAP = "image items need map — this verb reads text" # stage-7 wording, pinned
75
+
76
+
77
+ def transcribe(audio: AudioData) -> str:
78
+ """The default transcriber (the ``[audio]`` extra) with the pinned two-fix skip."""
79
+ from smartpipe.parsing.extract import MissingExtra, transcribe_audio
80
+
81
+ try:
82
+ return transcribe_audio(audio)
83
+ except MissingExtra as exc:
84
+ raise ItemError(AUDIO_NEEDS_TEXT) from exc
85
+
86
+
87
+ async def ensure_text(
88
+ item: Item,
89
+ *,
90
+ transcriber: Callable[[AudioData], str] = transcribe,
91
+ log: diagnostics.DegradationLog | None = None,
92
+ converter: Converter | None = None,
93
+ ) -> Item:
94
+ """Non-map verbs read text (D20/D32/D33): media parts convert to text through
95
+ the ladder — an LLM when the cost fence allows (free local: automatic; cloud:
96
+ behind --allow-captions), whisper for audio beneath it — every conversion
97
+ row-noted. Image-ONLY items with no conversion available keep the skip."""
98
+ if not item.media:
99
+ return item
100
+ import asyncio
101
+
102
+ from smartpipe.io.items import describe_source
103
+
104
+ where = describe_source(item.source)
105
+ text: str = item.text
106
+ figures: list[ImageData] = []
107
+ for part in item.media:
108
+ match part:
109
+ case AudioData() as audio:
110
+ spoken: str
111
+ if converter is not None:
112
+ spoken = await converter.audio_to_text(audio, where)
113
+ else:
114
+ spoken = await asyncio.to_thread(transcriber, audio)
115
+ if log is not None:
116
+ log.note(where, "audio → text", _whisper_detail())
117
+ text = _merge(text, spoken)
118
+ case VideoData() as video:
119
+ if converter is not None:
120
+ visual, speech = await converter.video_halves(video, where)
121
+ halves: list[str] = [half for half in (visual, speech) if half]
122
+ watched: str = "\n\n".join(halves)
123
+ text = _merge(text, watched)
124
+ continue
125
+ from smartpipe.parsing.extract import video_to_parts
126
+
127
+ parts = await asyncio.to_thread(video_to_parts, video)
128
+ if parts.track is None:
129
+ raise ItemError(
130
+ "this video has no audio track — text verbs read text; "
131
+ "map can see its frames"
132
+ )
133
+ # converter is provably None here — the halves branch above
134
+ # consumed every converter path and `continue`d
135
+ track_text: str = await asyncio.to_thread(transcriber, parts.track)
136
+ if log is not None:
137
+ log.note(
138
+ where,
139
+ "video → text",
140
+ "audio track converted; frames dropped — map sees frames",
141
+ )
142
+ text = _merge(text, track_text)
143
+ case ImageData() as image:
144
+ figures.append(image)
145
+ case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
146
+ assert_never(unreachable)
147
+ if figures:
148
+ text = await _figures_to_text(figures, text, where, log=log, converter=converter)
149
+ return replace(item, text=text, media=())
150
+
151
+
152
+ async def _figures_to_text(
153
+ figures: list[ImageData],
154
+ text: str,
155
+ where: str,
156
+ *,
157
+ log: diagnostics.DegradationLog | None,
158
+ converter: Converter | None,
159
+ ) -> str:
160
+ """Captions when the fence allows; a mixed item degrades to text-only (noted)
161
+ when it doesn't; an image-ONLY item skips with the two-fix line (D33)."""
162
+ if converter is not None:
163
+ try:
164
+ captions = [await converter.image_to_text(figure, where) for figure in figures]
165
+ except ItemError:
166
+ if not text:
167
+ raise # image-only: the converter's message names both fixes
168
+ captions = None
169
+ if captions is not None:
170
+ described = "\n\n".join(
171
+ f"[figure {position}] {caption}"
172
+ for position, caption in enumerate(captions, start=1)
173
+ )
174
+ return f"{text}\n\n{described}".strip() if text else described
175
+ if not text:
176
+ raise ItemError(IMAGE_NEEDS_MAP)
177
+ if log is not None:
178
+ plural = "s" if len(figures) != 1 else ""
179
+ log.note(
180
+ where,
181
+ "figures dropped",
182
+ f"{len(figures)} image{plural} — captions convert them "
183
+ "(free with a local vision model, --allow-captions for cloud)",
184
+ )
185
+ return text
186
+
187
+
188
+ def _merge(text: str, addition: str) -> str:
189
+ """Append a converted part to an item's text (empty-safe)."""
190
+ return f"{text}\n\n{addition}".strip() if text else addition
191
+
192
+
193
+ def _whisper_detail() -> str:
194
+ import os
195
+
196
+ from smartpipe.parsing.extract import whisper_size
197
+
198
+ return f"whisper {whisper_size(os.environ)}"
199
+
200
+
201
+ EMBED_BUDGET_TOKENS = 4_800 # 8k published minus the usual safety margin
202
+ _GEMINI_EMBED_BUDGET_TOKENS = 1_200 # gemini-embedding caps input at 2k tokens
203
+
204
+
205
+ def embed_budget(provider: str) -> int:
206
+ """Embedding windows aren't published via API — a conservative static table."""
207
+ return _GEMINI_EMBED_BUDGET_TOKENS if provider == "gemini" else EMBED_BUDGET_TOKENS
208
+
209
+
210
+ @dataclass
211
+ class WindowGate:
212
+ """Per-run, probe-aware oversize gate for chat calls (D26).
213
+
214
+ Fast path: an item within the static table budget never triggers anything.
215
+ The first item that exceeds it asks the provider for the real window once
216
+ (four wires publish it); the answer can only widen the budget.
217
+ """
218
+
219
+ provider: str
220
+ model_name: str
221
+ overhead: int
222
+ window: Callable[[], Awaitable[int | None]]
223
+ _budget: int | None = None
224
+ _probed: bool = False
225
+
226
+ async def budget_for_oversized(self, text: str) -> int | None:
227
+ """None when the text fits (no probe, no cost); else the best-known budget."""
228
+ from smartpipe.engine.chunking import budget_for, estimate_tokens
229
+
230
+ if self._budget is None:
231
+ self._budget = budget_for(self.provider, prompt_overhead=self.overhead)
232
+ if estimate_tokens(text) <= self._budget:
233
+ return None
234
+ if not self._probed:
235
+ self._probed = True
236
+ probed = await self.window()
237
+ if probed is not None:
238
+ widened = budget_for(self.provider, prompt_overhead=self.overhead, window=probed)
239
+ self._budget = max(self._budget, widened)
240
+ return None if estimate_tokens(text) <= self._budget else self._budget
241
+
242
+ def refusal(self, text: str, budget: int) -> str:
243
+ from smartpipe.engine.chunking import estimate_tokens
244
+
245
+ return (
246
+ f"~{estimate_tokens(text):,} tokens is past {self.model_name}'s "
247
+ f"~{budget:,}-token budget — split it first: "
248
+ 'smartpipe split --in FILE | smartpipe map "..." | smartpipe reduce "..."'
249
+ )
250
+
251
+
252
+ def resolve_schema(
253
+ path: Path | None,
254
+ dsl: str | None,
255
+ *,
256
+ loader: Callable[[Path], dict[str, object]],
257
+ ) -> dict[str, object] | None:
258
+ """Rungs 3/5 of the schema ladder (D22): mutually exclusive, resolved before
259
+ any model call. The loader is the verb's own ``load_schema`` so its test seam
260
+ keeps working."""
261
+ if path is not None and dsl is not None:
262
+ raise UsageFault(
263
+ "--schema-from and --schema both shape the output — use one\n"
264
+ " --schema-from builds the schema from a short description; --schema loads a file."
265
+ )
266
+ if dsl is not None:
267
+ from smartpipe.engine.schema_dsl import dsl_to_schema
268
+
269
+ return dsl_to_schema(dsl)
270
+ return loader(path) if path is not None else None
271
+
272
+
273
+ def batched(items: Sequence[T], size: int) -> Iterator[tuple[T, ...]]:
274
+ """``itertools.batched`` for the 3.11 floor — tuple chunks, order preserved."""
275
+ if size < 1:
276
+ raise ValueError(f"batch size must be >= 1, got {size}")
277
+ return (tuple(items[start : start + size]) for start in range(0, len(items), size))
278
+
279
+
280
+ _native_noted = False # one disclosure per process, not per item # noqa: N816-ish (module state)
281
+
282
+
283
+ def _native_route(item: Item, model: object) -> tuple[MediaEmbeddingModel, ImageData] | None:
284
+ """The native-path test (D39/04): a media-capable embedder + an
285
+ image-ONLY item (no meaningful text). Text-bearing items keep embedding
286
+ their text; audio/video keep the pivot ladder. Returns the narrowed
287
+ model alongside the image so the caller's ``model`` binding stays put."""
288
+ from smartpipe.models.base import supports_media_embedding
289
+
290
+ if not supports_media_embedding(model):
291
+ return None
292
+ if item.text.strip():
293
+ return None
294
+ images = [part for part in item.media if isinstance(part, ImageData)]
295
+ if len(images) != 1 or len(item.media) != 1:
296
+ return None
297
+ return model, images[0]
298
+
299
+
300
+ async def embed_in_batches(
301
+ model: EmbeddingModel,
302
+ items: Sequence[Item],
303
+ *,
304
+ failure_policy: FailurePolicy,
305
+ batch_size: int = EMBED_BATCH_SIZE,
306
+ stop: asyncio.Event | None = None,
307
+ transcriber: Callable[[AudioData], str] = transcribe,
308
+ log: diagnostics.DegradationLog | None = None,
309
+ converter: Converter | None = None,
310
+ ) -> AsyncIterator[ItemOutcome[tuple[Item, tuple[float, ...]]]]:
311
+ """Embed a finite corpus in ≤``batch_size`` chunks, sequentially (DEFER-3).
312
+
313
+ Bypasses ``run_ordered`` on purpose — batching ≠ per-item workers: order
314
+ comes from sequential chunks, and isolation from the fallback (a failed
315
+ chunk re-runs item-by-item, so one poison item skips alone instead of
316
+ taking 63 neighbors with it). Accounting mirrors the runner's: majority
317
+ failure past ``min_sample`` halts with ``TooManyFailures``.
318
+ """
319
+ processed = 0
320
+ skipped = 0
321
+ consecutive = 0
322
+ succeeded = False
323
+
324
+ def account_skip(reason: str) -> None:
325
+ nonlocal skipped, consecutive
326
+ skipped += 1
327
+ consecutive += 1
328
+ if should_halt(failure_policy, total=processed, skipped=skipped):
329
+ raise TooManyFailures(skipped, processed, reason)
330
+ if should_halt_consecutive(failure_policy, succeeded=succeeded, consecutive=consecutive):
331
+ raise TooManyFailures(skipped, processed, reason)
332
+
333
+ def account_done() -> None:
334
+ nonlocal consecutive, succeeded
335
+ consecutive = 0
336
+ succeeded = True
337
+
338
+ from smartpipe.engine.chunking import estimate_tokens, mean_pool, split_text
339
+
340
+ budget = embed_budget(model.ref.provider)
341
+
342
+ async def embed_batch(
343
+ batch: list[Item],
344
+ ) -> AsyncIterator[ItemOutcome[tuple[Item, tuple[float, ...]]]]:
345
+ if not batch:
346
+ return
347
+ try:
348
+ vectors = await model.embed([entry.text for entry in batch])
349
+ if len(vectors) != len(batch):
350
+ raise ItemError(f"endpoint returned {len(vectors)} vectors for {len(batch)} texts")
351
+ except ItemError:
352
+ # the poison fallback (DEFER-3): re-run item-by-item so one bad item
353
+ # skips alone — accounting runs BETWEEN calls so the D18 halt can
354
+ # stop the spend mid-batch, not after it
355
+ for entry in batch:
356
+ if stop is not None and stop.is_set():
357
+ return
358
+ try:
359
+ vector = (await model.embed([entry.text]))[0]
360
+ except ItemError as exc:
361
+ skip = Skipped(entry.source.index, str(exc), entry.source)
362
+ account(skip)
363
+ yield skip
364
+ else:
365
+ done = Done(entry.source.index, (entry, vector))
366
+ account(done)
367
+ yield done
368
+ return
369
+ for entry, vector in zip(batch, vectors, strict=True):
370
+ done = Done(entry.source.index, (entry, vector))
371
+ account(done)
372
+ yield done
373
+
374
+ async def pooled(entry: Item) -> ItemOutcome[tuple[Item, tuple[float, ...]]]:
375
+ # D26: one text past the embedding window — embed its chunks, mean-pool
376
+ try:
377
+ vectors = await model.embed(list(split_text(entry.text, budget)))
378
+ except ItemError as exc:
379
+ return Skipped(entry.source.index, str(exc), entry.source)
380
+ return Done(entry.source.index, (entry, mean_pool(vectors)))
381
+
382
+ def account(outcome: ItemOutcome[tuple[Item, tuple[float, ...]]]) -> None:
383
+ nonlocal processed
384
+ processed += 1
385
+ if isinstance(outcome, Done):
386
+ account_done()
387
+ else:
388
+ account_skip(outcome.reason)
389
+
390
+ async def _drain(
391
+ outcomes: AsyncIterator[ItemOutcome[tuple[Item, tuple[float, ...]]]],
392
+ ) -> list[ItemOutcome[tuple[Item, tuple[float, ...]]]]:
393
+ return [outcome async for outcome in outcomes]
394
+
395
+ pending: list[Item] = []
396
+ for item in items:
397
+ if stop is not None and stop.is_set():
398
+ return
399
+ if item.media:
400
+ native = _native_route(item, model)
401
+ if native is not None:
402
+ media_model, image = native
403
+ # D39/04: the embedder takes images natively — no captions
404
+ for outcome in await _drain(embed_batch(pending)):
405
+ yield outcome
406
+ pending = []
407
+ global _native_noted
408
+ if not _native_noted:
409
+ _native_noted = True
410
+ diagnostics.note(
411
+ f"media embedded natively ({model.ref.provider}/{model.ref.name})"
412
+ " — no captions"
413
+ )
414
+ try:
415
+ vectors = await media_model.embed_parts([image])
416
+ except ItemError as exc:
417
+ skip = Skipped(item.source.index, str(exc), item.source)
418
+ account(skip)
419
+ yield skip
420
+ continue
421
+ done = Done(item.source.index, (item, vectors[0]))
422
+ account(done)
423
+ yield done
424
+ continue
425
+ video = next((part for part in item.media if isinstance(part, VideoData)), None)
426
+ if video is not None and converter is not None and converter.chat is not None:
427
+ from smartpipe.verbs.convert import embed_video_halves
428
+
429
+ # flush first so stdout order stays input order, then the halves
430
+ for outcome in await _drain(embed_batch(pending)):
431
+ yield outcome
432
+ pending = []
433
+ try:
434
+ converted, vector = await embed_video_halves(model, item, video, converter)
435
+ except ItemError as exc:
436
+ skip = Skipped(item.source.index, str(exc), item.source)
437
+ account(skip)
438
+ yield skip
439
+ continue
440
+ done = Done(item.source.index, (converted, vector))
441
+ account(done)
442
+ yield done
443
+ continue
444
+ try:
445
+ item = await ensure_text(
446
+ item, transcriber=transcriber, log=log, converter=converter
447
+ )
448
+ except ItemError as exc:
449
+ skip = Skipped(item.source.index, str(exc), item.source)
450
+ account(skip)
451
+ yield skip
452
+ continue
453
+ if estimate_tokens(item.text) > budget:
454
+ # flush first so stdout order stays input order, then pool this one
455
+ async for outcome in embed_batch(pending):
456
+ yield outcome
457
+ pending = []
458
+ pooled_outcome = await pooled(item)
459
+ account(pooled_outcome)
460
+ yield pooled_outcome
461
+ continue
462
+ pending.append(item)
463
+ if len(pending) >= batch_size:
464
+ async for outcome in embed_batch(pending):
465
+ yield outcome
466
+ pending = []
467
+ async for outcome in embed_batch(pending):
468
+ yield outcome