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,251 @@
|
|
|
1
|
+
"""Modality → text conversion for embedding and text verbs (D27/D33).
|
|
2
|
+
|
|
3
|
+
One ladder per modality, one cost fence: a LOCAL chat model converts for free,
|
|
4
|
+
automatically; a cloud model converts only behind ``--allow-captions``; whisper
|
|
5
|
+
is audio's always-there fallback. Every conversion is a per-row degraded note.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from smartpipe.core.errors import ItemError
|
|
14
|
+
from smartpipe.engine.schema import validate_and_coerce
|
|
15
|
+
from smartpipe.models.base import AudioData, CompletionRequest, ImageData, VideoData
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from smartpipe.io.diagnostics import DegradationLog
|
|
19
|
+
from smartpipe.io.items import Item
|
|
20
|
+
from smartpipe.models.base import ChatModel, EmbeddingModel
|
|
21
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
22
|
+
|
|
23
|
+
__all__ = ["IMAGE_NEEDS_CAPTION", "Converter", "embed_video_halves", "make_converter"]
|
|
24
|
+
|
|
25
|
+
AUDIO_TO_TEXT_SYSTEM = (
|
|
26
|
+
"You convert audio to text for search indexing. If the audio contains "
|
|
27
|
+
"speech, transcribe it verbatim. If it does not, describe the sound in one "
|
|
28
|
+
"or two factual sentences. Reply with only the transcript or description."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
IMAGE_TO_TEXT_SYSTEM = (
|
|
32
|
+
"You describe images for search indexing. Describe the image factually in "
|
|
33
|
+
"two or three sentences, including any visible text verbatim. Reply with "
|
|
34
|
+
"only the description."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
VIDEO_TO_TEXT_SYSTEM = (
|
|
38
|
+
"You convert video to text for search indexing. Describe what is shown in "
|
|
39
|
+
"two or three factual sentences, then transcribe all speech verbatim. "
|
|
40
|
+
"Reply with only the description and transcript."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
IMAGE_NEEDS_CAPTION = (
|
|
44
|
+
"image needs a description to embed — a local vision model does it free "
|
|
45
|
+
"(profile 'local'), or opt into paid captions: --allow-captions"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_CONVERT_MAX_TOKENS = 512
|
|
49
|
+
_CAPTION_FRAMES = 4 # the fallback captions a handful, not a filmstrip
|
|
50
|
+
|
|
51
|
+
_VIDEO_HALVES_SCHEMA: dict[str, object] = {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"visual": {"type": "string", "description": "what is shown, 2-3 sentences"},
|
|
55
|
+
"transcript": {"type": "string", "description": "all speech verbatim; empty if none"},
|
|
56
|
+
},
|
|
57
|
+
"required": ["visual", "transcript"],
|
|
58
|
+
"additionalProperties": False,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Converter:
|
|
64
|
+
"""The per-run conversion policy: which chat model (if any) may convert."""
|
|
65
|
+
|
|
66
|
+
chat: ChatModel | None # None: no model resolvable — lower rungs only
|
|
67
|
+
allow_paid: bool # --allow-captions
|
|
68
|
+
log: DegradationLog
|
|
69
|
+
stt: RemoteTranscriber | None = None # the stt-model role (D39/05): verbatim rung 0
|
|
70
|
+
|
|
71
|
+
def _meter_paid(self) -> None:
|
|
72
|
+
from smartpipe.io import metering
|
|
73
|
+
|
|
74
|
+
if self.chat is None or self.chat.ref.provider != "ollama":
|
|
75
|
+
metering.add_conversion() # cloud conversions bill; local ones don't
|
|
76
|
+
|
|
77
|
+
def _model_may_convert(self) -> bool:
|
|
78
|
+
if self.chat is None:
|
|
79
|
+
return False
|
|
80
|
+
return self.chat.ref.provider == "ollama" or self.allow_paid
|
|
81
|
+
|
|
82
|
+
def _rung_name(self) -> str:
|
|
83
|
+
assert self.chat is not None
|
|
84
|
+
return f"{self.chat.ref.provider}/{self.chat.ref.name}"
|
|
85
|
+
|
|
86
|
+
async def audio_to_text(self, audio: AudioData, where: str) -> str:
|
|
87
|
+
"""stt-model rung (verbatim, consent-gated) → LLM rung → whisper →
|
|
88
|
+
the two-fix skip. A configured transcriber runs FIRST: whoever set it
|
|
89
|
+
wants verbatim, and LLM hearing paraphrases (D39/05)."""
|
|
90
|
+
if self.stt is not None and self.allow_paid:
|
|
91
|
+
try:
|
|
92
|
+
transcript = await self.stt.transcribe(audio)
|
|
93
|
+
except ItemError:
|
|
94
|
+
transcript = "" # the wire hiccuped — the ladder continues below
|
|
95
|
+
if transcript:
|
|
96
|
+
self._meter_paid()
|
|
97
|
+
self.log.note(
|
|
98
|
+
where,
|
|
99
|
+
"audio → text",
|
|
100
|
+
f"transcribed by {self.stt.ref.provider}/{self.stt.ref.name}",
|
|
101
|
+
)
|
|
102
|
+
return transcript
|
|
103
|
+
if self._model_may_convert():
|
|
104
|
+
assert self.chat is not None
|
|
105
|
+
try:
|
|
106
|
+
text = await self.chat.complete(
|
|
107
|
+
CompletionRequest(
|
|
108
|
+
system=AUDIO_TO_TEXT_SYSTEM,
|
|
109
|
+
user="Convert this audio to text.",
|
|
110
|
+
media=(audio,),
|
|
111
|
+
max_tokens=_CONVERT_MAX_TOKENS,
|
|
112
|
+
presence_penalty=0.5, # prose call — anti-rambling (D35)
|
|
113
|
+
frequency_penalty=0.5,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
except ItemError:
|
|
117
|
+
text = None # the model can't hear — fall through to whisper
|
|
118
|
+
if text is not None and text.strip():
|
|
119
|
+
self._meter_paid()
|
|
120
|
+
self.log.note(where, "audio → text", f"heard by {self._rung_name()}")
|
|
121
|
+
return text.strip()
|
|
122
|
+
import asyncio
|
|
123
|
+
import os
|
|
124
|
+
|
|
125
|
+
from smartpipe.parsing.extract import whisper_size
|
|
126
|
+
|
|
127
|
+
transcript = await asyncio.to_thread(_whisper_or_skip, audio)
|
|
128
|
+
|
|
129
|
+
self.log.note(where, "audio → text", f"whisper {whisper_size(os.environ)}")
|
|
130
|
+
return transcript
|
|
131
|
+
|
|
132
|
+
async def video_halves(self, video: VideoData, where: str) -> tuple[str | None, str | None]:
|
|
133
|
+
"""(visual, speech) — the two halves of a video's meaning (D36).
|
|
134
|
+
|
|
135
|
+
Rung 0: ONE watching call returns both via a response schema (gemini
|
|
136
|
+
native accepts; every other wire refuses pre-send, free). Rung 1: frame
|
|
137
|
+
captions + the track through the audio ladder. Rung 2: track only."""
|
|
138
|
+
if self._model_may_convert():
|
|
139
|
+
assert self.chat is not None
|
|
140
|
+
try:
|
|
141
|
+
reply = await self.chat.complete(
|
|
142
|
+
CompletionRequest(
|
|
143
|
+
system=VIDEO_TO_TEXT_SYSTEM,
|
|
144
|
+
user="Convert this video to text.",
|
|
145
|
+
media=(video,),
|
|
146
|
+
json_schema=_VIDEO_HALVES_SCHEMA,
|
|
147
|
+
max_tokens=_CONVERT_MAX_TOKENS,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
halves = validate_and_coerce(reply, _VIDEO_HALVES_SCHEMA)
|
|
151
|
+
visual = str(halves.get("visual") or "").strip() or None
|
|
152
|
+
speech = str(halves.get("transcript") or "").strip() or None
|
|
153
|
+
if visual or speech:
|
|
154
|
+
self._meter_paid()
|
|
155
|
+
self.log.note(where, "video → text", f"watched by {self._rung_name()}")
|
|
156
|
+
return visual, speech
|
|
157
|
+
except ItemError:
|
|
158
|
+
pass # this wire can't watch — the refusal cost nothing
|
|
159
|
+
import asyncio
|
|
160
|
+
|
|
161
|
+
from smartpipe.parsing.extract import video_to_parts
|
|
162
|
+
|
|
163
|
+
parts = await asyncio.to_thread(video_to_parts, video, max_frames=_CAPTION_FRAMES)
|
|
164
|
+
visual = None
|
|
165
|
+
if self._model_may_convert() and parts.frames:
|
|
166
|
+
captions = [await self.image_to_text(frame, where) for frame in parts.frames]
|
|
167
|
+
visual = "\n".join(
|
|
168
|
+
f"[scene {position}] {caption}"
|
|
169
|
+
for position, caption in enumerate(captions, start=1)
|
|
170
|
+
)
|
|
171
|
+
speech = None
|
|
172
|
+
if parts.track is not None:
|
|
173
|
+
speech = await self.audio_to_text(parts.track, where)
|
|
174
|
+
if visual is None and speech is None:
|
|
175
|
+
raise ItemError(
|
|
176
|
+
"this video has no audio track and no model can describe its "
|
|
177
|
+
"frames — map can still see them"
|
|
178
|
+
)
|
|
179
|
+
if visual is None:
|
|
180
|
+
self.log.note(
|
|
181
|
+
where, "video → text", "audio track only; frames dropped — map sees frames"
|
|
182
|
+
)
|
|
183
|
+
return visual, speech
|
|
184
|
+
|
|
185
|
+
async def image_to_text(self, image: ImageData, where: str) -> str:
|
|
186
|
+
"""LLM rung or nothing — there is no free non-LLM rung for images."""
|
|
187
|
+
if not self._model_may_convert():
|
|
188
|
+
raise ItemError(IMAGE_NEEDS_CAPTION)
|
|
189
|
+
assert self.chat is not None
|
|
190
|
+
text = await self.chat.complete(
|
|
191
|
+
CompletionRequest(
|
|
192
|
+
system=IMAGE_TO_TEXT_SYSTEM,
|
|
193
|
+
user="Describe this image.",
|
|
194
|
+
media=(image,),
|
|
195
|
+
max_tokens=_CONVERT_MAX_TOKENS,
|
|
196
|
+
presence_penalty=0.5, # prose call — anti-rambling (D35)
|
|
197
|
+
frequency_penalty=0.5,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
self._meter_paid()
|
|
201
|
+
self.log.note(where, "image → text", f"described by {self._rung_name()}")
|
|
202
|
+
return text.strip()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def embed_video_halves(
|
|
206
|
+
model: EmbeddingModel,
|
|
207
|
+
item: Item,
|
|
208
|
+
video: VideoData,
|
|
209
|
+
converter: Converter,
|
|
210
|
+
) -> tuple[Item, tuple[float, ...]]:
|
|
211
|
+
"""D36: a video's vector is the FAIR AVERAGE of its two halves — the visual
|
|
212
|
+
description and the speech transcript embedded separately and mean-pooled
|
|
213
|
+
50/50, so neither drowns the other. Returns (converted item, vector)."""
|
|
214
|
+
from dataclasses import replace
|
|
215
|
+
|
|
216
|
+
from smartpipe.engine.chunking import mean_pool
|
|
217
|
+
from smartpipe.io.items import describe_source
|
|
218
|
+
|
|
219
|
+
visual, speech = await converter.video_halves(video, describe_source(item.source))
|
|
220
|
+
texts = [part for part in (visual, speech) if part]
|
|
221
|
+
vectors = await model.embed(texts)
|
|
222
|
+
vector = mean_pool(vectors)
|
|
223
|
+
converted = replace(item, text="\n\n".join(texts), media=())
|
|
224
|
+
return converted, vector
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
AUDIO_NEEDS_TEXT = (
|
|
228
|
+
"audio items need text here — local transcription failed, "
|
|
229
|
+
"and no audio-capable model is configured (try map, or set stt-model)"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _whisper_or_skip(audio: AudioData) -> str:
|
|
234
|
+
"""Whisper with the pinned two-fix skip (self-contained: no common import —
|
|
235
|
+
verbs.common imports THIS module, and a cycle turns types Unknown)."""
|
|
236
|
+
from smartpipe.parsing.extract import MissingExtra, transcribe_audio
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
return transcribe_audio(audio)
|
|
240
|
+
except MissingExtra as exc:
|
|
241
|
+
raise ItemError(AUDIO_NEEDS_TEXT) from exc
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def make_converter(
|
|
245
|
+
chat: ChatModel | None,
|
|
246
|
+
*,
|
|
247
|
+
allow_paid: bool,
|
|
248
|
+
log: DegradationLog,
|
|
249
|
+
stt: RemoteTranscriber | None = None,
|
|
250
|
+
) -> Converter:
|
|
251
|
+
return Converter(chat=chat, allow_paid=allow_paid, log=log, stt=stt)
|
smartpipe/verbs/diff.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The ``diff`` verb: what distinguishes two sets of items (D38/06).
|
|
2
|
+
|
|
3
|
+
KQL's ``diffpatterns`` for meaning: embed both sides, cluster the union,
|
|
4
|
+
keep the lopsided themes. The post-incident "what changed" (P2), the eval
|
|
5
|
+
regression story (P7), and dataset drift before the GPU bill (P12).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from smartpipe.core.errors import ExitCode, UsageFault
|
|
15
|
+
from smartpipe.engine.chunking import mean_pool
|
|
16
|
+
from smartpipe.engine.clustering import adaptive_threshold, leader_clusters
|
|
17
|
+
from smartpipe.engine.ranking import cosine
|
|
18
|
+
from smartpipe.engine.runner import Done, FailurePolicy
|
|
19
|
+
from smartpipe.io import diagnostics, readers
|
|
20
|
+
from smartpipe.io.inputs import STDIN
|
|
21
|
+
from smartpipe.io.items import ItemSource, describe_source, item_from_line
|
|
22
|
+
from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
|
|
23
|
+
from smartpipe.verbs.cluster import ClusterContext, label_cluster
|
|
24
|
+
from smartpipe.verbs.common import embed_in_batches
|
|
25
|
+
from smartpipe.verbs.convert import make_converter
|
|
26
|
+
from smartpipe.verbs.embed import optional_chat
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TextIO
|
|
31
|
+
|
|
32
|
+
from smartpipe.io.items import Item
|
|
33
|
+
|
|
34
|
+
__all__ = ["DiffRequest", "run_diff"]
|
|
35
|
+
|
|
36
|
+
_MIN_LOPSIDEDNESS = 0.05 # below this a theme is "both sides" — omitted by default
|
|
37
|
+
_EXAMPLES = 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class DiffRequest:
|
|
42
|
+
right: Path
|
|
43
|
+
top: int | None = None
|
|
44
|
+
show_all: bool = False
|
|
45
|
+
model_flag: str | None = None # chat, for theme labels
|
|
46
|
+
embed_flag: str | None = None
|
|
47
|
+
concurrency_flag: int | None = None
|
|
48
|
+
allow_captions: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def run_diff(
|
|
52
|
+
request: DiffRequest,
|
|
53
|
+
context: ClusterContext,
|
|
54
|
+
*,
|
|
55
|
+
stdin: TextIO,
|
|
56
|
+
stdout: TextIO,
|
|
57
|
+
stop: asyncio.Event | None = None,
|
|
58
|
+
) -> ExitCode:
|
|
59
|
+
model = await context.embedding_model(request.embed_flag)
|
|
60
|
+
left_iter, _total = readers.resolve_items(STDIN, stdin, stop=stop)
|
|
61
|
+
left = [item async for item in left_iter]
|
|
62
|
+
right = _read_right(request.right)
|
|
63
|
+
if not left or not right:
|
|
64
|
+
raise UsageFault("diff needs items on BOTH sides — left is stdin, right is --right FILE")
|
|
65
|
+
boundary = len(left)
|
|
66
|
+
diagnostics.note(
|
|
67
|
+
f"diff: left = stdin ({len(left):,}) · right = {request.right.name} "
|
|
68
|
+
f"({len(right):,}) · ~{len(left) + len(right):,} embeddings + labels for "
|
|
69
|
+
"the lopsided themes"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
log = diagnostics.DegradationLog()
|
|
73
|
+
converter_chat = await optional_chat(context)
|
|
74
|
+
converter = make_converter(
|
|
75
|
+
converter_chat,
|
|
76
|
+
allow_paid=request.allow_captions,
|
|
77
|
+
log=log,
|
|
78
|
+
stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
|
|
79
|
+
)
|
|
80
|
+
union_items: list[Item] = []
|
|
81
|
+
vectors: list[tuple[float, ...]] = []
|
|
82
|
+
outcomes = embed_in_batches(
|
|
83
|
+
model,
|
|
84
|
+
[*left, *right],
|
|
85
|
+
failure_policy=FailurePolicy(),
|
|
86
|
+
stop=stop,
|
|
87
|
+
log=log,
|
|
88
|
+
converter=converter,
|
|
89
|
+
)
|
|
90
|
+
position = 0
|
|
91
|
+
kept_positions: list[int] = []
|
|
92
|
+
async for outcome in outcomes:
|
|
93
|
+
if isinstance(outcome, Done):
|
|
94
|
+
embedded, vector = outcome.value
|
|
95
|
+
union_items.append(embedded)
|
|
96
|
+
vectors.append(vector)
|
|
97
|
+
kept_positions.append(position)
|
|
98
|
+
else:
|
|
99
|
+
diagnostics.warn(f"excluded: {describe_source(outcome.source)} ({outcome.reason})")
|
|
100
|
+
position += 1
|
|
101
|
+
log.finish()
|
|
102
|
+
if not vectors:
|
|
103
|
+
return ExitCode.ALL_FAILED
|
|
104
|
+
|
|
105
|
+
left_total = sum(1 for kept in kept_positions if kept < boundary)
|
|
106
|
+
right_total = len(kept_positions) - left_total
|
|
107
|
+
if left_total == 0 or right_total == 0:
|
|
108
|
+
raise UsageFault("diff needs embeddable items on both sides")
|
|
109
|
+
|
|
110
|
+
clusters = leader_clusters(vectors, threshold=adaptive_threshold(vectors))
|
|
111
|
+
themes = [
|
|
112
|
+
_theme(members, kept_positions, boundary, left_total, right_total) for members in clusters
|
|
113
|
+
]
|
|
114
|
+
themes.sort(key=lambda theme: -theme.lopsidedness)
|
|
115
|
+
|
|
116
|
+
chat = None
|
|
117
|
+
lopsided = [theme for theme in themes if theme.side != "both"]
|
|
118
|
+
shown = lopsided if request.top is None else lopsided[: request.top]
|
|
119
|
+
if request.show_all:
|
|
120
|
+
shown = [*shown, *(theme for theme in themes if theme.side == "both")]
|
|
121
|
+
if shown:
|
|
122
|
+
if request.model_flag is not None:
|
|
123
|
+
chat = await context.chat_model(request.model_flag)
|
|
124
|
+
else:
|
|
125
|
+
chat = await optional_chat(context)
|
|
126
|
+
|
|
127
|
+
writer = make_writer(
|
|
128
|
+
WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=None), stdout
|
|
129
|
+
)
|
|
130
|
+
for number, theme in enumerate(shown, start=1):
|
|
131
|
+
if chat is not None:
|
|
132
|
+
texts = [union_items[member].text for member in theme.dominant_members[:8]]
|
|
133
|
+
label = await label_cluster(chat, texts, fallback=f"cluster {number}")
|
|
134
|
+
else:
|
|
135
|
+
label = f"cluster {number}"
|
|
136
|
+
writer.write_record(
|
|
137
|
+
{
|
|
138
|
+
"side": theme.side,
|
|
139
|
+
"theme": label,
|
|
140
|
+
"share_left": round(theme.share_left, 2),
|
|
141
|
+
"share_right": round(theme.share_right, 2),
|
|
142
|
+
"examples": _examples(theme.dominant_members, union_items, vectors),
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
writer.flush()
|
|
146
|
+
omitted = len(themes) - len(lopsided)
|
|
147
|
+
if omitted and not request.show_all:
|
|
148
|
+
diagnostics.note(f"diff: {omitted} shared theme(s) omitted — --all shows them")
|
|
149
|
+
if chat is None and shown:
|
|
150
|
+
diagnostics.note("no chat model — themes are unnamed; configure one: smartpipe config")
|
|
151
|
+
return ExitCode.OK
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class _Theme:
|
|
156
|
+
side: str # "left" | "right" | "both"
|
|
157
|
+
share_left: float
|
|
158
|
+
share_right: float
|
|
159
|
+
lopsidedness: float
|
|
160
|
+
dominant_members: tuple[int, ...] # indices into union_items, dominant side first
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _theme(
|
|
164
|
+
members: list[int],
|
|
165
|
+
kept_positions: list[int],
|
|
166
|
+
boundary: int,
|
|
167
|
+
left_total: int,
|
|
168
|
+
right_total: int,
|
|
169
|
+
) -> _Theme:
|
|
170
|
+
left_members = [m for m in members if kept_positions[m] < boundary]
|
|
171
|
+
right_members = [m for m in members if kept_positions[m] >= boundary]
|
|
172
|
+
share_left = len(left_members) / left_total
|
|
173
|
+
share_right = len(right_members) / right_total
|
|
174
|
+
lopsidedness = abs(share_left - share_right)
|
|
175
|
+
dominant = left_members if share_left >= share_right else right_members
|
|
176
|
+
minority = right_members if share_left >= share_right else left_members
|
|
177
|
+
side = "both"
|
|
178
|
+
if lopsidedness >= _MIN_LOPSIDEDNESS and len(dominant) >= 2:
|
|
179
|
+
side = "left" if share_left > share_right else "right"
|
|
180
|
+
return _Theme(
|
|
181
|
+
side=side,
|
|
182
|
+
share_left=share_left,
|
|
183
|
+
share_right=share_right,
|
|
184
|
+
lopsidedness=lopsidedness,
|
|
185
|
+
dominant_members=(*dominant, *minority),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _examples(
|
|
190
|
+
members: tuple[int, ...], items: list[Item], vectors: list[tuple[float, ...]]
|
|
191
|
+
) -> list[str]:
|
|
192
|
+
pool = list(members[: _EXAMPLES * 4])
|
|
193
|
+
centroid = mean_pool([vectors[member] for member in pool])
|
|
194
|
+
nearest = sorted(pool, key=lambda member: -cosine(vectors[member], centroid))
|
|
195
|
+
return [items[member].text[:160] for member in nearest[:_EXAMPLES]]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_right(path: Path) -> list[Item]:
|
|
199
|
+
if not path.exists():
|
|
200
|
+
raise UsageFault(f"no such file: {path}\n --right needs a JSONL or plain-lines file.")
|
|
201
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
202
|
+
return [
|
|
203
|
+
replace(item_from_line(line, index), source=ItemSource("file", path.name, index))
|
|
204
|
+
for index, line in enumerate(lines)
|
|
205
|
+
if line.strip()
|
|
206
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""The ``distinct`` verb: near-duplicate folding (D38/03, KQL ``distinct``).
|
|
2
|
+
|
|
3
|
+
Exact duplicates fold for free (hashing, before any embedding is spent); the
|
|
4
|
+
rest embed once and leader-cluster. First occurrence wins, input order and
|
|
5
|
+
bytes are preserved, and the receipt states exactly what was folded — the
|
|
6
|
+
training-data decontamination move (P12) and the alert-storm collapser (P6).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol
|
|
14
|
+
|
|
15
|
+
from smartpipe.core.errors import ExitCode, UsageFault
|
|
16
|
+
from smartpipe.engine.clustering import leader_clusters
|
|
17
|
+
from smartpipe.engine.runner import Done, FailurePolicy
|
|
18
|
+
from smartpipe.io import diagnostics, readers
|
|
19
|
+
from smartpipe.io.inputs import STDIN
|
|
20
|
+
from smartpipe.io.items import describe_source
|
|
21
|
+
from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
|
|
22
|
+
from smartpipe.verbs.common import embed_in_batches
|
|
23
|
+
from smartpipe.verbs.convert import make_converter
|
|
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
|
+
from smartpipe.models.base import ChatModel, EmbeddingModel, ModelRef
|
|
32
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
33
|
+
|
|
34
|
+
__all__ = ["DistinctRequest", "run_distinct"]
|
|
35
|
+
|
|
36
|
+
_DEFAULT_THRESHOLD = 0.90
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class DistinctRequest:
|
|
41
|
+
show_groups: bool = False
|
|
42
|
+
threshold: float = _DEFAULT_THRESHOLD
|
|
43
|
+
model_flag: str | None = None
|
|
44
|
+
concurrency_flag: int | None = None
|
|
45
|
+
allow_captions: bool = False
|
|
46
|
+
input: InputSpec = STDIN
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DistinctContext(Protocol):
|
|
50
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
|
|
51
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
52
|
+
async def embedding_model(self, flag: str | None = None) -> EmbeddingModel: ...
|
|
53
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def run_distinct(
|
|
57
|
+
request: DistinctRequest,
|
|
58
|
+
context: DistinctContext,
|
|
59
|
+
*,
|
|
60
|
+
stdin: TextIO,
|
|
61
|
+
stdout: TextIO,
|
|
62
|
+
stop: asyncio.Event | None = None,
|
|
63
|
+
) -> ExitCode:
|
|
64
|
+
if not 0.0 < request.threshold <= 1.0:
|
|
65
|
+
raise UsageFault("--threshold is a cosine similarity: between 0 and 1")
|
|
66
|
+
model = await context.embedding_model(request.model_flag)
|
|
67
|
+
items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
68
|
+
items = [item async for item in items_iter]
|
|
69
|
+
if not items:
|
|
70
|
+
return ExitCode.OK
|
|
71
|
+
|
|
72
|
+
# exact fast path: identical text folds for free, before any embedding
|
|
73
|
+
first_of: dict[str, int] = {}
|
|
74
|
+
exact_dupes_of: dict[int, list[int]] = {}
|
|
75
|
+
uniques: list[Item] = []
|
|
76
|
+
unique_positions: list[int] = []
|
|
77
|
+
exact_folded = 0
|
|
78
|
+
for position, item in enumerate(items):
|
|
79
|
+
key = item.text.strip() if not item.media else f"\x00media:{position}"
|
|
80
|
+
seen_at = first_of.get(key)
|
|
81
|
+
if seen_at is not None:
|
|
82
|
+
exact_dupes_of.setdefault(seen_at, []).append(position)
|
|
83
|
+
exact_folded += 1
|
|
84
|
+
continue
|
|
85
|
+
first_of[key] = position
|
|
86
|
+
uniques.append(item)
|
|
87
|
+
unique_positions.append(position)
|
|
88
|
+
|
|
89
|
+
log = diagnostics.DegradationLog()
|
|
90
|
+
converter_chat = await optional_chat(context)
|
|
91
|
+
converter = make_converter(
|
|
92
|
+
converter_chat,
|
|
93
|
+
allow_paid=request.allow_captions,
|
|
94
|
+
log=log,
|
|
95
|
+
stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
|
|
96
|
+
)
|
|
97
|
+
vectors: dict[int, tuple[float, ...]] = {} # original position → vector
|
|
98
|
+
unexamined: list[int] = [] # embed-skipped: kept, disclosed
|
|
99
|
+
outcomes = embed_in_batches(
|
|
100
|
+
model,
|
|
101
|
+
uniques,
|
|
102
|
+
failure_policy=FailurePolicy(),
|
|
103
|
+
stop=stop,
|
|
104
|
+
log=log,
|
|
105
|
+
converter=converter,
|
|
106
|
+
)
|
|
107
|
+
embed_order: list[int] = [] # positions, in outcome order
|
|
108
|
+
async for outcome in outcomes:
|
|
109
|
+
if isinstance(outcome, Done):
|
|
110
|
+
embedded_item, vector = outcome.value
|
|
111
|
+
del embedded_item
|
|
112
|
+
position = unique_positions[len(embed_order) + len(unexamined)]
|
|
113
|
+
vectors[position] = vector
|
|
114
|
+
embed_order.append(position)
|
|
115
|
+
else: # Skipped: keep the item — never silently drop what we couldn't compare
|
|
116
|
+
position = unique_positions[len(embed_order) + len(unexamined)]
|
|
117
|
+
unexamined.append(position)
|
|
118
|
+
diagnostics.warn(
|
|
119
|
+
f"kept unexamined: {describe_source(outcome.source)} ({outcome.reason})"
|
|
120
|
+
)
|
|
121
|
+
log.finish()
|
|
122
|
+
|
|
123
|
+
clusters = leader_clusters([vectors[p] for p in embed_order], threshold=request.threshold)
|
|
124
|
+
kept: set[int] = set(unexamined)
|
|
125
|
+
near_dupes_of: dict[int, list[int]] = {}
|
|
126
|
+
near_folded = 0
|
|
127
|
+
for members in clusters:
|
|
128
|
+
leader_position = embed_order[members[0]]
|
|
129
|
+
kept.add(leader_position)
|
|
130
|
+
followers = [embed_order[m] for m in members[1:]]
|
|
131
|
+
near_dupes_of[leader_position] = followers
|
|
132
|
+
near_folded += len(followers)
|
|
133
|
+
|
|
134
|
+
total = len(items)
|
|
135
|
+
diagnostics.note(
|
|
136
|
+
f"distinct: kept {len(kept):,} of {total:,} "
|
|
137
|
+
f"({exact_folded:,} exact + {near_folded:,} near duplicates folded)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if request.show_groups:
|
|
141
|
+
writer = make_writer(
|
|
142
|
+
WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=None), stdout
|
|
143
|
+
)
|
|
144
|
+
for position in sorted(kept):
|
|
145
|
+
duplicates = [
|
|
146
|
+
items[p].text
|
|
147
|
+
for p in (*near_dupes_of.get(position, ()), *exact_dupes_of.get(position, ()))
|
|
148
|
+
]
|
|
149
|
+
# exact dupes of folded near-dupes belong to the group too
|
|
150
|
+
for follower in near_dupes_of.get(position, ()):
|
|
151
|
+
duplicates.extend(items[p].text for p in exact_dupes_of.get(follower, ()))
|
|
152
|
+
writer.write_record(
|
|
153
|
+
{
|
|
154
|
+
"kept": items[position].text,
|
|
155
|
+
"count": 1 + len(duplicates),
|
|
156
|
+
"duplicates": duplicates,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
writer.flush()
|
|
160
|
+
return ExitCode.OK
|
|
161
|
+
|
|
162
|
+
for position in sorted(kept):
|
|
163
|
+
stdout.write(items[position].raw + "\n")
|
|
164
|
+
return ExitCode.OK
|