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,428 @@
|
|
|
1
|
+
"""The ``reduce`` verb: synthesize many items into one (spec §3.5).
|
|
2
|
+
|
|
3
|
+
The headline feature is invisible recursion: when the input exceeds the model's
|
|
4
|
+
context, smartpipe chunks it, summarizes each chunk into dense notes, and recurses on
|
|
5
|
+
the notes — no flags, no strategy to choose. ``--group-by`` runs one reduction per
|
|
6
|
+
group; ``--schema`` shapes the final output; ``--verbose`` shows the chunking tree.
|
|
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, UsageFault
|
|
16
|
+
from smartpipe.engine.chunking import (
|
|
17
|
+
budget_for,
|
|
18
|
+
chunk_indices,
|
|
19
|
+
estimate_tokens,
|
|
20
|
+
fits_in_one,
|
|
21
|
+
halve,
|
|
22
|
+
is_context_overflow,
|
|
23
|
+
)
|
|
24
|
+
from smartpipe.engine.prompts import (
|
|
25
|
+
build_reduce_final,
|
|
26
|
+
build_reduce_intermediate,
|
|
27
|
+
build_repair_request,
|
|
28
|
+
has_brace,
|
|
29
|
+
interpolate_fields,
|
|
30
|
+
parse_prompt,
|
|
31
|
+
reject_comma_groups,
|
|
32
|
+
to_instruction,
|
|
33
|
+
)
|
|
34
|
+
from smartpipe.engine.schema import load_schema, validate_and_coerce
|
|
35
|
+
from smartpipe.engine.windows import Window, WindowBuffer, WindowPolicy
|
|
36
|
+
from smartpipe.io import diagnostics, readers
|
|
37
|
+
from smartpipe.io.inputs import STDIN
|
|
38
|
+
from smartpipe.io.items import describe_source
|
|
39
|
+
from smartpipe.io.writers import OutputFormat
|
|
40
|
+
from smartpipe.verbs.common import (
|
|
41
|
+
ensure_text,
|
|
42
|
+
interrupted_exit_code,
|
|
43
|
+
outcome_exit_code,
|
|
44
|
+
resolve_schema,
|
|
45
|
+
)
|
|
46
|
+
from smartpipe.verbs.convert import make_converter
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import TextIO
|
|
52
|
+
|
|
53
|
+
from smartpipe.engine.prompts import Token
|
|
54
|
+
from smartpipe.io.inputs import InputSpec
|
|
55
|
+
from smartpipe.io.items import Item
|
|
56
|
+
from smartpipe.io.writers import ResultWriter
|
|
57
|
+
from smartpipe.models.base import ChatModel, ModelRef
|
|
58
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
59
|
+
|
|
60
|
+
__all__ = ["ReduceContext", "ReduceRequest", "Reducer", "run_reduce"]
|
|
61
|
+
|
|
62
|
+
_OVERHEAD_TOKENS = 300 # reserve room for the prompt template + response
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True)
|
|
66
|
+
class ReduceRequest:
|
|
67
|
+
prompt: str
|
|
68
|
+
schema_path: Path | None
|
|
69
|
+
group_by: str | None
|
|
70
|
+
model_flag: str | None
|
|
71
|
+
concurrency_flag: int | None
|
|
72
|
+
verbose: bool
|
|
73
|
+
input: InputSpec = STDIN
|
|
74
|
+
window: int | None = None # --window N: stream mode, one reduce per window
|
|
75
|
+
every: int | None = None # --every M: sliding stride (default: tumbling)
|
|
76
|
+
fields: tuple[str, ...] | None = None # --fields: project structured output
|
|
77
|
+
schema_dsl: str | None = None # --schema-from (rung 3, D22)
|
|
78
|
+
allow_captions: bool = False # cloud conversions opt-in (D33)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ReduceContext(Protocol):
|
|
82
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
|
|
83
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
84
|
+
async def context_window(self, ref: ModelRef) -> int | None: ...
|
|
85
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
86
|
+
def writer(
|
|
87
|
+
self,
|
|
88
|
+
output_flag: OutputFormat,
|
|
89
|
+
*,
|
|
90
|
+
structured: bool,
|
|
91
|
+
stdout: TextIO,
|
|
92
|
+
fields: tuple[str, ...] | None = None,
|
|
93
|
+
) -> ResultWriter: ...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def run_reduce(
|
|
97
|
+
request: ReduceRequest,
|
|
98
|
+
context: ReduceContext,
|
|
99
|
+
*,
|
|
100
|
+
stdin: TextIO,
|
|
101
|
+
stdout: TextIO,
|
|
102
|
+
stop: asyncio.Event | None = None,
|
|
103
|
+
) -> ExitCode:
|
|
104
|
+
tokens = parse_prompt(request.prompt)
|
|
105
|
+
reject_comma_groups(tokens)
|
|
106
|
+
if has_brace(tokens) and request.group_by is None:
|
|
107
|
+
raise UsageFault(
|
|
108
|
+
"field references like {x} in reduce only work with --group-by "
|
|
109
|
+
"(where {field} means the group's value)"
|
|
110
|
+
)
|
|
111
|
+
schema = resolve_schema(request.schema_path, request.schema_dsl, loader=load_schema)
|
|
112
|
+
if request.every is not None and request.window is None:
|
|
113
|
+
raise UsageFault(
|
|
114
|
+
"--every only makes sense with --window\n"
|
|
115
|
+
" --window N summarizes every N lines; --every M makes those windows slide.\n"
|
|
116
|
+
' Example: tail -f app.log | smartpipe reduce --window 100 --every 20 "error trend?"'
|
|
117
|
+
)
|
|
118
|
+
if request.window is not None:
|
|
119
|
+
return await _run_windowed(request, tokens, schema, context, stdin, stdout, stop)
|
|
120
|
+
items_iter, _total = readers.resolve_items(request.input, stdin)
|
|
121
|
+
collected = [item async for item in items_iter] # whole-set verbs need everything
|
|
122
|
+
items: list[Item] = []
|
|
123
|
+
media_skipped = 0
|
|
124
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
125
|
+
model = await context.chat_model(request.model_flag)
|
|
126
|
+
converter = make_converter(
|
|
127
|
+
model, allow_paid=request.allow_captions, log=log, stt=context.remote_transcriber(model.ref)
|
|
128
|
+
)
|
|
129
|
+
for candidate in collected:
|
|
130
|
+
try:
|
|
131
|
+
items.append(await ensure_text(candidate, log=log, converter=converter))
|
|
132
|
+
except ItemError as exc:
|
|
133
|
+
diagnostics.warn(f"skipped: {describe_source(candidate.source)} ({exc})")
|
|
134
|
+
media_skipped += 1
|
|
135
|
+
concurrency = context.concurrency(request.concurrency_flag)
|
|
136
|
+
structured = schema is not None
|
|
137
|
+
writer = context.writer(
|
|
138
|
+
OutputFormat.AUTO, structured=structured, stdout=stdout, fields=request.fields
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if not items:
|
|
142
|
+
return ExitCode.OK
|
|
143
|
+
|
|
144
|
+
reducer = Reducer(
|
|
145
|
+
model=model,
|
|
146
|
+
budget=budget_for(model.ref.provider, prompt_overhead=_OVERHEAD_TOKENS),
|
|
147
|
+
concurrency=concurrency,
|
|
148
|
+
verbose=request.verbose,
|
|
149
|
+
window_budget=_window_budget(context, model),
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
if request.group_by is not None:
|
|
153
|
+
await _run_grouped(reducer, request, tokens, schema, items, writer)
|
|
154
|
+
else:
|
|
155
|
+
await _run_single(reducer, tokens, schema, items, writer)
|
|
156
|
+
finally:
|
|
157
|
+
writer.flush()
|
|
158
|
+
log.finish()
|
|
159
|
+
if reducer.produced == 0:
|
|
160
|
+
return ExitCode.ALL_FAILED
|
|
161
|
+
return ExitCode.PARTIAL if (reducer.skipped or media_skipped) else ExitCode.OK
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _run_single(
|
|
165
|
+
reducer: Reducer,
|
|
166
|
+
tokens: tuple[Token, ...],
|
|
167
|
+
schema: Mapping[str, object] | None,
|
|
168
|
+
items: list[Item],
|
|
169
|
+
writer: ResultWriter,
|
|
170
|
+
) -> None:
|
|
171
|
+
instruction = to_instruction(tokens)
|
|
172
|
+
try:
|
|
173
|
+
result = await reducer.reduce(instruction, schema, [item.text for item in items])
|
|
174
|
+
except ItemError as exc:
|
|
175
|
+
diagnostics.warn(f"reduce failed: {exc}")
|
|
176
|
+
return
|
|
177
|
+
_emit(writer, structured=schema is not None, result=result)
|
|
178
|
+
reducer.produced += 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _run_windowed(
|
|
182
|
+
request: ReduceRequest,
|
|
183
|
+
tokens: tuple[Token, ...],
|
|
184
|
+
schema: Mapping[str, object] | None,
|
|
185
|
+
context: ReduceContext,
|
|
186
|
+
stdin: TextIO,
|
|
187
|
+
stdout: TextIO,
|
|
188
|
+
stop: asyncio.Event | None,
|
|
189
|
+
) -> ExitCode:
|
|
190
|
+
"""Stream mode (stage-08 §4.2): one reduce per window, emitted as it lands;
|
|
191
|
+
the trailing partial window is flushed so Ctrl+C never discards buffered lines."""
|
|
192
|
+
assert request.window is not None
|
|
193
|
+
if request.group_by is not None:
|
|
194
|
+
raise UsageFault(
|
|
195
|
+
"--window can't combine with --group-by (windows and groups don't compose)"
|
|
196
|
+
)
|
|
197
|
+
if request.input.patterns or request.input.from_files:
|
|
198
|
+
raise UsageFault(
|
|
199
|
+
"reduce --window reads a stream from stdin — it can't combine with --in\n"
|
|
200
|
+
" File inputs are a finite batch. Drop --window, or pipe the stream in."
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
policy = WindowPolicy(size=request.window, every=request.every or request.window)
|
|
204
|
+
except ValueError as exc:
|
|
205
|
+
raise UsageFault(str(exc)) from exc
|
|
206
|
+
|
|
207
|
+
model = await context.chat_model(request.model_flag)
|
|
208
|
+
writer = context.writer(
|
|
209
|
+
OutputFormat.AUTO, structured=True, stdout=stdout, fields=request.fields
|
|
210
|
+
)
|
|
211
|
+
instruction = to_instruction(tokens)
|
|
212
|
+
reducer = Reducer(
|
|
213
|
+
model=model,
|
|
214
|
+
budget=budget_for(model.ref.provider, prompt_overhead=_OVERHEAD_TOKENS),
|
|
215
|
+
concurrency=context.concurrency(request.concurrency_flag),
|
|
216
|
+
verbose=request.verbose,
|
|
217
|
+
window_budget=_window_budget(context, model),
|
|
218
|
+
)
|
|
219
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
220
|
+
converter = make_converter(
|
|
221
|
+
model, allow_paid=request.allow_captions, log=log, stt=context.remote_transcriber(model.ref)
|
|
222
|
+
)
|
|
223
|
+
buffer: WindowBuffer[str] = WindowBuffer(policy)
|
|
224
|
+
produced = 0
|
|
225
|
+
failed = 0
|
|
226
|
+
|
|
227
|
+
async def emit(window: Window[str]) -> None:
|
|
228
|
+
nonlocal produced, failed
|
|
229
|
+
try:
|
|
230
|
+
result = await reducer.reduce(instruction, schema, list(window.items))
|
|
231
|
+
except ItemError as exc:
|
|
232
|
+
diagnostics.warn(f"skipped: window ending at line {window.end_index} ({exc})")
|
|
233
|
+
failed += 1
|
|
234
|
+
return
|
|
235
|
+
record: dict[str, object] = {"window_end": window.end_index, "result": result}
|
|
236
|
+
if window.partial:
|
|
237
|
+
record["partial"] = True
|
|
238
|
+
writer.write_record(record)
|
|
239
|
+
produced += 1
|
|
240
|
+
|
|
241
|
+
items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
242
|
+
try:
|
|
243
|
+
async for item in items_iter:
|
|
244
|
+
if item.media:
|
|
245
|
+
try:
|
|
246
|
+
item = await ensure_text(item, log=log, converter=converter)
|
|
247
|
+
except ItemError as exc:
|
|
248
|
+
diagnostics.warn(f"skipped: {describe_source(item.source)} ({exc})")
|
|
249
|
+
failed += 1
|
|
250
|
+
continue
|
|
251
|
+
window = buffer.push(item.text)
|
|
252
|
+
if window is not None:
|
|
253
|
+
await emit(window)
|
|
254
|
+
tail = buffer.flush()
|
|
255
|
+
if tail is not None:
|
|
256
|
+
await emit(tail)
|
|
257
|
+
finally:
|
|
258
|
+
writer.flush()
|
|
259
|
+
log.finish()
|
|
260
|
+
if stop is not None and stop.is_set():
|
|
261
|
+
diagnostics.interrupted_summary(processed=produced, skipped=failed)
|
|
262
|
+
return interrupted_exit_code(done=produced, skipped=failed)
|
|
263
|
+
return outcome_exit_code(done=produced, skipped=failed)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _run_grouped(
|
|
267
|
+
reducer: Reducer,
|
|
268
|
+
request: ReduceRequest,
|
|
269
|
+
tokens: tuple[Token, ...],
|
|
270
|
+
schema: Mapping[str, object] | None,
|
|
271
|
+
items: list[Item],
|
|
272
|
+
writer: ResultWriter,
|
|
273
|
+
) -> None:
|
|
274
|
+
assert request.group_by is not None
|
|
275
|
+
for value, group_items in _group(items, request.group_by, reducer):
|
|
276
|
+
instruction = interpolate_fields(tokens, {request.group_by: value})
|
|
277
|
+
try:
|
|
278
|
+
result = await reducer.reduce(instruction, schema, [item.text for item in group_items])
|
|
279
|
+
except ItemError as exc:
|
|
280
|
+
diagnostics.warn(f"reduce failed for group {value!r}: {exc}")
|
|
281
|
+
reducer.skipped += 1
|
|
282
|
+
continue
|
|
283
|
+
writer.write_record({"group": value, "result": result})
|
|
284
|
+
reducer.produced += 1
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _group(items: list[Item], field_name: str, reducer: Reducer) -> list[tuple[object, list[Item]]]:
|
|
288
|
+
order: list[str] = []
|
|
289
|
+
groups: dict[str, tuple[object, list[Item]]] = {}
|
|
290
|
+
for item in items:
|
|
291
|
+
if item.data is None or field_name not in item.data:
|
|
292
|
+
diagnostics.warn(f"skipped: {describe_source(item.source)} (no field '{field_name}')")
|
|
293
|
+
reducer.skipped += 1
|
|
294
|
+
continue
|
|
295
|
+
value = item.data[field_name]
|
|
296
|
+
key = value if isinstance(value, str) else repr(value)
|
|
297
|
+
if key not in groups:
|
|
298
|
+
groups[key] = (value, [])
|
|
299
|
+
order.append(key)
|
|
300
|
+
groups[key][1].append(item)
|
|
301
|
+
return [groups[key] for key in order]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _emit(writer: ResultWriter, *, structured: bool, result: str | Mapping[str, object]) -> None:
|
|
305
|
+
if structured and not isinstance(result, str):
|
|
306
|
+
writer.write_record(result)
|
|
307
|
+
else:
|
|
308
|
+
writer.write_text(result if isinstance(result, str) else str(result))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _window_budget(context: ReduceContext, model: ChatModel) -> Callable[[], Awaitable[int | None]]:
|
|
312
|
+
async def refreshed() -> int | None:
|
|
313
|
+
window = await context.context_window(model.ref)
|
|
314
|
+
if window is None:
|
|
315
|
+
return None
|
|
316
|
+
return budget_for(model.ref.provider, prompt_overhead=_OVERHEAD_TOKENS, window=window)
|
|
317
|
+
|
|
318
|
+
return refreshed
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass
|
|
322
|
+
class Reducer:
|
|
323
|
+
model: ChatModel
|
|
324
|
+
budget: int
|
|
325
|
+
concurrency: int
|
|
326
|
+
verbose: bool
|
|
327
|
+
skipped: int = 0
|
|
328
|
+
produced: int = 0
|
|
329
|
+
bisection_noted: bool = False
|
|
330
|
+
window_budget: Callable[[], Awaitable[int | None]] | None = None
|
|
331
|
+
probed: bool = False
|
|
332
|
+
|
|
333
|
+
async def reduce(
|
|
334
|
+
self, instruction: str, schema: Mapping[str, object] | None, texts: Sequence[str]
|
|
335
|
+
) -> str | Mapping[str, object]:
|
|
336
|
+
trace = [len(texts)]
|
|
337
|
+
current = list(texts)
|
|
338
|
+
semaphore = asyncio.Semaphore(self.concurrency)
|
|
339
|
+
await self._widen_if_possible(current)
|
|
340
|
+
while True:
|
|
341
|
+
while not fits_in_one([estimate_tokens(t) for t in current], self.budget):
|
|
342
|
+
chunks = chunk_indices([estimate_tokens(t) for t in current], self.budget)
|
|
343
|
+
trace.append(len(chunks))
|
|
344
|
+
current = await self._reduce_level(instruction, current, chunks, semaphore)
|
|
345
|
+
if not current:
|
|
346
|
+
raise ItemError("every chunk failed to reduce")
|
|
347
|
+
try:
|
|
348
|
+
if len(trace) > 1:
|
|
349
|
+
trace.append(1)
|
|
350
|
+
if self.verbose:
|
|
351
|
+
diagnostics.note(_trace_line(trace))
|
|
352
|
+
return await self._final(instruction, schema, current)
|
|
353
|
+
except ItemError as exc:
|
|
354
|
+
if not (is_context_overflow(str(exc)) and len(current) > 1):
|
|
355
|
+
raise
|
|
356
|
+
# the synthesis call itself overflowed: collapse one more level
|
|
357
|
+
self._note_bisection()
|
|
358
|
+
first, second = halve(tuple(range(len(current))))
|
|
359
|
+
current = await self._reduce_level(instruction, current, (first, second), semaphore)
|
|
360
|
+
if not current:
|
|
361
|
+
raise ItemError("every chunk failed to reduce") from exc
|
|
362
|
+
|
|
363
|
+
async def _reduce_level(
|
|
364
|
+
self,
|
|
365
|
+
goal: str,
|
|
366
|
+
texts: list[str],
|
|
367
|
+
chunks: tuple[tuple[int, ...], ...],
|
|
368
|
+
semaphore: asyncio.Semaphore,
|
|
369
|
+
) -> list[str]:
|
|
370
|
+
async def reduce_chunk(chunk: tuple[int, ...]) -> list[str]:
|
|
371
|
+
request = build_reduce_intermediate(goal, [texts[i] for i in chunk])
|
|
372
|
+
try:
|
|
373
|
+
async with semaphore: # released before any bisection recursion
|
|
374
|
+
return [await self.model.complete(request)]
|
|
375
|
+
except ItemError as exc:
|
|
376
|
+
if is_context_overflow(str(exc)) and len(chunk) > 1:
|
|
377
|
+
# D26: the wire said this chunk is too big — the estimate
|
|
378
|
+
# lied, so split at item boundaries and retry both halves
|
|
379
|
+
self._note_bisection()
|
|
380
|
+
first, second = halve(chunk)
|
|
381
|
+
return [*await reduce_chunk(first), *await reduce_chunk(second)]
|
|
382
|
+
assert chunk, "chunk_indices never yields an empty chunk"
|
|
383
|
+
diagnostics.warn(
|
|
384
|
+
f"skipped: chunk over items {chunk[0] + 1}-{chunk[-1] + 1} ({exc})"
|
|
385
|
+
)
|
|
386
|
+
self.skipped += 1
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
tasks = [asyncio.create_task(reduce_chunk(chunk)) for chunk in chunks]
|
|
390
|
+
notes = [note for task in tasks for note in await task] # awaited in order
|
|
391
|
+
return notes
|
|
392
|
+
|
|
393
|
+
async def _widen_if_possible(self, texts: list[str]) -> None:
|
|
394
|
+
"""D26 layer 1: the table budget looks too small — ask the provider for
|
|
395
|
+
the real window, once. A bigger true window means fewer (or no) levels."""
|
|
396
|
+
if self.window_budget is None or self.probed:
|
|
397
|
+
return
|
|
398
|
+
if fits_in_one([estimate_tokens(t) for t in texts], self.budget):
|
|
399
|
+
return
|
|
400
|
+
self.probed = True
|
|
401
|
+
refreshed = await self.window_budget()
|
|
402
|
+
if refreshed is not None and refreshed > self.budget:
|
|
403
|
+
self.budget = refreshed
|
|
404
|
+
|
|
405
|
+
def _note_bisection(self) -> None:
|
|
406
|
+
if not self.bisection_noted:
|
|
407
|
+
self.bisection_noted = True
|
|
408
|
+
diagnostics.note(
|
|
409
|
+
"a chunk overflowed the model's window — splitting further and retrying"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
async def _final(
|
|
413
|
+
self, instruction: str, schema: Mapping[str, object] | None, texts: list[str]
|
|
414
|
+
) -> str | Mapping[str, object]:
|
|
415
|
+
request = build_reduce_final(instruction, texts, schema)
|
|
416
|
+
reply = await self.model.complete(request)
|
|
417
|
+
if schema is None:
|
|
418
|
+
return reply.strip()
|
|
419
|
+
try:
|
|
420
|
+
return validate_and_coerce(reply, schema)
|
|
421
|
+
except ItemError as first_error:
|
|
422
|
+
repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
|
|
423
|
+
return validate_and_coerce(await self.model.complete(repair), schema)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _trace_line(trace: list[int]) -> str:
|
|
427
|
+
tail = "".join(f" → {count}" for count in trace[2:])
|
|
428
|
+
return f"reduce: {trace[0]:,} items → {trace[1]} chunks{tail}"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The ``sample`` verb: seeded representative subsets (D38/08, KQL ``sample``).
|
|
2
|
+
|
|
3
|
+
Deterministic BY DEFAULT (seed 0): the same input yields the same sample with
|
|
4
|
+
no flags, so prompt comparisons compare prompts, and P10's methods section
|
|
5
|
+
can cite the sample. Reservoir sampling: one pass, constant memory, free.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from smartpipe.core.errors import ExitCode, UsageFault
|
|
15
|
+
from smartpipe.io import diagnostics
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typing import TextIO
|
|
19
|
+
|
|
20
|
+
__all__ = ["SampleRequest", "run_sample"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class SampleRequest:
|
|
25
|
+
count: int
|
|
26
|
+
seed: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_sample(request: SampleRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
30
|
+
if request.count < 1:
|
|
31
|
+
raise UsageFault("sample needs a positive count")
|
|
32
|
+
rng = random.Random(request.seed) # local instance — never the global RNG
|
|
33
|
+
reservoir: list[tuple[int, str]] = []
|
|
34
|
+
seen = 0
|
|
35
|
+
for line in stdin:
|
|
36
|
+
if not line.strip():
|
|
37
|
+
continue
|
|
38
|
+
raw = line.removesuffix("\n").removesuffix("\r")
|
|
39
|
+
if seen < request.count:
|
|
40
|
+
reservoir.append((seen, raw))
|
|
41
|
+
else:
|
|
42
|
+
slot = rng.randint(0, seen)
|
|
43
|
+
if slot < request.count:
|
|
44
|
+
reservoir[slot] = (seen, raw)
|
|
45
|
+
seen += 1
|
|
46
|
+
for _index, raw in sorted(reservoir): # input order preserved
|
|
47
|
+
stdout.write(raw + "\n")
|
|
48
|
+
if seen <= request.count:
|
|
49
|
+
diagnostics.note(f"sample: input had {seen:,} rows ≤ {request.count:,} — all kept")
|
|
50
|
+
else:
|
|
51
|
+
diagnostics.note(f"sample: {len(reservoir):,} of {seen:,} (seed {request.seed})")
|
|
52
|
+
return ExitCode.OK
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The ``sort`` verb: order NDJSON by a field (D38/10, KQL ``sort by``).
|
|
2
|
+
|
|
3
|
+
Free, whole-set (inherently), stable, passthrough-verbatim. Missing-field
|
|
4
|
+
rows always land last — in both directions — and are disclosed.
|
|
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.io import diagnostics
|
|
14
|
+
from smartpipe.io.items import item_from_line
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from typing import TextIO
|
|
18
|
+
|
|
19
|
+
__all__ = ["SortRequest", "run_sort"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class SortRequest:
|
|
24
|
+
by: str
|
|
25
|
+
descending: bool = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_sort(request: SortRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
29
|
+
keyed: list[tuple[tuple[int, float, str], str]] = []
|
|
30
|
+
missing: list[str] = []
|
|
31
|
+
for index, line in enumerate(stdin):
|
|
32
|
+
if not line.strip():
|
|
33
|
+
continue
|
|
34
|
+
item = item_from_line(line, index)
|
|
35
|
+
value = item.data.get(request.by) if item.data is not None else None
|
|
36
|
+
if value is None:
|
|
37
|
+
missing.append(item.raw)
|
|
38
|
+
continue
|
|
39
|
+
keyed.append((_key(value, descending=request.descending), item.raw))
|
|
40
|
+
keyed.sort(key=lambda pair: pair[0]) # stable: ties keep input order
|
|
41
|
+
for _sort_key, raw in keyed:
|
|
42
|
+
stdout.write(raw + "\n")
|
|
43
|
+
for raw in missing: # always last, regardless of direction
|
|
44
|
+
stdout.write(raw + "\n")
|
|
45
|
+
if missing:
|
|
46
|
+
diagnostics.note(f"sort: {len(missing):,} rows missing '{request.by}' placed last")
|
|
47
|
+
return ExitCode.OK
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _key(value: object, *, descending: bool) -> tuple[int, float, str]:
|
|
51
|
+
"""Numbers first (numerically), then strings (lexically), then the rest
|
|
52
|
+
(stringified). Descending flips within each band — bands never mix."""
|
|
53
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
54
|
+
text = value if isinstance(value, str) else str(value)
|
|
55
|
+
band = 1 if isinstance(value, str) else 2
|
|
56
|
+
return (band, 0.0, _flip_text(text) if descending else text)
|
|
57
|
+
number = float(value)
|
|
58
|
+
return (0, -number if descending else number, "")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _flip_text(text: str) -> str:
|
|
62
|
+
# descending lexicographic via per-character complement (stable, pure)
|
|
63
|
+
return "".join(chr(0x10FFFF - ord(char)) for char in text)
|