smartpipe-cli 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""``smartpipe outliers`` — the items least like the rest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from smartpipe.cli.completions import complete_embed_models
|
|
12
|
+
from smartpipe.cli.input_options import input_options, input_spec
|
|
13
|
+
from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
|
|
14
|
+
from smartpipe.core.errors import ExitCode
|
|
15
|
+
from smartpipe.verbs.outliers import OutliersRequest, run_outliers
|
|
16
|
+
|
|
17
|
+
__all__ = ["outliers_command"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command(name="outliers")
|
|
21
|
+
@click.argument("count", type=int, default=5)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--embed-model",
|
|
24
|
+
"model_flag",
|
|
25
|
+
shell_complete=complete_embed_models,
|
|
26
|
+
help="Embedding model for this run.",
|
|
27
|
+
)
|
|
28
|
+
@click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
|
|
29
|
+
@click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
|
|
30
|
+
@click.option(
|
|
31
|
+
"--allow-captions",
|
|
32
|
+
"allow_captions",
|
|
33
|
+
is_flag=True,
|
|
34
|
+
help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
|
|
35
|
+
)
|
|
36
|
+
@input_options
|
|
37
|
+
def outliers_command(
|
|
38
|
+
count: int,
|
|
39
|
+
model_flag: str | None,
|
|
40
|
+
concurrency_flag: int | None,
|
|
41
|
+
max_calls: int | None,
|
|
42
|
+
allow_captions: bool,
|
|
43
|
+
in_patterns: tuple[str, ...],
|
|
44
|
+
from_files: bool,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Rank the N items least like the rest — novelty, surfaced.
|
|
47
|
+
|
|
48
|
+
\b
|
|
49
|
+
Examples:
|
|
50
|
+
cat today.log | smartpipe outliers 5 # the failure shapes you HAVEN'T seen
|
|
51
|
+
cat train.jsonl | smartpipe outliers 20 # label noise and template glitches
|
|
52
|
+
|
|
53
|
+
top_k's mirror: farthest from everything instead of nearest to a query.
|
|
54
|
+
Embeddings only — no chat calls. Each row carries _distance, and the
|
|
55
|
+
stderr line anchors it ("median neighbor distance 0.21 — these are
|
|
56
|
+
3.1x-3.9x out") so the score means something.
|
|
57
|
+
"""
|
|
58
|
+
request = OutliersRequest(
|
|
59
|
+
count=count,
|
|
60
|
+
model_flag=model_flag,
|
|
61
|
+
concurrency_flag=concurrency_flag,
|
|
62
|
+
allow_captions=allow_captions,
|
|
63
|
+
input=input_spec(in_patterns, from_files=from_files),
|
|
64
|
+
)
|
|
65
|
+
code = asyncio.run(_run(request, max_calls))
|
|
66
|
+
if code is not ExitCode.OK:
|
|
67
|
+
raise SystemExit(int(code))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _run(request: OutliersRequest, max_calls: int | None) -> ExitCode:
|
|
71
|
+
from dataclasses import replace
|
|
72
|
+
|
|
73
|
+
from smartpipe.container import build_container
|
|
74
|
+
|
|
75
|
+
async with (
|
|
76
|
+
graceful_interrupts() as stop,
|
|
77
|
+
build_container(os.environ, max_calls=max_calls, stop=stop) as container,
|
|
78
|
+
):
|
|
79
|
+
if not request.allow_captions and container.config.allow_captions:
|
|
80
|
+
request = replace(request, allow_captions=True) # profile consent (D35)
|
|
81
|
+
code = await run_outliers(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
|
|
82
|
+
return settle_budget(container.budget, code)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""``doctor --probe`` (D31/D42): the modality matrix, with real tiny calls.
|
|
2
|
+
|
|
3
|
+
``doctor`` alone never spends a cent (D18); this flag is the explicit opt-in
|
|
4
|
+
that answers what the docs can only claim: which modalities *actually* reach
|
|
5
|
+
your configured models. Marks: check = native; dash+star = works via a
|
|
6
|
+
footnote names it); cross = no path. Four tiny paid calls, announced first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from smartpipe.core.errors import SempipeError
|
|
16
|
+
from smartpipe.io import diagnostics
|
|
17
|
+
from smartpipe.models.base import (
|
|
18
|
+
AudioData,
|
|
19
|
+
CompletionRequest,
|
|
20
|
+
ImageData,
|
|
21
|
+
supports_media_embedding,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
|
|
27
|
+
from smartpipe.models.base import ChatModel, EmbeddingModel
|
|
28
|
+
|
|
29
|
+
__all__ = ["render_matrix", "run_probe"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Cell:
|
|
34
|
+
verdict: str # "ok" | "no" | "via" (fallback) | "na"
|
|
35
|
+
detail: str
|
|
36
|
+
footnote: str | None = None # what the * means, when verdict == "via"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _asset(name: str) -> bytes:
|
|
40
|
+
return (resources.files("smartpipe.assets") / name).read_bytes()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def run_probe(env: Mapping[str, str]) -> str:
|
|
44
|
+
"""Build the matrix against the configured chat + embed models."""
|
|
45
|
+
import os
|
|
46
|
+
|
|
47
|
+
from smartpipe.container import build_container
|
|
48
|
+
|
|
49
|
+
del env # the container reads the process environment itself
|
|
50
|
+
async with build_container(os.environ) as container:
|
|
51
|
+
chat = await container.chat_model()
|
|
52
|
+
embed = await container.embedding_model()
|
|
53
|
+
diagnostics.note(
|
|
54
|
+
"probing modalities with 4 tiny calls "
|
|
55
|
+
f"(chat: {chat.ref.name} · embed: {embed.ref.name})"
|
|
56
|
+
)
|
|
57
|
+
stt = _stt_path(os.environ, container.config.stt_model)
|
|
58
|
+
chat_image = await _chat_image(chat)
|
|
59
|
+
chat_audio = await _chat_audio(chat, stt)
|
|
60
|
+
rows = {
|
|
61
|
+
"text": (await _chat_text(chat), await _embed_text(embed)),
|
|
62
|
+
"image": (chat_image, _embed_image(embed, chat_image)),
|
|
63
|
+
"audio": (chat_audio, _embed_audio(chat_audio, stt)),
|
|
64
|
+
"video": (_chat_video(chat), _embed_video()),
|
|
65
|
+
"document": (
|
|
66
|
+
Cell("ok", "parsed locally (no call)"),
|
|
67
|
+
Cell("ok", "as extracted text"),
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
return render_matrix(rows)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _stt_path(env: Mapping[str, str], configured: str | None) -> str | None:
|
|
74
|
+
"""The transcription path the ladder would take, if any (D39/05)."""
|
|
75
|
+
named = env.get("SMARTPIPE_STT_MODEL", "").strip() or (configured or "")
|
|
76
|
+
if named:
|
|
77
|
+
return named
|
|
78
|
+
if env.get("OPENAI_API_KEY", "").strip():
|
|
79
|
+
return "openai/whisper-1 (auto)"
|
|
80
|
+
from importlib.util import find_spec
|
|
81
|
+
|
|
82
|
+
if find_spec("faster_whisper") is not None:
|
|
83
|
+
return "local whisper"
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _chat_text(chat: ChatModel) -> Cell:
|
|
88
|
+
try:
|
|
89
|
+
reply = await chat.complete(
|
|
90
|
+
CompletionRequest(system=None, user="Reply with exactly: OK", max_tokens=8)
|
|
91
|
+
)
|
|
92
|
+
return Cell("ok", f"replied {reply.strip()[:16]!r}")
|
|
93
|
+
except SempipeError as exc:
|
|
94
|
+
return Cell("no", _first_line(exc))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _chat_image(chat: ChatModel) -> Cell:
|
|
98
|
+
try:
|
|
99
|
+
reply = await chat.complete(
|
|
100
|
+
CompletionRequest(
|
|
101
|
+
system=None,
|
|
102
|
+
user="One word: what color dominates this image?",
|
|
103
|
+
media=(ImageData(_asset("probe.png"), "image/png"),),
|
|
104
|
+
max_tokens=8,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return Cell("ok", f"saw it — {reply.strip()[:16]!r}")
|
|
108
|
+
except SempipeError:
|
|
109
|
+
return Cell("no", "this model can't see images")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _chat_audio(chat: ChatModel, stt: str | None) -> Cell:
|
|
113
|
+
try:
|
|
114
|
+
reply = await chat.complete(
|
|
115
|
+
CompletionRequest(
|
|
116
|
+
system=None,
|
|
117
|
+
user="One word: is this sound a tone or speech?",
|
|
118
|
+
media=(AudioData(_asset("probe.wav"), "audio/wav"),),
|
|
119
|
+
max_tokens=8,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return Cell("ok", f"heard it — {reply.strip()[:16]!r}")
|
|
123
|
+
except SempipeError:
|
|
124
|
+
if stt is not None:
|
|
125
|
+
return Cell("via", "transcribed, then chat", footnote=f"audio → {stt}")
|
|
126
|
+
return Cell("no", "no transcription path — reinstall smartpipe")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _embed_text(embed: EmbeddingModel) -> Cell:
|
|
130
|
+
try:
|
|
131
|
+
vectors = await embed.embed([_asset("probe.txt").decode("utf-8").strip()])
|
|
132
|
+
return Cell("ok", f"{len(vectors[0])}-dim vector")
|
|
133
|
+
except SempipeError as exc:
|
|
134
|
+
return Cell("no", _first_line(exc))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _embed_image(embed: EmbeddingModel, chat_image: Cell) -> Cell:
|
|
138
|
+
if supports_media_embedding(embed):
|
|
139
|
+
return Cell("ok", "embedded as pixels")
|
|
140
|
+
if chat_image.verdict == "ok":
|
|
141
|
+
return Cell("via", "caption, then embed", footnote="image → caption pivot (D33)")
|
|
142
|
+
return Cell("no", "needs a vision chat model to caption")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _embed_audio(chat_audio: Cell, stt: str | None) -> Cell:
|
|
146
|
+
if chat_audio.verdict == "ok" or stt is not None:
|
|
147
|
+
return Cell("via", "transcript, then embed", footnote="audio → transcript pivot")
|
|
148
|
+
return Cell("no", "no transcription path")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _chat_video(chat: ChatModel) -> Cell:
|
|
152
|
+
if chat.ref.provider == "gemini":
|
|
153
|
+
return Cell("ok", "watched natively")
|
|
154
|
+
try:
|
|
155
|
+
from smartpipe.parsing.extract import ffmpeg_exe
|
|
156
|
+
|
|
157
|
+
ffmpeg_exe()
|
|
158
|
+
return Cell("via", "frames + audio track", footnote="video → 1 fps frames + track")
|
|
159
|
+
except SempipeError:
|
|
160
|
+
return Cell("no", "ffmpeg unavailable — reinstall smartpipe")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _embed_video() -> Cell:
|
|
164
|
+
return Cell("via", "halves, then embed", footnote="video → visual+speech halves (D36)")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _first_line(exc: SempipeError) -> str:
|
|
168
|
+
return str(exc).splitlines()[0].removeprefix("error: ")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_MARKS = {"ok": "✓ ", "no": "✗ ", "via": "–*", "na": "– "} # noqa: RUF001 — pinned marks
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def render_matrix(rows: Mapping[str, tuple[Cell, Cell]]) -> str:
|
|
175
|
+
"""Aligned by VISIBLE width, cells truncated to the grid — overflow never
|
|
176
|
+
smashes columns (D42; live-caught by the owner's screenshot)."""
|
|
177
|
+
from smartpipe.cli.screens import bad, good, heading, tint
|
|
178
|
+
|
|
179
|
+
label_width = max(len(name) for name in rows) + 2
|
|
180
|
+
cap = 30
|
|
181
|
+
|
|
182
|
+
def cell_text(cell: Cell) -> str:
|
|
183
|
+
detail = cell.detail if len(cell.detail) <= cap else cell.detail[: cap - 1] + "…"
|
|
184
|
+
return f"{_MARKS[cell.verdict]} {detail}"
|
|
185
|
+
|
|
186
|
+
def paint(cell: Cell, text: str) -> str:
|
|
187
|
+
mark_len = 2
|
|
188
|
+
mark, rest = text[:mark_len], text[mark_len:]
|
|
189
|
+
match cell.verdict:
|
|
190
|
+
case "ok":
|
|
191
|
+
return good(mark) + rest
|
|
192
|
+
case "no":
|
|
193
|
+
return bad(mark) + rest
|
|
194
|
+
case _:
|
|
195
|
+
return tint(mark, "2") + rest
|
|
196
|
+
|
|
197
|
+
chat_width = max(len(cell_text(chat)) for chat, _embed in rows.values()) + 3
|
|
198
|
+
header = f" {' ' * label_width}{heading(_pad_plain('chat', chat_width))}{heading('embed')}"
|
|
199
|
+
lines = [header]
|
|
200
|
+
footnotes: list[str] = []
|
|
201
|
+
for modality, (chat_cell, embed_cell) in rows.items():
|
|
202
|
+
left = paint(chat_cell, cell_text(chat_cell))
|
|
203
|
+
right = paint(embed_cell, cell_text(embed_cell))
|
|
204
|
+
label = tint(modality.ljust(label_width), "2")
|
|
205
|
+
lines.append(f" {label}{_pad_ansi(left, chat_width)}{right}")
|
|
206
|
+
for cell in (chat_cell, embed_cell):
|
|
207
|
+
if cell.footnote and cell.footnote not in footnotes:
|
|
208
|
+
footnotes.append(cell.footnote)
|
|
209
|
+
if footnotes:
|
|
210
|
+
lines.append(tint(" * fallback paths: " + " · ".join(footnotes), "2"))
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _pad_plain(text: str, width: int) -> str:
|
|
215
|
+
return f"{text:{width}s}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _pad_ansi(text: str, width: int) -> str:
|
|
219
|
+
"""Pad by VISIBLE length — ANSI escapes are zero-width."""
|
|
220
|
+
import re
|
|
221
|
+
|
|
222
|
+
visible = len(re.sub(r"\x1b\[[0-9;]*m", "", text))
|
|
223
|
+
return text + " " * max(0, width - visible)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""``smartpipe reduce`` — synthesize many items into one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from smartpipe.cli.completions import complete_chat_models
|
|
13
|
+
from smartpipe.cli.input_options import (
|
|
14
|
+
fields_option,
|
|
15
|
+
input_options,
|
|
16
|
+
input_spec,
|
|
17
|
+
resolve_prompt,
|
|
18
|
+
)
|
|
19
|
+
from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
|
|
20
|
+
from smartpipe.core.errors import ExitCode
|
|
21
|
+
from smartpipe.verbs.reduce import ReduceRequest, run_reduce
|
|
22
|
+
|
|
23
|
+
__all__ = ["reduce_command"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.command(name="reduce")
|
|
27
|
+
@click.argument("prompt", required=False)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--prompt-file",
|
|
30
|
+
"prompt_file",
|
|
31
|
+
type=click.Path(path_type=Path),
|
|
32
|
+
help="Read the prompt from a file (the @file shorthand does the same).",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--schema-from",
|
|
36
|
+
"schema_dsl",
|
|
37
|
+
metavar="DSL",
|
|
38
|
+
help='Build the schema from a short DSL: "vendor string; total number >= 0".',
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--schema",
|
|
42
|
+
"schema_path",
|
|
43
|
+
type=click.Path(path_type=Path),
|
|
44
|
+
help="Shape the final result with a JSON Schema.",
|
|
45
|
+
)
|
|
46
|
+
@click.option("--group-by", "group_by", help="Reduce per group (by an input JSON field).")
|
|
47
|
+
@click.option(
|
|
48
|
+
"--model", "model_flag", shell_complete=complete_chat_models, help="Model for this run."
|
|
49
|
+
)
|
|
50
|
+
@click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
|
|
51
|
+
@click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
|
|
52
|
+
@click.option("--verbose", is_flag=True, help="Show the chunking tree on stderr.")
|
|
53
|
+
@click.option("--window", type=int, help="Stream mode: reduce every N lines (tumbling).")
|
|
54
|
+
@click.option("--every", type=int, help="With --window: slide, reducing after every M lines.")
|
|
55
|
+
@fields_option
|
|
56
|
+
@click.option(
|
|
57
|
+
"--allow-captions",
|
|
58
|
+
"allow_captions",
|
|
59
|
+
is_flag=True,
|
|
60
|
+
help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
|
|
61
|
+
)
|
|
62
|
+
@input_options
|
|
63
|
+
def reduce_command(
|
|
64
|
+
prompt: str | None,
|
|
65
|
+
prompt_file: Path | None,
|
|
66
|
+
schema_path: Path | None,
|
|
67
|
+
schema_dsl: str | None,
|
|
68
|
+
group_by: str | None,
|
|
69
|
+
model_flag: str | None,
|
|
70
|
+
concurrency_flag: int | None,
|
|
71
|
+
max_calls: int | None,
|
|
72
|
+
allow_captions: bool,
|
|
73
|
+
verbose: bool,
|
|
74
|
+
window: int | None,
|
|
75
|
+
every: int | None,
|
|
76
|
+
fields: tuple[str, ...] | None,
|
|
77
|
+
in_patterns: tuple[str, ...],
|
|
78
|
+
from_files: bool,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Synthesize all input items into a single result.
|
|
81
|
+
|
|
82
|
+
\b
|
|
83
|
+
Examples:
|
|
84
|
+
smartpipe reduce "Write a one-page executive summary" --in 'notes/*.md'
|
|
85
|
+
cat reports.jsonl | smartpipe reduce "Write a root-cause analysis" --schema rca.json
|
|
86
|
+
cat feedback.jsonl | smartpipe reduce "Summarize sentiment" --group-by product
|
|
87
|
+
|
|
88
|
+
When the input is too large for the model, smartpipe chunks it and recursively
|
|
89
|
+
summarizes — automatically. Add --verbose to see the chunking tree.
|
|
90
|
+
"""
|
|
91
|
+
request = ReduceRequest(
|
|
92
|
+
allow_captions=allow_captions,
|
|
93
|
+
prompt=resolve_prompt(prompt, prompt_file),
|
|
94
|
+
schema_path=schema_path,
|
|
95
|
+
schema_dsl=schema_dsl,
|
|
96
|
+
group_by=group_by,
|
|
97
|
+
model_flag=model_flag,
|
|
98
|
+
concurrency_flag=concurrency_flag,
|
|
99
|
+
verbose=verbose,
|
|
100
|
+
window=window,
|
|
101
|
+
every=every,
|
|
102
|
+
input=input_spec(in_patterns, from_files=from_files),
|
|
103
|
+
fields=fields,
|
|
104
|
+
)
|
|
105
|
+
code = asyncio.run(_run(request, max_calls))
|
|
106
|
+
if code is not ExitCode.OK:
|
|
107
|
+
raise SystemExit(int(code))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _run(request: ReduceRequest, max_calls: int | None) -> ExitCode:
|
|
111
|
+
from smartpipe.container import build_container
|
|
112
|
+
|
|
113
|
+
if request.window is None: # whole-set mode: ^C exits immediately; budget is fatal (D18)
|
|
114
|
+
async with build_container(os.environ, max_calls=max_calls) as container:
|
|
115
|
+
if not request.allow_captions and container.config.allow_captions:
|
|
116
|
+
from dataclasses import replace as _replace
|
|
117
|
+
|
|
118
|
+
request = _replace(request, allow_captions=True) # profile consent (D35)
|
|
119
|
+
return await run_reduce(request, container, stdin=sys.stdin, stdout=sys.stdout)
|
|
120
|
+
async with ( # stream mode drains + flushes partial
|
|
121
|
+
graceful_interrupts() as stop,
|
|
122
|
+
build_container(os.environ, max_calls=max_calls, stop=stop) as container,
|
|
123
|
+
):
|
|
124
|
+
if not request.allow_captions and container.config.allow_captions:
|
|
125
|
+
from dataclasses import replace as _replace
|
|
126
|
+
|
|
127
|
+
request = _replace(request, allow_captions=True) # profile consent (D35)
|
|
128
|
+
code = await run_reduce(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
|
|
129
|
+
return settle_budget(container.budget, code)
|