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
smartpipe/verbs/map.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""The ``map`` verb: transform each item with a prompt (spec §3.1).
|
|
2
|
+
|
|
3
|
+
Orchestration only — the parsing, planning, schema work, and ordering all live in
|
|
4
|
+
the pure engine; this is the imperative shell that wires a resolved model to the
|
|
5
|
+
runner and streams results out. The one bit of per-verb cleverness is the single
|
|
6
|
+
repair retry: a structured reply that fails validation is re-asked once with the
|
|
7
|
+
validator's complaint before the item is skipped.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from functools import partial
|
|
16
|
+
from typing import TYPE_CHECKING, Protocol
|
|
17
|
+
|
|
18
|
+
from smartpipe.core.errors import ExitCode, ItemError, UsageFault
|
|
19
|
+
from smartpipe.engine.prompts import (
|
|
20
|
+
build_map_request,
|
|
21
|
+
build_repair_request,
|
|
22
|
+
parse_prompt,
|
|
23
|
+
plan_map,
|
|
24
|
+
to_instruction,
|
|
25
|
+
)
|
|
26
|
+
from smartpipe.engine.runner import Done, FailurePolicy, run_ordered
|
|
27
|
+
from smartpipe.engine.schema import load_schema, validate_and_coerce
|
|
28
|
+
from smartpipe.io import diagnostics, readers
|
|
29
|
+
from smartpipe.io.inputs import STDIN
|
|
30
|
+
from smartpipe.io.items import describe_source
|
|
31
|
+
from smartpipe.io.progress import make_stderr_spinner
|
|
32
|
+
from smartpipe.models.base import AudioData, VideoData
|
|
33
|
+
from smartpipe.verbs.common import (
|
|
34
|
+
WindowGate,
|
|
35
|
+
interrupted_exit_code,
|
|
36
|
+
outcome_exit_code,
|
|
37
|
+
resolve_schema,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import TextIO
|
|
43
|
+
|
|
44
|
+
from smartpipe.engine.prompts import MapPlan
|
|
45
|
+
from smartpipe.io.inputs import InputSpec
|
|
46
|
+
from smartpipe.io.items import Item
|
|
47
|
+
from smartpipe.io.writers import OutputFormat, ResultWriter
|
|
48
|
+
from smartpipe.models.base import ChatModel, MediaData, ModelRef
|
|
49
|
+
|
|
50
|
+
__all__ = ["MapContext", "MapRequest", "map_one", "run_map"]
|
|
51
|
+
|
|
52
|
+
_PROMPT_OVERHEAD_TOKENS = 500 # instruction + wrapper + reply headroom
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class MapRequest:
|
|
57
|
+
prompt: str
|
|
58
|
+
schema_path: Path | None
|
|
59
|
+
model_flag: str | None
|
|
60
|
+
output: OutputFormat
|
|
61
|
+
concurrency_flag: int | None
|
|
62
|
+
input: InputSpec = STDIN
|
|
63
|
+
fields: tuple[str, ...] | None = None # --fields: project structured output
|
|
64
|
+
schema_dsl: str | None = None # --schema-from (rung 3, D22)
|
|
65
|
+
tally_field: str | None = None # --tally FIELD: live distribution on stderr
|
|
66
|
+
explode_field: str | None = None # --explode FIELD: one row per list element
|
|
67
|
+
frame_every: float | None = None # --frame-every SECONDS: video density guarantee (D43)
|
|
68
|
+
max_frames: int | None = None # --max-frames N: video frame budget (D43)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MapContext(Protocol):
|
|
72
|
+
"""The slice of the container ``map`` needs — a DI seam so tests inject fakes."""
|
|
73
|
+
|
|
74
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
75
|
+
async def context_window(self, ref: ModelRef) -> int | None: ...
|
|
76
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
77
|
+
def writer(
|
|
78
|
+
self,
|
|
79
|
+
output_flag: OutputFormat,
|
|
80
|
+
*,
|
|
81
|
+
structured: bool,
|
|
82
|
+
stdout: TextIO,
|
|
83
|
+
fields: tuple[str, ...] | None = None,
|
|
84
|
+
) -> ResultWriter: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def run_map(
|
|
88
|
+
request: MapRequest,
|
|
89
|
+
context: MapContext,
|
|
90
|
+
*,
|
|
91
|
+
stdin: TextIO,
|
|
92
|
+
stdout: TextIO,
|
|
93
|
+
stop: asyncio.Event | None = None,
|
|
94
|
+
) -> ExitCode:
|
|
95
|
+
tokens = parse_prompt(request.prompt, allow_descriptions=True) # rung 2 (D22)
|
|
96
|
+
schema = resolve_schema(request.schema_path, request.schema_dsl, loader=load_schema)
|
|
97
|
+
plan = plan_map(tokens, schema=schema)
|
|
98
|
+
instruction = to_instruction(tokens)
|
|
99
|
+
items_iter, total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
100
|
+
model = await context.chat_model(request.model_flag) # may emit a note / SetupFault
|
|
101
|
+
structured = plan.mode == "structured"
|
|
102
|
+
writer = context.writer(
|
|
103
|
+
request.output, structured=structured, stdout=stdout, fields=request.fields
|
|
104
|
+
)
|
|
105
|
+
concurrency = context.concurrency(request.concurrency_flag)
|
|
106
|
+
|
|
107
|
+
tally = None
|
|
108
|
+
if request.tally_field is not None:
|
|
109
|
+
if not structured:
|
|
110
|
+
raise UsageFault(
|
|
111
|
+
"--tally needs structured output — name fields in braces or pass --schema\n"
|
|
112
|
+
' Example: smartpipe map "Extract {label}" --tally label'
|
|
113
|
+
)
|
|
114
|
+
from smartpipe.engine.tally import Tally
|
|
115
|
+
|
|
116
|
+
tally = Tally(request.tally_field)
|
|
117
|
+
if request.explode_field is not None and not structured:
|
|
118
|
+
raise UsageFault(
|
|
119
|
+
"--explode needs structured output — name fields in braces or pass --schema\n"
|
|
120
|
+
' Example: smartpipe map "Extract {risks}" --explode risks'
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
spinner = make_stderr_spinner()
|
|
124
|
+
spinner.start(total=total)
|
|
125
|
+
|
|
126
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
127
|
+
gate = WindowGate(
|
|
128
|
+
provider=model.ref.provider,
|
|
129
|
+
model_name=model.ref.name,
|
|
130
|
+
overhead=_PROMPT_OVERHEAD_TOKENS,
|
|
131
|
+
window=partial(context.context_window, model.ref),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def worker(item: Item) -> str | Mapping[str, object]:
|
|
135
|
+
budget = await gate.budget_for_oversized(item.text)
|
|
136
|
+
if budget is not None:
|
|
137
|
+
# D26: silently chunking would change what was asked — teach the pipeline
|
|
138
|
+
raise ItemError(gate.refusal(item.text, budget))
|
|
139
|
+
return await map_one(
|
|
140
|
+
model,
|
|
141
|
+
plan,
|
|
142
|
+
instruction,
|
|
143
|
+
item,
|
|
144
|
+
log,
|
|
145
|
+
frame_every=request.frame_every,
|
|
146
|
+
max_frames=request.max_frames,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
done = 0
|
|
150
|
+
skipped = 0
|
|
151
|
+
outcomes = run_ordered(
|
|
152
|
+
items_iter,
|
|
153
|
+
worker,
|
|
154
|
+
concurrency=concurrency,
|
|
155
|
+
failure_policy=FailurePolicy(),
|
|
156
|
+
stop=stop,
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
async for outcome in outcomes:
|
|
160
|
+
if isinstance(outcome, Done):
|
|
161
|
+
for row in _rows(outcome.value, request.explode_field):
|
|
162
|
+
_write(writer, structured=structured, value=row)
|
|
163
|
+
if tally is not None and isinstance(row, Mapping):
|
|
164
|
+
tally.add(row)
|
|
165
|
+
spinner.extra = tally.live_segment()
|
|
166
|
+
done += 1
|
|
167
|
+
else: # Skipped — the union has no third case
|
|
168
|
+
diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
|
|
169
|
+
skipped += 1
|
|
170
|
+
spinner.advance()
|
|
171
|
+
finally:
|
|
172
|
+
spinner.finish()
|
|
173
|
+
writer.flush()
|
|
174
|
+
log.finish()
|
|
175
|
+
if tally is not None and tally.counts:
|
|
176
|
+
diagnostics.note(tally.final_line())
|
|
177
|
+
if stop is not None and stop.is_set():
|
|
178
|
+
diagnostics.interrupted_summary(processed=done, skipped=skipped)
|
|
179
|
+
return interrupted_exit_code(done=done, skipped=skipped)
|
|
180
|
+
return outcome_exit_code(done=done, skipped=skipped)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def map_one(
|
|
184
|
+
model: ChatModel,
|
|
185
|
+
plan: MapPlan,
|
|
186
|
+
instruction: str,
|
|
187
|
+
item: Item,
|
|
188
|
+
log: diagnostics.DegradationLog,
|
|
189
|
+
*,
|
|
190
|
+
frame_every: float | None = None,
|
|
191
|
+
max_frames: int | None = None,
|
|
192
|
+
) -> str | Mapping[str, object]:
|
|
193
|
+
video = next((part for part in item.media if isinstance(part, VideoData)), None)
|
|
194
|
+
if video is not None:
|
|
195
|
+
return await _map_video(
|
|
196
|
+
model,
|
|
197
|
+
plan,
|
|
198
|
+
instruction,
|
|
199
|
+
item,
|
|
200
|
+
video,
|
|
201
|
+
log,
|
|
202
|
+
frame_every=frame_every,
|
|
203
|
+
max_frames=max_frames,
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
return await _attempt(model, plan, instruction, item.text, item.media)
|
|
207
|
+
except ItemError as native_failure:
|
|
208
|
+
audio = next((part for part in item.media if isinstance(part, AudioData)), None)
|
|
209
|
+
if audio is None:
|
|
210
|
+
raise
|
|
211
|
+
# the ladder's middle rung (D20 §5): the model can't hear it — transcribe
|
|
212
|
+
# if the extra is there (MissingExtra keeps the two-fix skip), retry as text
|
|
213
|
+
transcript = await asyncio.to_thread(_transcribe_or_skip, audio, native_failure)
|
|
214
|
+
log.note(describe_source(item.source), "audio → text", _whisper_note())
|
|
215
|
+
spoken = f"{item.text}\n\n{transcript}".strip() if item.text else transcript
|
|
216
|
+
remaining = tuple(part for part in item.media if not isinstance(part, AudioData))
|
|
217
|
+
return await _attempt(model, plan, instruction, spoken, remaining)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _map_video(
|
|
221
|
+
model: ChatModel,
|
|
222
|
+
plan: MapPlan,
|
|
223
|
+
instruction: str,
|
|
224
|
+
item: Item,
|
|
225
|
+
video: VideoData,
|
|
226
|
+
log: diagnostics.DegradationLog,
|
|
227
|
+
*,
|
|
228
|
+
frame_every: float | None = None,
|
|
229
|
+
max_frames: int | None = None,
|
|
230
|
+
) -> str | Mapping[str, object]:
|
|
231
|
+
"""Video ladder (D27/D34): the real thing where the wire watches it (gemini
|
|
232
|
+
native accepts video; every other adapter refuses pre-send at zero cost),
|
|
233
|
+
then frames + heard track, then frames + transcript."""
|
|
234
|
+
try:
|
|
235
|
+
return await _attempt(model, plan, instruction, item.text, (video,))
|
|
236
|
+
except ItemError:
|
|
237
|
+
pass # this wire can't watch — convert (the refusal cost nothing)
|
|
238
|
+
from functools import partial as _partial
|
|
239
|
+
|
|
240
|
+
from smartpipe.parsing.extract import video_to_parts
|
|
241
|
+
|
|
242
|
+
sample = _partial(
|
|
243
|
+
video_to_parts,
|
|
244
|
+
video,
|
|
245
|
+
max_frames=max_frames if max_frames is not None else 24,
|
|
246
|
+
every_seconds=frame_every,
|
|
247
|
+
)
|
|
248
|
+
parts = await asyncio.to_thread(sample)
|
|
249
|
+
track = parts.track
|
|
250
|
+
detail = f"{len(parts.frames)} frames" + (" + audio" if track is not None else ", silent")
|
|
251
|
+
log.note(describe_source(item.source), "video → frames+audio", detail)
|
|
252
|
+
media: tuple[MediaData, ...] = (*parts.frames, track) if track is not None else parts.frames
|
|
253
|
+
try:
|
|
254
|
+
return await _attempt(model, plan, instruction, item.text, media)
|
|
255
|
+
except ItemError as native_failure:
|
|
256
|
+
if track is None:
|
|
257
|
+
raise
|
|
258
|
+
# the model saw frames but couldn't hear — transcribe the track, retry
|
|
259
|
+
transcript = await asyncio.to_thread(_transcribe_or_skip, track, native_failure)
|
|
260
|
+
log.note(describe_source(item.source), "video audio → text", _whisper_note())
|
|
261
|
+
spoken = f"{item.text}\n\n[audio track transcript]\n{transcript}".strip()
|
|
262
|
+
return await _attempt(model, plan, instruction, spoken, parts.frames)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _whisper_note() -> str:
|
|
266
|
+
import os
|
|
267
|
+
|
|
268
|
+
from smartpipe.parsing.extract import whisper_size
|
|
269
|
+
|
|
270
|
+
return f"whisper {whisper_size(os.environ)}"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def _attempt(
|
|
274
|
+
model: ChatModel,
|
|
275
|
+
plan: MapPlan,
|
|
276
|
+
instruction: str,
|
|
277
|
+
text: str,
|
|
278
|
+
media: tuple[MediaData, ...],
|
|
279
|
+
) -> str | Mapping[str, object]:
|
|
280
|
+
request = build_map_request(plan, instruction, text, media=media)
|
|
281
|
+
reply = await model.complete(request)
|
|
282
|
+
if plan.schema is None:
|
|
283
|
+
return reply.rstrip() # plain mode: keep the reply, only trim trailing whitespace
|
|
284
|
+
try:
|
|
285
|
+
return validate_and_coerce(reply, plan.schema)
|
|
286
|
+
except ItemError as first_error:
|
|
287
|
+
repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
|
|
288
|
+
repaired = await model.complete(repair)
|
|
289
|
+
return validate_and_coerce(repaired, plan.schema) # a second failure → Skipped
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _transcribe_or_skip(audio: AudioData, native_failure: ItemError) -> str:
|
|
293
|
+
from smartpipe.parsing.extract import MissingExtra, transcribe_audio
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
return transcribe_audio(audio)
|
|
297
|
+
except MissingExtra:
|
|
298
|
+
raise native_failure from None # the adapter's message already names both fixes
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _rows(
|
|
302
|
+
value: str | Mapping[str, object], explode_field: str | None
|
|
303
|
+
) -> list[str | Mapping[str, object]]:
|
|
304
|
+
if explode_field is None or not isinstance(value, Mapping):
|
|
305
|
+
return [value]
|
|
306
|
+
from smartpipe.engine.tally import explode_record
|
|
307
|
+
|
|
308
|
+
return list(explode_record(value, explode_field))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _write(writer: ResultWriter, *, structured: bool, value: str | Mapping[str, object]) -> None:
|
|
312
|
+
if structured and not isinstance(value, str):
|
|
313
|
+
writer.write_record(value)
|
|
314
|
+
else:
|
|
315
|
+
writer.write_text(value if isinstance(value, str) else str(value))
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The ``outliers`` verb: the items least like the rest (D38/04).
|
|
2
|
+
|
|
3
|
+
top_k's mirror — "farthest from everything" instead of "nearest to the
|
|
4
|
+
query". Embeddings only; the weirdness score is mean cosine distance to the
|
|
5
|
+
k nearest neighbors, which stays honest on multi-cluster corpora.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from smartpipe.core.errors import ExitCode, UsageFault
|
|
15
|
+
from smartpipe.engine.clustering import knn_mean_distance
|
|
16
|
+
from smartpipe.engine.runner import Done, FailurePolicy
|
|
17
|
+
from smartpipe.io import diagnostics, readers
|
|
18
|
+
from smartpipe.io.inputs import STDIN
|
|
19
|
+
from smartpipe.io.items import describe_source
|
|
20
|
+
from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
|
|
21
|
+
from smartpipe.verbs.common import embed_in_batches
|
|
22
|
+
from smartpipe.verbs.convert import make_converter
|
|
23
|
+
from smartpipe.verbs.distinct import DistinctContext
|
|
24
|
+
from smartpipe.verbs.embed import optional_chat
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from typing import TextIO
|
|
28
|
+
|
|
29
|
+
from smartpipe.io.inputs import InputSpec
|
|
30
|
+
from smartpipe.io.items import Item
|
|
31
|
+
|
|
32
|
+
__all__ = ["OutliersRequest", "run_outliers"]
|
|
33
|
+
|
|
34
|
+
_NEIGHBORS = 5 # kNN depth — internal, not a knob
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class OutliersRequest:
|
|
39
|
+
count: int = 5
|
|
40
|
+
model_flag: str | None = None
|
|
41
|
+
concurrency_flag: int | None = None
|
|
42
|
+
allow_captions: bool = False
|
|
43
|
+
input: InputSpec = STDIN
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def run_outliers(
|
|
47
|
+
request: OutliersRequest,
|
|
48
|
+
context: DistinctContext,
|
|
49
|
+
*,
|
|
50
|
+
stdin: TextIO,
|
|
51
|
+
stdout: TextIO,
|
|
52
|
+
stop: asyncio.Event | None = None,
|
|
53
|
+
) -> ExitCode:
|
|
54
|
+
if request.count < 1:
|
|
55
|
+
raise UsageFault("outliers needs a positive count")
|
|
56
|
+
model = await context.embedding_model(request.model_flag)
|
|
57
|
+
items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
58
|
+
items = [item async for item in items_iter]
|
|
59
|
+
if len(items) < 3:
|
|
60
|
+
raise UsageFault("outliers needs at least 3 items to know what normal looks like")
|
|
61
|
+
|
|
62
|
+
log = diagnostics.DegradationLog()
|
|
63
|
+
converter_chat = await optional_chat(context)
|
|
64
|
+
converter = make_converter(
|
|
65
|
+
converter_chat,
|
|
66
|
+
allow_paid=request.allow_captions,
|
|
67
|
+
log=log,
|
|
68
|
+
stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
|
|
69
|
+
)
|
|
70
|
+
scored_items: list[Item] = []
|
|
71
|
+
vectors: list[tuple[float, ...]] = []
|
|
72
|
+
position = 0
|
|
73
|
+
outcomes = embed_in_batches(
|
|
74
|
+
model,
|
|
75
|
+
items,
|
|
76
|
+
failure_policy=FailurePolicy(),
|
|
77
|
+
stop=stop,
|
|
78
|
+
log=log,
|
|
79
|
+
converter=converter,
|
|
80
|
+
)
|
|
81
|
+
async for outcome in outcomes:
|
|
82
|
+
if isinstance(outcome, Done):
|
|
83
|
+
embedded, vector = outcome.value
|
|
84
|
+
scored_items.append(embedded)
|
|
85
|
+
vectors.append(vector)
|
|
86
|
+
else: # an unexamined item can't be scored — excluded, disclosed
|
|
87
|
+
diagnostics.warn(f"excluded: {describe_source(outcome.source)} ({outcome.reason})")
|
|
88
|
+
position += 1
|
|
89
|
+
log.finish()
|
|
90
|
+
if len(vectors) < 3:
|
|
91
|
+
raise UsageFault("outliers needs at least 3 embeddable items")
|
|
92
|
+
|
|
93
|
+
distances = knn_mean_distance(vectors, k=_NEIGHBORS)
|
|
94
|
+
ranked = sorted(range(len(distances)), key=lambda index: -distances[index])
|
|
95
|
+
top = ranked[: request.count]
|
|
96
|
+
median = sorted(distances)[len(distances) // 2]
|
|
97
|
+
if median > 0:
|
|
98
|
+
low = distances[top[-1]] / median
|
|
99
|
+
high = distances[top[0]] / median
|
|
100
|
+
diagnostics.note(
|
|
101
|
+
f"outliers: median neighbor distance {median:.2f} — these are "
|
|
102
|
+
f"{low:.1f}x-{high:.1f}x out"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
writer = make_writer(
|
|
106
|
+
WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=None), stdout
|
|
107
|
+
)
|
|
108
|
+
for index in top:
|
|
109
|
+
item = scored_items[index]
|
|
110
|
+
record: dict[str, object]
|
|
111
|
+
if item.data is not None:
|
|
112
|
+
record = {key: value for key, value in item.data.items() if key != "vector"}
|
|
113
|
+
else:
|
|
114
|
+
record = {"text": item.raw}
|
|
115
|
+
record["_distance"] = round(distances[index], 4)
|
|
116
|
+
record.setdefault("source", describe_source(item.source))
|
|
117
|
+
writer.write_record(record)
|
|
118
|
+
writer.flush()
|
|
119
|
+
return ExitCode.OK
|