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,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)
@@ -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