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.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- 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
|