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,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}")
@@ -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
+ [![CI](https://github.com/prabal-rje/smartpipe/actions/workflows/ci.yml/badge.svg)](https://github.com/prabal-rje/smartpipe/actions/workflows/ci.yml)
46
+ [![PyPI](https://img.shields.io/pypi/v/smartpipe-cli)](https://pypi.org/project/smartpipe-cli/)
47
+ [![Python](https://img.shields.io/badge/python-3.11%E2%80%933.13-blue)](pyproject.toml)
48
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue)](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.