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/top_k.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""The ``top_k`` verb: rank items by similarity to a query (spec §3.4).
|
|
2
|
+
|
|
3
|
+
Embeds the query and every item (reusing a precomputed ``vector`` field from an
|
|
4
|
+
``embed`` record when present), ranks by cosine, and keeps the top K and/or
|
|
5
|
+
everything above a threshold — reordered, each with a ``_score``. Unlike the
|
|
6
|
+
per-item verbs, ``top_k`` inherently buffers: it must see all scores to rank.
|
|
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, ItemError, SetupFault, UsageFault
|
|
16
|
+
from smartpipe.core.jsontools import as_float_vector
|
|
17
|
+
from smartpipe.engine.ranking import board_insert, cosine, rank, select, unit_score
|
|
18
|
+
from smartpipe.engine.runner import Done, FailurePolicy, run_ordered
|
|
19
|
+
from smartpipe.io import diagnostics, readers, tty
|
|
20
|
+
from smartpipe.io.inputs import STDIN
|
|
21
|
+
from smartpipe.io.items import describe_source
|
|
22
|
+
from smartpipe.io.leaderboard import LiveBoard
|
|
23
|
+
from smartpipe.io.progress import make_stderr_spinner
|
|
24
|
+
from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
|
|
25
|
+
from smartpipe.verbs.common import (
|
|
26
|
+
embed_in_batches,
|
|
27
|
+
ensure_text,
|
|
28
|
+
interrupted_exit_code,
|
|
29
|
+
outcome_exit_code,
|
|
30
|
+
)
|
|
31
|
+
from smartpipe.verbs.convert import Converter, make_converter
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from typing import TextIO
|
|
35
|
+
|
|
36
|
+
from smartpipe.io.inputs import InputSpec
|
|
37
|
+
from smartpipe.io.items import Item
|
|
38
|
+
from smartpipe.io.writers import ResultWriter
|
|
39
|
+
from smartpipe.models.base import ChatModel, EmbeddingModel, ModelRef
|
|
40
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
41
|
+
|
|
42
|
+
__all__ = ["TopKContext", "TopKRequest", "run_top_k"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class TopKRequest:
|
|
47
|
+
near: str
|
|
48
|
+
k: int | None
|
|
49
|
+
threshold: float | None
|
|
50
|
+
model_flag: str | None
|
|
51
|
+
concurrency_flag: int | None
|
|
52
|
+
input: InputSpec = STDIN
|
|
53
|
+
stream: bool = False # --stream: the live leaderboard (a different output protocol)
|
|
54
|
+
fields: tuple[str, ...] | None = None # --fields: project structured records
|
|
55
|
+
allow_captions: bool = False # cloud conversions opt-in (D33)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TopKContext(Protocol):
|
|
59
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
|
|
60
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
61
|
+
async def embedding_model(self, flag: str | None = None) -> EmbeddingModel: ...
|
|
62
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def run_top_k(
|
|
66
|
+
request: TopKRequest,
|
|
67
|
+
context: TopKContext,
|
|
68
|
+
*,
|
|
69
|
+
stdin: TextIO,
|
|
70
|
+
stdout: TextIO,
|
|
71
|
+
stop: asyncio.Event | None = None,
|
|
72
|
+
) -> ExitCode:
|
|
73
|
+
if request.stream:
|
|
74
|
+
return await _run_stream(request, context, stdin=stdin, stdout=stdout, stop=stop)
|
|
75
|
+
if request.k is None and request.threshold is None:
|
|
76
|
+
raise UsageFault("top_k needs a number (K), --threshold, or both")
|
|
77
|
+
model = await context.embedding_model(request.model_flag)
|
|
78
|
+
# still validates the flag; batch embedding is chunked (≤64/call), not per-item-parallel
|
|
79
|
+
context.concurrency(request.concurrency_flag)
|
|
80
|
+
|
|
81
|
+
items_iter, _total = readers.resolve_items(request.input, stdin)
|
|
82
|
+
items = [item async for item in items_iter] # whole-set verbs need everything
|
|
83
|
+
if not items:
|
|
84
|
+
return ExitCode.OK
|
|
85
|
+
|
|
86
|
+
query_vector = (await model.embed([request.near]))[0]
|
|
87
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
88
|
+
converter_chat = await _optional_chat(context)
|
|
89
|
+
converter = make_converter(
|
|
90
|
+
converter_chat,
|
|
91
|
+
allow_paid=request.allow_captions,
|
|
92
|
+
log=log,
|
|
93
|
+
stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
|
|
94
|
+
)
|
|
95
|
+
vectors, skipped = await _collect_vectors(model, items, log, converter)
|
|
96
|
+
log.finish()
|
|
97
|
+
_check_dimensions(query_vector, vectors)
|
|
98
|
+
|
|
99
|
+
entries = sorted(vectors.items()) # (item_index, vector), stable by index for ties
|
|
100
|
+
ranked = rank(query_vector, [vector for _, vector in entries])
|
|
101
|
+
scored = tuple((entries[position][0], score) for position, score in ranked)
|
|
102
|
+
chosen = select(scored, k=request.k, threshold=request.threshold)
|
|
103
|
+
|
|
104
|
+
by_index = {item.source.index: item for item in items}
|
|
105
|
+
writer = make_writer(
|
|
106
|
+
WriterConfig(mode=RenderMode.TEXT, color=False, width=80, fields=request.fields), stdout
|
|
107
|
+
)
|
|
108
|
+
for item_index, score in chosen:
|
|
109
|
+
_emit(writer, by_index[item_index], score)
|
|
110
|
+
writer.flush()
|
|
111
|
+
|
|
112
|
+
if skipped == 0:
|
|
113
|
+
return ExitCode.OK
|
|
114
|
+
return ExitCode.ALL_FAILED if not vectors else ExitCode.PARTIAL
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _run_stream(
|
|
118
|
+
request: TopKRequest,
|
|
119
|
+
context: TopKContext,
|
|
120
|
+
*,
|
|
121
|
+
stdin: TextIO,
|
|
122
|
+
stdout: TextIO,
|
|
123
|
+
stop: asyncio.Event | None,
|
|
124
|
+
) -> ExitCode:
|
|
125
|
+
"""The rolling leaderboard (stage-08 §4.3): maintain the top K as items arrive.
|
|
126
|
+
|
|
127
|
+
Pipe mode emits an NDJSON snapshot (a ``{"_snapshot": seq}`` marker line, then
|
|
128
|
+
the K records, rank order) whenever membership/order changes; TTY mode repaints
|
|
129
|
+
the block in place. A vector whose dimensions don't match the query is skipped
|
|
130
|
+
(a stream shouldn't die wholesale on one bad record — unlike batch, where a
|
|
131
|
+
mismatched corpus is a setup fault).
|
|
132
|
+
"""
|
|
133
|
+
if request.k is None:
|
|
134
|
+
raise UsageFault(
|
|
135
|
+
"top_k --stream needs K (a live leaderboard has a size)\n"
|
|
136
|
+
' Example: tail -f tickets.jsonl | smartpipe top_k 5 --stream --near "billing dispute"'
|
|
137
|
+
)
|
|
138
|
+
if request.input.patterns or request.input.from_files:
|
|
139
|
+
raise UsageFault(
|
|
140
|
+
"top_k --stream reads a stream from stdin — it can't combine with --in\n"
|
|
141
|
+
" File inputs are a finite batch. Drop --stream, or pipe the stream in."
|
|
142
|
+
)
|
|
143
|
+
k = request.k
|
|
144
|
+
model = await context.embedding_model(request.model_flag)
|
|
145
|
+
concurrency = context.concurrency(request.concurrency_flag)
|
|
146
|
+
query_vector = (await model.embed([request.near]))[0]
|
|
147
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
148
|
+
converter_chat = await _optional_chat(context)
|
|
149
|
+
converter = make_converter(
|
|
150
|
+
converter_chat,
|
|
151
|
+
allow_paid=request.allow_captions,
|
|
152
|
+
log=log,
|
|
153
|
+
stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def worker(item: Item) -> tuple[Item, tuple[float, ...]]:
|
|
157
|
+
item = await ensure_text(item, log=log, converter=converter) # D33 ladder
|
|
158
|
+
vector = _precomputed_vector(item)
|
|
159
|
+
if vector is None:
|
|
160
|
+
vector = (await model.embed([item.text]))[0]
|
|
161
|
+
if len(vector) != len(query_vector):
|
|
162
|
+
raise ItemError(
|
|
163
|
+
f"embedding dimensions {len(vector)} don't match the query ({len(query_vector)})"
|
|
164
|
+
)
|
|
165
|
+
return item, vector
|
|
166
|
+
|
|
167
|
+
board: tuple[tuple[float, int], ...] = ()
|
|
168
|
+
by_arrival: dict[int, Item] = {}
|
|
169
|
+
live = _make_live_board(stdout)
|
|
170
|
+
writer = make_writer(
|
|
171
|
+
WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=request.fields), stdout
|
|
172
|
+
)
|
|
173
|
+
snapshot_seq = 0
|
|
174
|
+
scored = 0
|
|
175
|
+
skipped = 0
|
|
176
|
+
items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
177
|
+
outcomes = run_ordered(
|
|
178
|
+
items_iter, worker, concurrency=concurrency, failure_policy=FailurePolicy(), stop=stop
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
async for outcome in outcomes:
|
|
182
|
+
if not isinstance(outcome, Done):
|
|
183
|
+
diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
|
|
184
|
+
skipped += 1
|
|
185
|
+
continue
|
|
186
|
+
item, vector = outcome.value
|
|
187
|
+
scored += 1
|
|
188
|
+
score = unit_score(cosine(query_vector, vector))
|
|
189
|
+
if request.threshold is not None and score < request.threshold:
|
|
190
|
+
continue
|
|
191
|
+
arrival = scored
|
|
192
|
+
by_arrival[arrival] = item
|
|
193
|
+
board, changed = board_insert(board, score, arrival, k)
|
|
194
|
+
if not changed:
|
|
195
|
+
continue
|
|
196
|
+
if live is not None:
|
|
197
|
+
live.paint(_board_rows(board, by_arrival))
|
|
198
|
+
else:
|
|
199
|
+
snapshot_seq += 1
|
|
200
|
+
_emit_snapshot(writer, snapshot_seq, board, by_arrival)
|
|
201
|
+
finally:
|
|
202
|
+
if live is not None:
|
|
203
|
+
live.paint(_board_rows(board, by_arrival), force=True) # final state stays visible
|
|
204
|
+
writer.flush()
|
|
205
|
+
if stop is not None and stop.is_set():
|
|
206
|
+
diagnostics.interrupted_summary(processed=scored, skipped=skipped)
|
|
207
|
+
return interrupted_exit_code(done=scored, skipped=skipped)
|
|
208
|
+
return outcome_exit_code(done=scored, skipped=skipped)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _make_live_board(stdout: TextIO) -> LiveBoard | None:
|
|
212
|
+
if not tty.stdout_is_tty():
|
|
213
|
+
return None
|
|
214
|
+
import time
|
|
215
|
+
|
|
216
|
+
return LiveBoard(stream=stdout, width=tty.terminal_width(), clock=time.monotonic)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _board_rows(
|
|
220
|
+
board: tuple[tuple[float, int], ...], by_arrival: dict[int, Item]
|
|
221
|
+
) -> list[tuple[float, str]]:
|
|
222
|
+
return [(score, by_arrival[arrival].raw) for score, arrival in board]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _emit_snapshot(
|
|
226
|
+
writer: ResultWriter,
|
|
227
|
+
seq: int,
|
|
228
|
+
board: tuple[tuple[float, int], ...],
|
|
229
|
+
by_arrival: dict[int, Item],
|
|
230
|
+
) -> None:
|
|
231
|
+
writer.write_record({"_snapshot": seq})
|
|
232
|
+
for position, (score, arrival) in enumerate(board, start=1):
|
|
233
|
+
item = by_arrival[arrival]
|
|
234
|
+
record: dict[str, object]
|
|
235
|
+
if item.data is not None:
|
|
236
|
+
record = {key: value for key, value in item.data.items() if key != "vector"}
|
|
237
|
+
else:
|
|
238
|
+
record = {"text": item.raw}
|
|
239
|
+
record["_score"] = round(score, 4)
|
|
240
|
+
record["_rank"] = position
|
|
241
|
+
writer.write_record(record)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def _optional_chat(context: TopKContext) -> ChatModel | None:
|
|
245
|
+
"""The converter's LLM rung — absent when chat isn't configured (D33)."""
|
|
246
|
+
try:
|
|
247
|
+
return await context.chat_model()
|
|
248
|
+
except Exception:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def _collect_vectors(
|
|
253
|
+
model: EmbeddingModel,
|
|
254
|
+
items: list[Item],
|
|
255
|
+
log: diagnostics.DegradationLog,
|
|
256
|
+
converter: Converter,
|
|
257
|
+
) -> tuple[dict[int, tuple[float, ...]], int]:
|
|
258
|
+
"""Embed everything that needs embedding — chunked (≤64/call, DEFER-3),
|
|
259
|
+
run_ordered bypassed on purpose: batching ≠ per-item workers (order comes
|
|
260
|
+
from sequential chunks, isolation from the per-item poison fallback)."""
|
|
261
|
+
vectors: dict[int, tuple[float, ...]] = {}
|
|
262
|
+
to_embed: list[Item] = []
|
|
263
|
+
for item in items:
|
|
264
|
+
precomputed = _precomputed_vector(item)
|
|
265
|
+
if precomputed is not None:
|
|
266
|
+
vectors[item.source.index] = precomputed
|
|
267
|
+
else:
|
|
268
|
+
to_embed.append(item)
|
|
269
|
+
|
|
270
|
+
spinner = make_stderr_spinner()
|
|
271
|
+
spinner.start(total=len(to_embed))
|
|
272
|
+
skipped = 0
|
|
273
|
+
outcomes = embed_in_batches(
|
|
274
|
+
model, to_embed, failure_policy=FailurePolicy(), log=log, converter=converter
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
async for outcome in outcomes:
|
|
278
|
+
if isinstance(outcome, Done):
|
|
279
|
+
_item, vector = outcome.value
|
|
280
|
+
vectors[outcome.index] = vector
|
|
281
|
+
else: # Skipped
|
|
282
|
+
diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
|
|
283
|
+
skipped += 1
|
|
284
|
+
spinner.advance()
|
|
285
|
+
finally:
|
|
286
|
+
spinner.finish()
|
|
287
|
+
log.finish()
|
|
288
|
+
return vectors, skipped
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _precomputed_vector(item: Item) -> tuple[float, ...] | None:
|
|
292
|
+
"""An ``embed`` record carries its own ``vector`` — skip re-embedding it (spec §3.4)."""
|
|
293
|
+
if item.data is None:
|
|
294
|
+
return None
|
|
295
|
+
return as_float_vector(item.data.get("vector"))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _check_dimensions(query: tuple[float, ...], vectors: dict[int, tuple[float, ...]]) -> None:
|
|
299
|
+
for vector in vectors.values():
|
|
300
|
+
if len(vector) != len(query):
|
|
301
|
+
raise SetupFault(
|
|
302
|
+
f"error: the corpus and the query were embedded with different models "
|
|
303
|
+
f"(dimensions {len(vector)} vs {len(query)})\n"
|
|
304
|
+
" Use the same embedding model for both — e.g. re-run embed and top_k\n"
|
|
305
|
+
" with the same --embed-model, or check SMARTPIPE_EMBED_MODEL."
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _emit(writer: ResultWriter, item: Item, score: float) -> None:
|
|
310
|
+
rounded = round(score, 4)
|
|
311
|
+
if item.source.kind == "file": # rank files → get filenames back (the resume demo)
|
|
312
|
+
writer.write_text(f"{item.source.name}\t{rounded}")
|
|
313
|
+
elif item.data is not None:
|
|
314
|
+
record = {key: value for key, value in item.data.items() if key != "vector"}
|
|
315
|
+
record["_score"] = rounded
|
|
316
|
+
writer.write_record(record)
|
|
317
|
+
else:
|
|
318
|
+
writer.write_text(f"{item.raw}\t{rounded}")
|
smartpipe/verbs/where.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The ``where`` verb: a free deterministic filter (D38/01, KQL ``where``).
|
|
2
|
+
|
|
3
|
+
The filter-early idiom given its missing operator: cut the corpus BEFORE any
|
|
4
|
+
paid stage touches it. Never calls a model; streams; passthrough-verbatim.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from smartpipe.core.errors import ExitCode
|
|
13
|
+
from smartpipe.engine.predicate import FieldTally, evaluate, parse_predicate
|
|
14
|
+
from smartpipe.io import diagnostics
|
|
15
|
+
from smartpipe.io.items import item_from_line
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typing import TextIO
|
|
19
|
+
|
|
20
|
+
__all__ = ["WhereRequest", "run_where"]
|
|
21
|
+
|
|
22
|
+
_ROLLUP_FIELDS = 3 # cap the closing disclosure at this many field names
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class WhereRequest:
|
|
27
|
+
predicate: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_where(request: WhereRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
31
|
+
predicate = parse_predicate(request.predicate) # UsageFault (64) before reading stdin
|
|
32
|
+
tally = FieldTally()
|
|
33
|
+
seen = 0
|
|
34
|
+
matched = 0
|
|
35
|
+
for index, line in enumerate(stdin):
|
|
36
|
+
if not line.strip():
|
|
37
|
+
continue
|
|
38
|
+
seen += 1
|
|
39
|
+
item = item_from_line(line, index)
|
|
40
|
+
if evaluate(predicate, item, tally):
|
|
41
|
+
matched += 1
|
|
42
|
+
stdout.write(item.raw + "\n")
|
|
43
|
+
diagnostics.note(f"where: {matched:,} of {seen:,} matched")
|
|
44
|
+
for label, counter in (("missing", tally.missing), ("non-numeric", tally.non_numeric)):
|
|
45
|
+
for field_name, count in counter.most_common(_ROLLUP_FIELDS):
|
|
46
|
+
diagnostics.note(f"field '{field_name}' {label} on {count:,} rows")
|
|
47
|
+
return ExitCode.OK # zero matches is a valid result (filter's contract)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smartpipe-cli
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Semantic pipes for your terminal - PDFs, images, audio, video, and text through Unix verbs that understand.
|
|
5
|
+
Project-URL: Homepage, https://github.com/prabal-rje/smartpipe
|
|
6
|
+
Project-URL: Changelog, https://github.com/prabal-rje/smartpipe/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: Issues, https://github.com/prabal-rje/smartpipe/issues
|
|
8
|
+
Author-email: Prabal Gupta <prabal@rjeinc.ca>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: NOTICE
|
|
12
|
+
Keywords: cli,grep,llm,ollama,pipes,semantic,unix
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Text Processing :: Filters
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: anthropic>=0.40
|
|
24
|
+
Requires-Dist: click>=8.1
|
|
25
|
+
Requires-Dist: fastembed>=0.4; python_version < '3.14'
|
|
26
|
+
Requires-Dist: faster-whisper>=1.0; python_version < '3.14'
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: imageio-ffmpeg>=0.5
|
|
29
|
+
Requires-Dist: jsonschema>=4.23
|
|
30
|
+
Requires-Dist: markitdown[docx,pdf,pptx,xlsx]>=0.1.6; python_version < '3.14'
|
|
31
|
+
Requires-Dist: pdfminer-six>=20231228
|
|
32
|
+
Requires-Dist: pypdf>=5.0
|
|
33
|
+
Requires-Dist: svgwrite>=1.4
|
|
34
|
+
Requires-Dist: tomli-w>=1.0
|
|
35
|
+
Provides-Extra: all
|
|
36
|
+
Provides-Extra: anthropic
|
|
37
|
+
Provides-Extra: audio
|
|
38
|
+
Provides-Extra: charts
|
|
39
|
+
Provides-Extra: files
|
|
40
|
+
Provides-Extra: video
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# smartpipe
|
|
44
|
+
|
|
45
|
+
[](https://github.com/prabal-rje/smartpipe/actions/workflows/ci.yml)
|
|
46
|
+
[](https://pypi.org/project/smartpipe-cli/)
|
|
47
|
+
[](pyproject.toml)
|
|
48
|
+
[](LICENSE)
|
|
49
|
+
|
|
50
|
+
**Semantic pipes for your terminal.** (PyPI: [`smartpipe-cli`](https://pypi.org/project/smartpipe-cli/); the command is `smartpipe`.)
|
|
51
|
+
|
|
52
|
+
Run PDFs, images, audio, video, and text through Unix verbs that understand their
|
|
53
|
+
input. Use Ollama for local models, or choose a cloud provider explicitly.
|
|
54
|
+
|
|
55
|
+
> Formerly `sempipe` (which still works as a command alias). The import name,
|
|
56
|
+
> `SMARTPIPE_*` env vars, and `~/.config/smartpipe` keep the old spelling.
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
$ uvx --from smartpipe-cli smartpipe # zero-install trial (or: pip install smartpipe-cli)
|
|
60
|
+
|
|
61
|
+
$ smartpipe map "summarize the key risk" --in 'filings/*.pdf' # documents, figures included
|
|
62
|
+
$ smartpipe filter "the caller sounds frustrated" --in 'calls/*.mp3'
|
|
63
|
+
$ echo "hello world" \
|
|
64
|
+
| smartpipe map "translate to Spanish"
|
|
65
|
+
hola mundo
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A PDF arrives with its figures attached. A scanned page routes itself to a vision
|
|
69
|
+
model and says so.
|
|
70
|
+
|
|
71
|
+
Audio is heard natively or transcribed. Video is watched where the wire supports
|
|
72
|
+
it, and decomposed into frames plus transcript where it does not. Every degradation
|
|
73
|
+
is disclosed per row.
|
|
74
|
+
|
|
75
|
+
No server. No YAML. No vector database. stdin to stdout, composing with
|
|
76
|
+
`grep`, `jq`, `sort` - and `tail -f`: the per-item verbs stream.
|
|
77
|
+
|
|
78
|
+
## The verbs
|
|
79
|
+
|
|
80
|
+
**Semantic** (call a model):
|
|
81
|
+
|
|
82
|
+
| Verb | What it does | Feels like |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `map` | transform each item - text or media - with a prompt | `sed`, but it understands |
|
|
85
|
+
| `extend` | add extracted fields; everything else survives | your record, plus columns |
|
|
86
|
+
| `filter` | keep items matching a plain-English condition | `grep`, but semantic |
|
|
87
|
+
| `embed` / `top_k` | vectors; rank by similarity | `sort \| head`, by meaning |
|
|
88
|
+
| `reduce` | synthesize many items into one | `awk` END, but literate |
|
|
89
|
+
| `join` | match two inputs (`--kind inner\|leftouter\|anti`) | SQL join, but semantic |
|
|
90
|
+
| `cluster` | group by meaning, label each group | themes with sizes and quotes |
|
|
91
|
+
| `distinct` | fold near-duplicates | `sort -u`, by meaning |
|
|
92
|
+
| `diff` | what distinguishes two sets | the post-incident answer |
|
|
93
|
+
| `outliers` | the items least like the rest | novelty, surfaced |
|
|
94
|
+
|
|
95
|
+
**Free utilities** (never call a model): `where` (KQL-style predicates),
|
|
96
|
+
`summarize` (count/avg/percentiles, time buckets), `sort`, `sample` (seeded),
|
|
97
|
+
`getschema`, `split`, `chart` (terminal bars, SVG, facets, time series).
|
|
98
|
+
Put them first - they cut the corpus before anything paid runs.
|
|
99
|
+
|
|
100
|
+
## Sixty seconds
|
|
101
|
+
|
|
102
|
+
```console
|
|
103
|
+
# 1. Point smartpipe at a model (local & free via Ollama, or cloud):
|
|
104
|
+
$ smartpipe config
|
|
105
|
+
|
|
106
|
+
# 2. Ask a question across a folder of mixed documents:
|
|
107
|
+
$ smartpipe map "What does this say about pricing?" --in 'docs/*.pdf'
|
|
108
|
+
|
|
109
|
+
# 3. Typed extraction - braces carry names, types, AND guidance:
|
|
110
|
+
$ cat tickets.jsonl \
|
|
111
|
+
| smartpipe extend "Add {label enum(bug, feature, praise), urgency number: 0 to 1}"
|
|
112
|
+
|
|
113
|
+
# 4. The analyst's Monday, one line:
|
|
114
|
+
# group by meaning, label each theme; chart it for the deck
|
|
115
|
+
$ cat feedback.txt \
|
|
116
|
+
| smartpipe cluster --top 8 \
|
|
117
|
+
| smartpipe chart cluster --save themes.svg
|
|
118
|
+
|
|
119
|
+
# 5. Free gates before paid judges - and watch the live token/media counts:
|
|
120
|
+
# where cuts for free; the model judges only what remains
|
|
121
|
+
$ cat app.log \
|
|
122
|
+
| smartpipe where 'text has "ERROR"' \
|
|
123
|
+
| smartpipe filter "an actual outage"
|
|
124
|
+
|
|
125
|
+
# 6. Save the whole pipeline as a file; it becomes a command:
|
|
126
|
+
$ smartpipe run triage.sem --dry-run # the stage graph + cost posture, zero calls
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
New to any of this? The [ten-minute quickstart](docs/quickstart.md) assumes
|
|
130
|
+
nothing - including that you know what a "model" is.
|
|
131
|
+
|
|
132
|
+
## Honest about where your data goes, and what it costs
|
|
133
|
+
|
|
134
|
+
Some of smartpipe runs locally regardless of chat model choice: local embeddings
|
|
135
|
+
(fastembed) and local transcription (whisper) ship built in.
|
|
136
|
+
|
|
137
|
+
For chat, [Ollama](https://ollama.com) gives you a local path when it runs on your
|
|
138
|
+
machine. If you choose a cloud model, that provider sees the data for that run.
|
|
139
|
+
Examples: `gpt-5.4-mini`, `claude-opus-4-8`, `gemini-3.1-flash-lite`,
|
|
140
|
+
`mistral-large-latest`, and `openrouter/...`.
|
|
141
|
+
|
|
142
|
+
API keys come from environment variables and are never stored. ChatGPT subscribers
|
|
143
|
+
can use `smartpipe auth login` instead.
|
|
144
|
+
|
|
145
|
+
Paid media conversions require `allow-captions`. Runs show live token/media counts
|
|
146
|
+
and end with a receipt. `smartpipe usage` keeps local hour/day/week/month/lifetime
|
|
147
|
+
totals, and the opt-in result cache makes repeated calls free.
|
|
148
|
+
|
|
149
|
+
## It behaves like a real Unix tool
|
|
150
|
+
|
|
151
|
+
- **stdout is data, stderr is chatter.** Progress and receipts never contaminate your pipe.
|
|
152
|
+
- **TTY-aware.** Human-readable at the terminal, NDJSON when piped - automatically.
|
|
153
|
+
- **Order-preserving.** Output order matches input order, even with parallel calls.
|
|
154
|
+
- **Failure-tolerant.** One bad item is a warning, not a crash.
|
|
155
|
+
- **Reproducible.** Temperature 0 everywhere, seeded sampling, deterministic clustering.
|
|
156
|
+
|
|
157
|
+
## Learn more
|
|
158
|
+
|
|
159
|
+
Full docs in [`docs/`](docs/index.md) (or as a site - `uv run --group docs mkdocs serve`):
|
|
160
|
+
|
|
161
|
+
- [Quickstart](docs/quickstart.md) - zero to first result, gently
|
|
162
|
+
- [Install](docs/install.md) - package and platform notes
|
|
163
|
+
- [Working with files & media](docs/inputs/files.md) - PDFs, scans, images, audio, video
|
|
164
|
+
- [The verbs](docs/reference/cli.md) - `map`, `extend`, `filter`, `cluster`,
|
|
165
|
+
`distinct`, `diff`, `where`, and the rest
|
|
166
|
+
- [Training-data prep](docs/cookbook/training-data-prep.md) - the curator's loop
|
|
167
|
+
with receipts
|
|
168
|
+
- [Custom verbs](docs/reference/custom-verbs.md), [`.sem` pipelines](docs/reference/sem-files.md),
|
|
169
|
+
[Troubleshooting](docs/troubleshooting.md), and [Privacy](docs/privacy.md)
|
|
170
|
+
|
|
171
|
+
## How to cite
|
|
172
|
+
|
|
173
|
+
If smartpipe is useful in your research, cite it (or run `smartpipe cite`):
|
|
174
|
+
|
|
175
|
+
```bibtex
|
|
176
|
+
@software{gupta_smartpipe_2026,
|
|
177
|
+
author = {Gupta, Prabal},
|
|
178
|
+
title = {smartpipe: semantic pipes for your terminal},
|
|
179
|
+
year = {2026},
|
|
180
|
+
version = {1.3.0},
|
|
181
|
+
license = {Apache-2.0},
|
|
182
|
+
url = {https://github.com/prabal-rje/smartpipe}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
GitHub's "Cite this repository" button (from [CITATION.cff](CITATION.cff)) gives APA too.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
Built in the open, under **Apache-2.0**. Contributor setup and the quality
|
|
191
|
+
gates are in [CONTRIBUTING.md](CONTRIBUTING.md); the manual release pass
|
|
192
|
+
lives in [`qa/`](qa/README.md). The CLI surface is a SemVer contract.
|