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/join.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""The ``join`` verb (D21): match stdin against a second input, semantically.
|
|
2
|
+
|
|
3
|
+
Embed → block → judge: the right side (``--right``) is finite — read whole,
|
|
4
|
+
embedded once (chunked), held as an in-memory index; each left item embeds,
|
|
5
|
+
blocks to its top-K nearest candidates, and only those pairs reach the chat
|
|
6
|
+
model with the filter-style verdict schema. N·K calls, never N·M.
|
|
7
|
+
|
|
8
|
+
Fail-before-spend order (D18): flags → grammar → right file exists/parses/
|
|
9
|
+
non-empty → right side fully embedded → cost preview → the first judge call.
|
|
10
|
+
A bad right side costs zero chat calls.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
from typing import TYPE_CHECKING, Protocol
|
|
18
|
+
|
|
19
|
+
from smartpipe.core.errors import ExitCode, ItemError, TooManyFailures, UsageFault
|
|
20
|
+
from smartpipe.engine.blocking import RightIndex, build_index, candidates
|
|
21
|
+
from smartpipe.engine.chunking import estimate_tokens, mean_pool, split_text
|
|
22
|
+
from smartpipe.engine.prompts import (
|
|
23
|
+
JUDGE_SCHEMA,
|
|
24
|
+
build_judge_request,
|
|
25
|
+
build_repair_request,
|
|
26
|
+
parse_join_predicate,
|
|
27
|
+
)
|
|
28
|
+
from smartpipe.engine.ranking import rank
|
|
29
|
+
from smartpipe.engine.runner import (
|
|
30
|
+
Done,
|
|
31
|
+
FailurePolicy,
|
|
32
|
+
run_ordered,
|
|
33
|
+
should_halt,
|
|
34
|
+
should_halt_consecutive,
|
|
35
|
+
)
|
|
36
|
+
from smartpipe.engine.schema import validate_and_coerce
|
|
37
|
+
from smartpipe.io import diagnostics, readers
|
|
38
|
+
from smartpipe.io.inputs import STDIN
|
|
39
|
+
from smartpipe.io.items import ItemSource, describe_source, item_from_line
|
|
40
|
+
from smartpipe.io.progress import make_stderr_spinner
|
|
41
|
+
from smartpipe.verbs.common import (
|
|
42
|
+
embed_budget,
|
|
43
|
+
embed_in_batches,
|
|
44
|
+
ensure_text,
|
|
45
|
+
interrupted_exit_code,
|
|
46
|
+
outcome_exit_code,
|
|
47
|
+
)
|
|
48
|
+
from smartpipe.verbs.convert import Converter, make_converter
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import TextIO
|
|
53
|
+
|
|
54
|
+
from smartpipe.engine.prompts import Token
|
|
55
|
+
from smartpipe.io.inputs import InputSpec
|
|
56
|
+
from smartpipe.io.items import Item
|
|
57
|
+
from smartpipe.io.writers import OutputFormat, ResultWriter
|
|
58
|
+
from smartpipe.models.base import ChatModel, EmbeddingModel, ModelRef
|
|
59
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
60
|
+
|
|
61
|
+
__all__ = ["JoinContext", "JoinRequest", "PairBook", "run_join"]
|
|
62
|
+
|
|
63
|
+
_PREVIEW_THRESHOLD = 200 # estimated judge calls before the cost line appears (D21)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class JoinRequest:
|
|
68
|
+
predicate: str
|
|
69
|
+
right: Path
|
|
70
|
+
k: int
|
|
71
|
+
threshold: float | None
|
|
72
|
+
model_flag: str | None
|
|
73
|
+
embed_model_flag: str | None
|
|
74
|
+
concurrency_flag: int | None
|
|
75
|
+
output: OutputFormat
|
|
76
|
+
input: InputSpec = STDIN
|
|
77
|
+
fields: tuple[str, ...] | None = None
|
|
78
|
+
unmatched: Path | None = None # write zero-match left items here, verbatim
|
|
79
|
+
allow_captions: bool = False # cloud conversions opt-in (D33)
|
|
80
|
+
kind: str = "inner" # inner | leftouter | anti (D38/11)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class JoinContext(Protocol):
|
|
84
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
|
|
85
|
+
|
|
86
|
+
"""The first verb that needs BOTH models — the container already has both."""
|
|
87
|
+
|
|
88
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
89
|
+
async def embedding_model(self, flag: str | None = None) -> EmbeddingModel: ...
|
|
90
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
91
|
+
def writer(
|
|
92
|
+
self,
|
|
93
|
+
output_flag: OutputFormat,
|
|
94
|
+
*,
|
|
95
|
+
structured: bool,
|
|
96
|
+
stdout: TextIO,
|
|
97
|
+
fields: tuple[str, ...] | None = None,
|
|
98
|
+
) -> ResultWriter: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class PairBook:
|
|
103
|
+
"""Judge-call accounting (D21: the halt policies count judge calls)."""
|
|
104
|
+
|
|
105
|
+
policy: FailurePolicy
|
|
106
|
+
right_name: str
|
|
107
|
+
judged: int = 0
|
|
108
|
+
skipped: int = 0
|
|
109
|
+
consecutive: int = 0
|
|
110
|
+
succeeded: bool = False
|
|
111
|
+
|
|
112
|
+
def ok(self) -> None:
|
|
113
|
+
self.judged += 1
|
|
114
|
+
self.consecutive = 0
|
|
115
|
+
self.succeeded = True
|
|
116
|
+
|
|
117
|
+
def skip(self, left: Item, right_position: int, reason: str) -> None:
|
|
118
|
+
self.judged += 1
|
|
119
|
+
self.skipped += 1
|
|
120
|
+
self.consecutive += 1
|
|
121
|
+
diagnostics.warn(
|
|
122
|
+
f"skipped: {describe_source(left.source)} × " # noqa: RUF001 — pinned pair mark
|
|
123
|
+
f"{self.right_name} line {right_position + 1} ({reason})"
|
|
124
|
+
)
|
|
125
|
+
if should_halt(self.policy, total=self.judged, skipped=self.skipped):
|
|
126
|
+
raise TooManyFailures(self.skipped, self.judged, reason)
|
|
127
|
+
if should_halt_consecutive(
|
|
128
|
+
self.policy, succeeded=self.succeeded, consecutive=self.consecutive
|
|
129
|
+
):
|
|
130
|
+
raise TooManyFailures(self.skipped, self.judged, reason)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def run_join(
|
|
134
|
+
request: JoinRequest,
|
|
135
|
+
context: JoinContext,
|
|
136
|
+
*,
|
|
137
|
+
stdin: TextIO,
|
|
138
|
+
stdout: TextIO,
|
|
139
|
+
stop: asyncio.Event | None = None,
|
|
140
|
+
) -> ExitCode:
|
|
141
|
+
tokens = parse_join_predicate(request.predicate) # UsageFault on bad grammar
|
|
142
|
+
if request.k < 1:
|
|
143
|
+
raise UsageFault(f"--k must be >= 1, got {request.k}")
|
|
144
|
+
right_items = _load_right(request.right)
|
|
145
|
+
embed_model = await context.embedding_model(request.embed_model_flag)
|
|
146
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
147
|
+
kept_right, index, right_chunks = await _index_right(
|
|
148
|
+
embed_model, right_items, request.right.name, log
|
|
149
|
+
)
|
|
150
|
+
chat = await context.chat_model(request.model_flag)
|
|
151
|
+
concurrency = context.concurrency(request.concurrency_flag)
|
|
152
|
+
if request.kind not in ("inner", "leftouter", "anti"):
|
|
153
|
+
raise UsageFault("--kind takes inner, leftouter, or anti")
|
|
154
|
+
if request.kind == "anti" and request.unmatched is not None:
|
|
155
|
+
raise UsageFault(
|
|
156
|
+
"--unmatched with --kind anti is redundant — anti already puts unmatched rows on stdout"
|
|
157
|
+
)
|
|
158
|
+
writer = context.writer(
|
|
159
|
+
request.output,
|
|
160
|
+
structured=request.kind != "anti", # anti emits left rows verbatim
|
|
161
|
+
stdout=stdout,
|
|
162
|
+
fields=request.fields,
|
|
163
|
+
)
|
|
164
|
+
items_iter, total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
165
|
+
preview_cost(total, request.k, len(index))
|
|
166
|
+
|
|
167
|
+
converter = make_converter(
|
|
168
|
+
chat, allow_paid=request.allow_captions, log=log, stt=context.remote_transcriber(chat.ref)
|
|
169
|
+
)
|
|
170
|
+
book = PairBook(policy=FailurePolicy(), right_name=request.right.name)
|
|
171
|
+
spinner = make_stderr_spinner()
|
|
172
|
+
spinner.start(total=total)
|
|
173
|
+
|
|
174
|
+
async def worker(item: Item) -> tuple[Item, tuple[tuple[int, float], ...]]:
|
|
175
|
+
matches = await _join_one(
|
|
176
|
+
item,
|
|
177
|
+
log=log,
|
|
178
|
+
converter=converter,
|
|
179
|
+
embed_model=embed_model,
|
|
180
|
+
chat=chat,
|
|
181
|
+
tokens=tokens,
|
|
182
|
+
index=index,
|
|
183
|
+
kept_right=kept_right,
|
|
184
|
+
right_chunks=right_chunks,
|
|
185
|
+
request=request,
|
|
186
|
+
book=book,
|
|
187
|
+
stop=stop,
|
|
188
|
+
)
|
|
189
|
+
return item, matches
|
|
190
|
+
|
|
191
|
+
done = 0
|
|
192
|
+
skipped = 0
|
|
193
|
+
outcomes = run_ordered(
|
|
194
|
+
items_iter, worker, concurrency=concurrency, failure_policy=FailurePolicy(), stop=stop
|
|
195
|
+
)
|
|
196
|
+
matched_pairs = 0
|
|
197
|
+
unmatched_count = 0
|
|
198
|
+
unmatched_sink = (
|
|
199
|
+
request.unmatched.open("w", encoding="utf-8") if request.unmatched is not None else None
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
async for outcome in outcomes:
|
|
203
|
+
if isinstance(outcome, Done):
|
|
204
|
+
left, matches = outcome.value
|
|
205
|
+
if request.kind != "anti":
|
|
206
|
+
for position, score in matches:
|
|
207
|
+
writer.write_record(
|
|
208
|
+
{
|
|
209
|
+
"left": _payload(left),
|
|
210
|
+
"right": _payload(kept_right[position]),
|
|
211
|
+
"_score": round(score, 4),
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
matched_pairs += len(matches)
|
|
215
|
+
if not matches:
|
|
216
|
+
unmatched_count += 1
|
|
217
|
+
match request.kind:
|
|
218
|
+
case "anti": # the unmatched row IS the finding — verbatim
|
|
219
|
+
writer.write_text(left.raw)
|
|
220
|
+
case "leftouter": # every left row, match or not
|
|
221
|
+
writer.write_record({"left": _payload(left), "right": None})
|
|
222
|
+
case _:
|
|
223
|
+
if unmatched_sink is not None:
|
|
224
|
+
unmatched_sink.write(left.raw + "\n")
|
|
225
|
+
done += 1
|
|
226
|
+
else: # Skipped — the left item itself failed (image, embed error, …)
|
|
227
|
+
diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
|
|
228
|
+
skipped += 1
|
|
229
|
+
spinner.advance()
|
|
230
|
+
finally:
|
|
231
|
+
spinner.finish()
|
|
232
|
+
writer.flush()
|
|
233
|
+
log.finish()
|
|
234
|
+
if unmatched_sink is not None:
|
|
235
|
+
unmatched_sink.close()
|
|
236
|
+
if request.unmatched is not None:
|
|
237
|
+
diagnostics.note(
|
|
238
|
+
f"join: {matched_pairs} matched · {unmatched_count} unmatched → "
|
|
239
|
+
f"{request.unmatched.name}"
|
|
240
|
+
)
|
|
241
|
+
elif request.kind != "inner":
|
|
242
|
+
diagnostics.note(f"join: {matched_pairs} matched · {unmatched_count} unmatched")
|
|
243
|
+
if stop is not None and stop.is_set():
|
|
244
|
+
diagnostics.interrupted_summary(processed=done, skipped=skipped + book.skipped)
|
|
245
|
+
return interrupted_exit_code(done=done, skipped=skipped + book.skipped)
|
|
246
|
+
return outcome_exit_code(done=done, skipped=skipped + book.skipped)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _join_one(
|
|
250
|
+
item: Item,
|
|
251
|
+
*,
|
|
252
|
+
log: diagnostics.DegradationLog,
|
|
253
|
+
converter: Converter,
|
|
254
|
+
embed_model: EmbeddingModel,
|
|
255
|
+
chat: ChatModel,
|
|
256
|
+
tokens: tuple[Token, ...],
|
|
257
|
+
index: RightIndex,
|
|
258
|
+
kept_right: list[Item],
|
|
259
|
+
right_chunks: dict[int, ChunkedSide],
|
|
260
|
+
request: JoinRequest,
|
|
261
|
+
book: PairBook,
|
|
262
|
+
stop: asyncio.Event | None,
|
|
263
|
+
) -> tuple[tuple[int, float], ...]:
|
|
264
|
+
item = await ensure_text(item, log=log, converter=converter) # D33 ladder
|
|
265
|
+
budget = embed_budget(embed_model.ref.provider)
|
|
266
|
+
left_side: ChunkedSide | None = None
|
|
267
|
+
if estimate_tokens(item.text) > budget:
|
|
268
|
+
vector, left_side = await _chunked_side(embed_model, item.text, budget)
|
|
269
|
+
log.note(
|
|
270
|
+
describe_source(item.source),
|
|
271
|
+
"oversized → best-chunk judge",
|
|
272
|
+
f"{len(left_side.chunks)} chunks pooled for blocking",
|
|
273
|
+
)
|
|
274
|
+
else:
|
|
275
|
+
vector = (await embed_model.embed([item.text]))[0]
|
|
276
|
+
matches: list[tuple[int, float]] = []
|
|
277
|
+
for position, score in candidates(vector, index, k=request.k, threshold=request.threshold):
|
|
278
|
+
if stop is not None and stop.is_set():
|
|
279
|
+
break
|
|
280
|
+
right_item = kept_right[position]
|
|
281
|
+
left_for_judge = item
|
|
282
|
+
if left_side is not None:
|
|
283
|
+
left_for_judge = replace(item, text=left_side.best_text(index.vectors[position]))
|
|
284
|
+
chunked_right = right_chunks.get(position)
|
|
285
|
+
if chunked_right is not None:
|
|
286
|
+
right_item = replace(right_item, text=chunked_right.best_text(vector))
|
|
287
|
+
try:
|
|
288
|
+
verdict = await _judge(chat, tokens, left_for_judge, right_item)
|
|
289
|
+
except ItemError as exc:
|
|
290
|
+
book.skip(item, position, str(exc))
|
|
291
|
+
continue
|
|
292
|
+
book.ok()
|
|
293
|
+
if verdict:
|
|
294
|
+
matches.append((position, score))
|
|
295
|
+
return tuple(matches)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _payload(item: Item) -> dict[str, object]:
|
|
299
|
+
return dict(item.data) if item.data is not None else {"text": item.text}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def _judge(chat: ChatModel, tokens: tuple[Token, ...], left: Item, right: Item) -> bool:
|
|
303
|
+
"""One verdict with the standard single repair; ItemError = the pair skips."""
|
|
304
|
+
request = build_judge_request(tokens, left, right)
|
|
305
|
+
reply = await chat.complete(request)
|
|
306
|
+
try:
|
|
307
|
+
verdict = validate_and_coerce(reply, JUDGE_SCHEMA)
|
|
308
|
+
except ItemError as first_error:
|
|
309
|
+
repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
|
|
310
|
+
verdict = validate_and_coerce(await chat.complete(repair), JUDGE_SCHEMA)
|
|
311
|
+
return verdict.get("match") is True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _load_right(path: Path) -> list[Item]:
|
|
315
|
+
if str(path) == "-":
|
|
316
|
+
raise UsageFault(
|
|
317
|
+
"--right - reads nothing — stdin is join's left side\n"
|
|
318
|
+
" The right side is a finite file smartpipe indexes up front.\n"
|
|
319
|
+
' Example: cat stream.jsonl | smartpipe join "…" --right catalog.jsonl'
|
|
320
|
+
)
|
|
321
|
+
if not path.exists():
|
|
322
|
+
raise UsageFault(
|
|
323
|
+
f"no such file: {path}\n --right needs a JSONL or plain-lines file to index."
|
|
324
|
+
)
|
|
325
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
326
|
+
items = [
|
|
327
|
+
replace(
|
|
328
|
+
item_from_line(line, index),
|
|
329
|
+
source=ItemSource("file", path.name, index),
|
|
330
|
+
)
|
|
331
|
+
for index, line in enumerate(lines)
|
|
332
|
+
]
|
|
333
|
+
if not items:
|
|
334
|
+
raise UsageFault(
|
|
335
|
+
f"{path} is empty — a join against nothing is a mistake\n"
|
|
336
|
+
' join needs a right side to match against. If you meant "keep nothing", '
|
|
337
|
+
"that's filter."
|
|
338
|
+
)
|
|
339
|
+
return items
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@dataclass(frozen=True, slots=True)
|
|
343
|
+
class ChunkedSide:
|
|
344
|
+
"""An oversized side's chunks + their vectors — the judge sees the best one."""
|
|
345
|
+
|
|
346
|
+
chunks: tuple[str, ...]
|
|
347
|
+
vectors: tuple[tuple[float, ...], ...]
|
|
348
|
+
|
|
349
|
+
def best_text(self, other_vector: tuple[float, ...]) -> str:
|
|
350
|
+
position, _score = rank(other_vector, self.vectors)[0]
|
|
351
|
+
return self.chunks[position]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def _chunked_side(
|
|
355
|
+
model: EmbeddingModel, text: str, budget: int
|
|
356
|
+
) -> tuple[tuple[float, ...], ChunkedSide]:
|
|
357
|
+
"""Chunk-embed one oversized text: pooled vector for blocking, chunks kept
|
|
358
|
+
so the judge reads the most-relevant one (D26/W3 — no more skipping)."""
|
|
359
|
+
chunks = split_text(text, budget)
|
|
360
|
+
vectors = await model.embed(list(chunks))
|
|
361
|
+
return mean_pool(vectors), ChunkedSide(tuple(chunks), tuple(vectors))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def _index_right(
|
|
365
|
+
model: EmbeddingModel,
|
|
366
|
+
items: list[Item],
|
|
367
|
+
right_name: str,
|
|
368
|
+
log: diagnostics.DegradationLog,
|
|
369
|
+
) -> tuple[list[Item], RightIndex, dict[int, ChunkedSide]]:
|
|
370
|
+
"""The build side, fully embedded before any chat spend (the preflight)."""
|
|
371
|
+
budget = embed_budget(model.ref.provider)
|
|
372
|
+
normal = [item for item in items if estimate_tokens(item.text) <= budget]
|
|
373
|
+
oversized = [item for item in items if estimate_tokens(item.text) > budget]
|
|
374
|
+
kept: list[Item] = []
|
|
375
|
+
vectors: list[tuple[float, ...]] = []
|
|
376
|
+
chunked: dict[int, ChunkedSide] = {}
|
|
377
|
+
async for outcome in embed_in_batches(model, normal, failure_policy=FailurePolicy()):
|
|
378
|
+
if isinstance(outcome, Done):
|
|
379
|
+
item, vector = outcome.value
|
|
380
|
+
kept.append(item)
|
|
381
|
+
vectors.append(vector)
|
|
382
|
+
else:
|
|
383
|
+
diagnostics.warn(f"skipped: {right_name} line {outcome.index + 1} ({outcome.reason})")
|
|
384
|
+
for item in oversized:
|
|
385
|
+
try:
|
|
386
|
+
pooled, side = await _chunked_side(model, item.text, budget)
|
|
387
|
+
except ItemError as exc:
|
|
388
|
+
diagnostics.warn(f"skipped: {right_name} line {item.source.index + 1} ({exc})")
|
|
389
|
+
continue
|
|
390
|
+
chunked[len(kept)] = side
|
|
391
|
+
kept.append(item)
|
|
392
|
+
vectors.append(pooled)
|
|
393
|
+
log.note(
|
|
394
|
+
f"{right_name} line {item.source.index + 1}",
|
|
395
|
+
"oversized → best-chunk judge",
|
|
396
|
+
f"{len(side.chunks)} chunks pooled for blocking",
|
|
397
|
+
)
|
|
398
|
+
return kept, build_index(vectors), chunked
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def preview_cost(total: int | None, k: int, index_size: int) -> None:
|
|
402
|
+
per_item = min(k, index_size)
|
|
403
|
+
if total is None:
|
|
404
|
+
diagnostics.preview(
|
|
405
|
+
f"join: up to {per_item} model calls per input line (cap with --max-calls)"
|
|
406
|
+
)
|
|
407
|
+
return
|
|
408
|
+
estimate = total * per_item
|
|
409
|
+
if estimate > _PREVIEW_THRESHOLD:
|
|
410
|
+
diagnostics.preview(
|
|
411
|
+
f"join: {total:,} left items · up to {per_item} candidates each = "
|
|
412
|
+
f"at most {estimate:,} model calls (cap with --max-calls)"
|
|
413
|
+
)
|