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/embed.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The ``embed`` verb: turn each item into a vector (spec §3.3).
|
|
2
|
+
|
|
3
|
+
The only verb that never touches a chat LLM — it uses the embedding model and
|
|
4
|
+
emits one NDJSON record per item: ``{"text", "vector", "source"}``. Output is
|
|
5
|
+
always NDJSON (a vector has no human view), so it feeds ``top_k`` or a file.
|
|
6
|
+
|
|
7
|
+
Two execution shapes (plan/post-1.0/06, DEFER-3): a finite file corpus is
|
|
8
|
+
embedded in ≤64-text chunks (64x fewer round-trips, poison chunks re-run
|
|
9
|
+
item-by-item); a stream stays one item per call — latency beats throughput
|
|
10
|
+
when lines arrive over time.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Protocol
|
|
18
|
+
|
|
19
|
+
from smartpipe.core.errors import ExitCode
|
|
20
|
+
from smartpipe.engine.runner import Done, FailurePolicy, run_ordered
|
|
21
|
+
from smartpipe.io import diagnostics, readers, tty
|
|
22
|
+
from smartpipe.io.inputs import STDIN
|
|
23
|
+
from smartpipe.io.items import describe_source
|
|
24
|
+
from smartpipe.io.progress import make_stderr_spinner
|
|
25
|
+
from smartpipe.io.writers import RenderMode, WriterConfig, make_writer
|
|
26
|
+
from smartpipe.models.base import VideoData
|
|
27
|
+
from smartpipe.verbs.common import (
|
|
28
|
+
embed_in_batches,
|
|
29
|
+
ensure_text,
|
|
30
|
+
interrupted_exit_code,
|
|
31
|
+
outcome_exit_code,
|
|
32
|
+
)
|
|
33
|
+
from smartpipe.verbs.convert import Converter, embed_video_halves, make_converter
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from typing import TextIO
|
|
37
|
+
|
|
38
|
+
from smartpipe.io.inputs import InputSpec
|
|
39
|
+
from smartpipe.io.items import Item
|
|
40
|
+
from smartpipe.models.base import ChatModel, EmbeddingModel, ModelRef
|
|
41
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
42
|
+
|
|
43
|
+
__all__ = ["EmbedContext", "EmbedRequest", "optional_chat", "run_embed"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class EmbedRequest:
|
|
48
|
+
model_flag: str | None
|
|
49
|
+
concurrency_flag: int | None
|
|
50
|
+
allow_captions: bool = False # cloud conversions opt-in (D33)
|
|
51
|
+
input: InputSpec = STDIN
|
|
52
|
+
fields: tuple[str, ...] | None = None # --fields: project the {text, vector, source} records
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EmbedContext(Protocol):
|
|
56
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
|
|
57
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
58
|
+
async def embedding_model(self, flag: str | None = None) -> EmbeddingModel: ...
|
|
59
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def run_embed(
|
|
63
|
+
request: EmbedRequest,
|
|
64
|
+
context: EmbedContext,
|
|
65
|
+
*,
|
|
66
|
+
stdin: TextIO,
|
|
67
|
+
stdout: TextIO,
|
|
68
|
+
stop: asyncio.Event | None = None,
|
|
69
|
+
) -> ExitCode:
|
|
70
|
+
model = await context.embedding_model(request.model_flag)
|
|
71
|
+
concurrency = context.concurrency(request.concurrency_flag)
|
|
72
|
+
|
|
73
|
+
items_iter, total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
74
|
+
if (total is None or total > 0) and tty.stdout_is_tty():
|
|
75
|
+
diagnostics.note(
|
|
76
|
+
"embeddings are large — redirect to a file: smartpipe embed > corpus.embeddings"
|
|
77
|
+
)
|
|
78
|
+
writer = make_writer(
|
|
79
|
+
WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=request.fields), stdout
|
|
80
|
+
)
|
|
81
|
+
spinner = make_stderr_spinner()
|
|
82
|
+
spinner.start(total=total)
|
|
83
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
84
|
+
converter_chat = await optional_chat(context)
|
|
85
|
+
converter = make_converter(
|
|
86
|
+
converter_chat,
|
|
87
|
+
allow_paid=request.allow_captions,
|
|
88
|
+
log=log,
|
|
89
|
+
stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
done = 0
|
|
93
|
+
skipped = 0
|
|
94
|
+
if total is not None:
|
|
95
|
+
# finite --in corpus: chunked calls, run_ordered bypassed on purpose —
|
|
96
|
+
# batching ≠ per-item workers (order from sequential chunks, isolation
|
|
97
|
+
# from the per-item fallback inside embed_in_batches)
|
|
98
|
+
collected = [item async for item in items_iter]
|
|
99
|
+
outcomes = embed_in_batches(
|
|
100
|
+
model,
|
|
101
|
+
collected,
|
|
102
|
+
failure_policy=FailurePolicy(),
|
|
103
|
+
stop=stop,
|
|
104
|
+
log=log,
|
|
105
|
+
converter=converter,
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
# live stream: one item per call — latency beats throughput
|
|
109
|
+
|
|
110
|
+
async def worker(item: Item) -> tuple[Item, tuple[float, ...]]:
|
|
111
|
+
return await _embed_one(model, item, log, converter)
|
|
112
|
+
|
|
113
|
+
outcomes = run_ordered(
|
|
114
|
+
items_iter,
|
|
115
|
+
worker,
|
|
116
|
+
concurrency=concurrency,
|
|
117
|
+
failure_policy=FailurePolicy(),
|
|
118
|
+
stop=stop,
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
async for outcome in outcomes:
|
|
122
|
+
if isinstance(outcome, Done):
|
|
123
|
+
item, vector = outcome.value
|
|
124
|
+
writer.write_record(
|
|
125
|
+
{
|
|
126
|
+
"text": item.text,
|
|
127
|
+
"vector": list(vector),
|
|
128
|
+
"source": item.source.name,
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
done += 1
|
|
132
|
+
else: # Skipped
|
|
133
|
+
diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
|
|
134
|
+
skipped += 1
|
|
135
|
+
spinner.advance()
|
|
136
|
+
finally:
|
|
137
|
+
spinner.finish()
|
|
138
|
+
writer.flush()
|
|
139
|
+
log.finish()
|
|
140
|
+
if stop is not None and stop.is_set():
|
|
141
|
+
diagnostics.interrupted_summary(processed=done, skipped=skipped)
|
|
142
|
+
return interrupted_exit_code(done=done, skipped=skipped)
|
|
143
|
+
return outcome_exit_code(done=done, skipped=skipped)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def optional_chat(context: EmbedContext) -> ChatModel | None:
|
|
147
|
+
"""The converter's LLM rung — absent when no chat model is configured;
|
|
148
|
+
embedding never fails because chat isn't set up (D33)."""
|
|
149
|
+
try:
|
|
150
|
+
return await context.chat_model()
|
|
151
|
+
except Exception:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _embed_one(
|
|
156
|
+
model: EmbeddingModel,
|
|
157
|
+
item: Item,
|
|
158
|
+
log: diagnostics.DegradationLog,
|
|
159
|
+
converter: Converter,
|
|
160
|
+
) -> tuple[Item, tuple[float, ...]]:
|
|
161
|
+
video = next((part for part in item.media if isinstance(part, VideoData)), None)
|
|
162
|
+
if video is not None and converter.chat is not None:
|
|
163
|
+
return await embed_video_halves(model, item, video, converter) # 50/50 (D36)
|
|
164
|
+
item = await ensure_text(item, log=log, converter=converter) # D33 ladder
|
|
165
|
+
vectors = await model.embed([item.text])
|
|
166
|
+
return item, vectors[0] # the CONVERTED item — its text is what the vector means
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The ``extend`` verb: enrich, don't replace (D38/02, KQL ``extend``).
|
|
2
|
+
|
|
3
|
+
map's machinery with a merge at the emit edge: the input record's fields
|
|
4
|
+
survive, the extracted fields land beside them. The verb every dataset owner
|
|
5
|
+
(P1/P11/P12/P13) needs so results flow back into their existing pipelines.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import partial
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from smartpipe.core.errors import ExitCode, ItemError, UsageFault
|
|
17
|
+
from smartpipe.engine.prompts import parse_prompt, plan_map, to_instruction
|
|
18
|
+
from smartpipe.engine.runner import Done, FailurePolicy, run_ordered
|
|
19
|
+
from smartpipe.engine.schema import load_schema
|
|
20
|
+
from smartpipe.io import diagnostics, readers
|
|
21
|
+
from smartpipe.io.inputs import STDIN
|
|
22
|
+
from smartpipe.io.items import describe_source
|
|
23
|
+
from smartpipe.io.progress import make_stderr_spinner
|
|
24
|
+
from smartpipe.verbs.common import (
|
|
25
|
+
WindowGate,
|
|
26
|
+
interrupted_exit_code,
|
|
27
|
+
outcome_exit_code,
|
|
28
|
+
resolve_schema,
|
|
29
|
+
)
|
|
30
|
+
from smartpipe.verbs.map import MapContext, map_one
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from pathlib import Path
|
|
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 OutputFormat
|
|
39
|
+
|
|
40
|
+
__all__ = ["ExtendRequest", "base_fields", "run_extend"]
|
|
41
|
+
|
|
42
|
+
_PROMPT_OVERHEAD_TOKENS = 500 # instruction + wrapper + reply headroom (map's)
|
|
43
|
+
|
|
44
|
+
EXTEND_NEEDS_FIELDS = (
|
|
45
|
+
"extend adds fields — name them in braces or pass --schema\n"
|
|
46
|
+
' Example: smartpipe extend "Add {sentiment enum(pos, neg, neutral)}"\n'
|
|
47
|
+
" Plain-text transformation? That's map."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# media transport keys (D27/D32): consumed by the model, poison to re-emit
|
|
51
|
+
_TRANSPORT_KEYS = frozenset({"audio_b64", "image_b64", "video_b64", "parts", "mime"})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class ExtendRequest:
|
|
56
|
+
prompt: str
|
|
57
|
+
schema_path: Path | None
|
|
58
|
+
model_flag: str | None
|
|
59
|
+
output: OutputFormat
|
|
60
|
+
concurrency_flag: int | None
|
|
61
|
+
input: InputSpec = STDIN
|
|
62
|
+
fields: tuple[str, ...] | None = None
|
|
63
|
+
schema_dsl: str | None = None
|
|
64
|
+
tally_field: str | None = None
|
|
65
|
+
explode_field: str | None = None
|
|
66
|
+
frame_every: float | None = None # D43
|
|
67
|
+
max_frames: int | None = None # D43
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def run_extend(
|
|
71
|
+
request: ExtendRequest,
|
|
72
|
+
context: MapContext,
|
|
73
|
+
*,
|
|
74
|
+
stdin: TextIO,
|
|
75
|
+
stdout: TextIO,
|
|
76
|
+
stop: asyncio.Event | None = None,
|
|
77
|
+
) -> ExitCode:
|
|
78
|
+
tokens = parse_prompt(request.prompt, allow_descriptions=True)
|
|
79
|
+
schema = resolve_schema(request.schema_path, request.schema_dsl, loader=load_schema)
|
|
80
|
+
plan = plan_map(tokens, schema=schema)
|
|
81
|
+
if plan.mode != "structured":
|
|
82
|
+
raise UsageFault(EXTEND_NEEDS_FIELDS) # exit 64, zero model calls
|
|
83
|
+
instruction = to_instruction(tokens)
|
|
84
|
+
items_iter, total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
85
|
+
model = await context.chat_model(request.model_flag)
|
|
86
|
+
writer = context.writer(request.output, structured=True, stdout=stdout, fields=request.fields)
|
|
87
|
+
concurrency = context.concurrency(request.concurrency_flag)
|
|
88
|
+
|
|
89
|
+
tally = None
|
|
90
|
+
if request.tally_field is not None:
|
|
91
|
+
from smartpipe.engine.tally import Tally
|
|
92
|
+
|
|
93
|
+
tally = Tally(request.tally_field)
|
|
94
|
+
|
|
95
|
+
spinner = make_stderr_spinner()
|
|
96
|
+
spinner.start(total=total)
|
|
97
|
+
|
|
98
|
+
log = diagnostics.DegradationLog()
|
|
99
|
+
gate = WindowGate(
|
|
100
|
+
provider=model.ref.provider,
|
|
101
|
+
model_name=model.ref.name,
|
|
102
|
+
overhead=_PROMPT_OVERHEAD_TOKENS,
|
|
103
|
+
window=partial(context.context_window, model.ref),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def worker(item: Item) -> tuple[Item, Mapping[str, object]]:
|
|
107
|
+
budget = await gate.budget_for_oversized(item.text)
|
|
108
|
+
if budget is not None:
|
|
109
|
+
raise ItemError(gate.refusal(item.text, budget)) # D26: no silent chunking
|
|
110
|
+
result = await map_one(
|
|
111
|
+
model,
|
|
112
|
+
plan,
|
|
113
|
+
instruction,
|
|
114
|
+
item,
|
|
115
|
+
log,
|
|
116
|
+
frame_every=request.frame_every,
|
|
117
|
+
max_frames=request.max_frames,
|
|
118
|
+
)
|
|
119
|
+
assert isinstance(result, Mapping) # structured mode: validated against the schema
|
|
120
|
+
return item, result
|
|
121
|
+
|
|
122
|
+
done = 0
|
|
123
|
+
skipped = 0
|
|
124
|
+
overwritten: set[str] = set() # disclosed once per field
|
|
125
|
+
outcomes = run_ordered(
|
|
126
|
+
items_iter,
|
|
127
|
+
worker,
|
|
128
|
+
concurrency=concurrency,
|
|
129
|
+
failure_policy=FailurePolicy(),
|
|
130
|
+
stop=stop,
|
|
131
|
+
)
|
|
132
|
+
try:
|
|
133
|
+
async for outcome in outcomes:
|
|
134
|
+
if isinstance(outcome, Done):
|
|
135
|
+
item, extracted = outcome.value
|
|
136
|
+
base = base_fields(item)
|
|
137
|
+
for collision in sorted(base.keys() & extracted.keys()):
|
|
138
|
+
if collision not in overwritten:
|
|
139
|
+
overwritten.add(collision)
|
|
140
|
+
diagnostics.note(f"overwriting '{collision}' on incoming records")
|
|
141
|
+
merged: dict[str, object] = {**base, **extracted}
|
|
142
|
+
for row in _rows(merged, request.explode_field):
|
|
143
|
+
writer.write_record(row)
|
|
144
|
+
if tally is not None:
|
|
145
|
+
tally.add(row)
|
|
146
|
+
spinner.extra = tally.live_segment()
|
|
147
|
+
done += 1
|
|
148
|
+
else: # Skipped
|
|
149
|
+
diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
|
|
150
|
+
skipped += 1
|
|
151
|
+
spinner.advance()
|
|
152
|
+
finally:
|
|
153
|
+
spinner.finish()
|
|
154
|
+
writer.flush()
|
|
155
|
+
log.finish()
|
|
156
|
+
if tally is not None and tally.counts:
|
|
157
|
+
diagnostics.note(tally.final_line())
|
|
158
|
+
if stop is not None and stop.is_set():
|
|
159
|
+
diagnostics.interrupted_summary(processed=done, skipped=skipped)
|
|
160
|
+
return interrupted_exit_code(done=done, skipped=skipped)
|
|
161
|
+
return outcome_exit_code(done=done, skipped=skipped)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def base_fields(item: Item) -> dict[str, object]:
|
|
165
|
+
"""The record to enrich: its own fields, or {"text": …} for plain lines.
|
|
166
|
+
Media-transport payloads are dropped — consumed by the model, poison to
|
|
167
|
+
re-emit (megabytes of b64 in every output row)."""
|
|
168
|
+
if item.data is None:
|
|
169
|
+
return {"text": item.text}
|
|
170
|
+
if not item.media:
|
|
171
|
+
return dict(item.data)
|
|
172
|
+
return {key: value for key, value in item.data.items() if key not in _TRANSPORT_KEYS}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _rows(merged: Mapping[str, object], explode_field: str | None) -> list[Mapping[str, object]]:
|
|
176
|
+
if explode_field is None:
|
|
177
|
+
return [merged]
|
|
178
|
+
from smartpipe.engine.tally import explode_record
|
|
179
|
+
|
|
180
|
+
return list(explode_record(merged, explode_field))
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""The ``filter`` verb: semantic grep (spec §3.2).
|
|
2
|
+
|
|
3
|
+
Judges each item against a natural-language condition and emits the items that
|
|
4
|
+
match — byte-for-byte unchanged, in input order, a strict subset of the input.
|
|
5
|
+
``--not`` inverts. Zero matches is success (exit 0), unlike grep: an empty result
|
|
6
|
+
is a valid result.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from functools import partial
|
|
14
|
+
from typing import TYPE_CHECKING, Protocol
|
|
15
|
+
|
|
16
|
+
from smartpipe.cli import screens
|
|
17
|
+
from smartpipe.core.errors import ExitCode, ItemError, UsageFault
|
|
18
|
+
from smartpipe.engine.chunking import split_text
|
|
19
|
+
from smartpipe.engine.prompts import (
|
|
20
|
+
JUDGE_SCHEMA,
|
|
21
|
+
build_filter_request,
|
|
22
|
+
build_repair_request,
|
|
23
|
+
has_brace,
|
|
24
|
+
interpolate_fields,
|
|
25
|
+
parse_prompt,
|
|
26
|
+
reject_comma_groups,
|
|
27
|
+
)
|
|
28
|
+
from smartpipe.engine.runner import Done, FailurePolicy, run_ordered
|
|
29
|
+
from smartpipe.engine.schema import validate_and_coerce
|
|
30
|
+
from smartpipe.io import diagnostics, readers
|
|
31
|
+
from smartpipe.io.inputs import STDIN
|
|
32
|
+
from smartpipe.io.items import describe_source
|
|
33
|
+
from smartpipe.io.progress import make_stderr_spinner
|
|
34
|
+
from smartpipe.io.writers import OutputFormat
|
|
35
|
+
from smartpipe.verbs.common import (
|
|
36
|
+
WindowGate,
|
|
37
|
+
ensure_text,
|
|
38
|
+
interrupted_exit_code,
|
|
39
|
+
outcome_exit_code,
|
|
40
|
+
prepend,
|
|
41
|
+
)
|
|
42
|
+
from smartpipe.verbs.convert import Converter, make_converter
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from typing import TextIO
|
|
46
|
+
|
|
47
|
+
from smartpipe.engine.prompts import Token
|
|
48
|
+
from smartpipe.io.inputs import InputSpec
|
|
49
|
+
from smartpipe.io.items import Item
|
|
50
|
+
from smartpipe.io.writers import ResultWriter
|
|
51
|
+
from smartpipe.models.base import ChatModel, ModelRef
|
|
52
|
+
from smartpipe.models.stt import RemoteTranscriber
|
|
53
|
+
|
|
54
|
+
__all__ = ["FilterContext", "FilterRequest", "run_filter"]
|
|
55
|
+
|
|
56
|
+
_PROMPT_OVERHEAD_TOKENS = 500 # condition + judge wrapper headroom
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class FilterRequest:
|
|
61
|
+
condition: str
|
|
62
|
+
invert: bool
|
|
63
|
+
model_flag: str | None
|
|
64
|
+
concurrency_flag: int | None
|
|
65
|
+
input: InputSpec = STDIN
|
|
66
|
+
allow_captions: bool = False # cloud conversions opt-in (D33)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class FilterContext(Protocol):
|
|
70
|
+
def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
|
|
71
|
+
async def chat_model(self, flag: str | None = None) -> ChatModel: ...
|
|
72
|
+
async def context_window(self, ref: ModelRef) -> int | None: ...
|
|
73
|
+
def concurrency(self, flag: int | None = None) -> int: ...
|
|
74
|
+
def writer(
|
|
75
|
+
self, output_flag: OutputFormat, *, structured: bool, stdout: TextIO
|
|
76
|
+
) -> ResultWriter: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def run_filter(
|
|
80
|
+
request: FilterRequest,
|
|
81
|
+
context: FilterContext,
|
|
82
|
+
*,
|
|
83
|
+
stdin: TextIO,
|
|
84
|
+
stdout: TextIO,
|
|
85
|
+
stop: asyncio.Event | None = None,
|
|
86
|
+
) -> ExitCode:
|
|
87
|
+
tokens = parse_prompt(request.condition) # UsageFault on bad grammar
|
|
88
|
+
reject_comma_groups(tokens) # UsageFault: comma-braces are map-only
|
|
89
|
+
items_iter, total = readers.resolve_items(request.input, stdin, stop=stop)
|
|
90
|
+
model = await context.chat_model(request.model_flag)
|
|
91
|
+
writer = context.writer(OutputFormat.AUTO, structured=False, stdout=stdout)
|
|
92
|
+
concurrency = context.concurrency(request.concurrency_flag)
|
|
93
|
+
|
|
94
|
+
# First-item brace check (streaming can't see "all items" up front): the common
|
|
95
|
+
# mistake — braces over a plain-text pipe — still fails fast, before any model
|
|
96
|
+
# call; a mixed stream after a JSON first line skips per item instead.
|
|
97
|
+
first = await anext(items_iter, None)
|
|
98
|
+
if first is None:
|
|
99
|
+
if stop is not None and stop.is_set():
|
|
100
|
+
diagnostics.interrupted_summary(processed=0, skipped=0)
|
|
101
|
+
return interrupted_exit_code(done=0, skipped=0)
|
|
102
|
+
return ExitCode.OK
|
|
103
|
+
if has_brace(tokens) and first.data is None:
|
|
104
|
+
raise UsageFault(screens.FIELD_REF_ON_PLAIN_INPUT) # exit 64, zero model calls
|
|
105
|
+
items_iter = prepend(first, items_iter)
|
|
106
|
+
|
|
107
|
+
spinner = make_stderr_spinner()
|
|
108
|
+
spinner.start(total=total)
|
|
109
|
+
|
|
110
|
+
log = diagnostics.DegradationLog() # per-row conversion disclosure (D27)
|
|
111
|
+
converter = make_converter(
|
|
112
|
+
model, allow_paid=request.allow_captions, log=log, stt=context.remote_transcriber(model.ref)
|
|
113
|
+
)
|
|
114
|
+
gate = WindowGate(
|
|
115
|
+
provider=model.ref.provider,
|
|
116
|
+
model_name=model.ref.name,
|
|
117
|
+
overhead=_PROMPT_OVERHEAD_TOKENS,
|
|
118
|
+
window=partial(context.context_window, model.ref),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def worker(item: Item) -> tuple[Item, bool]:
|
|
122
|
+
budget = await gate.budget_for_oversized(item.text)
|
|
123
|
+
if budget is None:
|
|
124
|
+
return item, await _judge(model, tokens, item, log, converter)
|
|
125
|
+
# D26: judge the chunks — any match keeps the whole item (--not inverts after)
|
|
126
|
+
for chunk in split_text(item.text, budget):
|
|
127
|
+
if await _judge(model, tokens, replace(item, text=chunk), log, converter):
|
|
128
|
+
return item, True
|
|
129
|
+
return item, False
|
|
130
|
+
|
|
131
|
+
judged = 0
|
|
132
|
+
matches = 0
|
|
133
|
+
skipped = 0
|
|
134
|
+
outcomes = run_ordered(
|
|
135
|
+
items_iter,
|
|
136
|
+
worker,
|
|
137
|
+
concurrency=concurrency,
|
|
138
|
+
failure_policy=FailurePolicy(),
|
|
139
|
+
stop=stop,
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
async for outcome in outcomes:
|
|
143
|
+
if isinstance(outcome, Done):
|
|
144
|
+
judged += 1
|
|
145
|
+
item, matched = outcome.value
|
|
146
|
+
if matched:
|
|
147
|
+
matches += 1
|
|
148
|
+
spinner.matched = matches # the status line's "N matched" segment
|
|
149
|
+
if matched is not request.invert: # kept (or, with --not, dropped)
|
|
150
|
+
_emit_match(writer, item)
|
|
151
|
+
else: # Skipped
|
|
152
|
+
diagnostics.warn(f"skipped: {describe_source(outcome.source)} ({outcome.reason})")
|
|
153
|
+
skipped += 1
|
|
154
|
+
spinner.advance()
|
|
155
|
+
finally:
|
|
156
|
+
spinner.finish()
|
|
157
|
+
writer.flush()
|
|
158
|
+
log.finish()
|
|
159
|
+
if stop is not None and stop.is_set():
|
|
160
|
+
diagnostics.interrupted_summary(processed=judged, skipped=skipped)
|
|
161
|
+
return interrupted_exit_code(done=judged, skipped=skipped)
|
|
162
|
+
return outcome_exit_code(done=judged, skipped=skipped)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _emit_match(writer: ResultWriter, item: Item) -> None:
|
|
166
|
+
# In file mode the useful output is the filename, not the extracted document text
|
|
167
|
+
# (rank/keep files → get paths back, the Unix behavior — spec §8 / stage-07).
|
|
168
|
+
if item.source.kind == "file":
|
|
169
|
+
writer.write_text(item.source.name)
|
|
170
|
+
else:
|
|
171
|
+
writer.write_passthrough(item)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _judge(
|
|
175
|
+
model: ChatModel,
|
|
176
|
+
tokens: tuple[Token, ...],
|
|
177
|
+
item: Item,
|
|
178
|
+
log: diagnostics.DegradationLog,
|
|
179
|
+
converter: Converter,
|
|
180
|
+
) -> bool:
|
|
181
|
+
item = await ensure_text(item, log=log, converter=converter) # D33 ladder
|
|
182
|
+
condition = interpolate_fields(tokens, item.data) # ItemError → skip-and-warn
|
|
183
|
+
request = build_filter_request(condition, item.text)
|
|
184
|
+
reply = await model.complete(request)
|
|
185
|
+
try:
|
|
186
|
+
verdict = validate_and_coerce(reply, JUDGE_SCHEMA)
|
|
187
|
+
except ItemError as first_error:
|
|
188
|
+
repair = build_repair_request(request, bad_reply=reply, error=str(first_error))
|
|
189
|
+
repaired = await model.complete(repair)
|
|
190
|
+
verdict = validate_and_coerce(repaired, JUDGE_SCHEMA) # second failure → Skipped
|
|
191
|
+
return bool(verdict["match"])
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The ``getschema`` verb: what's in this stream (D38/09, KQL ``getschema``).
|
|
2
|
+
|
|
3
|
+
Everyone's first 30 seconds with a new file, answered for free: fields,
|
|
4
|
+
types, coverage, an example each — and what to try next.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from smartpipe.core.errors import ExitCode
|
|
14
|
+
from smartpipe.io import diagnostics, tty
|
|
15
|
+
from smartpipe.io.items import item_from_line
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typing import TextIO
|
|
19
|
+
|
|
20
|
+
__all__ = ["GetSchemaRequest", "run_getschema"]
|
|
21
|
+
|
|
22
|
+
_SCAN_CAP = 10_000
|
|
23
|
+
_EXAMPLE_WIDTH = 24
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class GetSchemaRequest:
|
|
28
|
+
scan_all: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class _FieldState:
|
|
33
|
+
types: set[str] = field(default_factory=set[str])
|
|
34
|
+
non_null: int = 0
|
|
35
|
+
example: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_getschema(request: GetSchemaRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
|
|
39
|
+
fields: dict[str, _FieldState] = {}
|
|
40
|
+
scanned = 0
|
|
41
|
+
plain_lengths: list[int] = []
|
|
42
|
+
capped = False
|
|
43
|
+
for index, line in enumerate(stdin):
|
|
44
|
+
if not line.strip():
|
|
45
|
+
continue
|
|
46
|
+
if not request.scan_all and scanned >= _SCAN_CAP:
|
|
47
|
+
capped = True
|
|
48
|
+
break
|
|
49
|
+
scanned += 1
|
|
50
|
+
item = item_from_line(line, index)
|
|
51
|
+
if item.data is None:
|
|
52
|
+
plain_lengths.append(len(item.text))
|
|
53
|
+
continue
|
|
54
|
+
for name, value in item.data.items():
|
|
55
|
+
state = fields.setdefault(name, _FieldState())
|
|
56
|
+
state.types.add(_type_name(value))
|
|
57
|
+
if value is not None:
|
|
58
|
+
state.non_null += 1
|
|
59
|
+
if state.example is None:
|
|
60
|
+
state.example = _render_example(value)
|
|
61
|
+
|
|
62
|
+
if not fields:
|
|
63
|
+
lengths = sorted(plain_lengths)
|
|
64
|
+
median = lengths[len(lengths) // 2] if lengths else 0
|
|
65
|
+
stdout.write(
|
|
66
|
+
f"plain text lines (no fields) — {scanned:,} lines · median {median:,} chars\n"
|
|
67
|
+
)
|
|
68
|
+
diagnostics.note('try: smartpipe map "Extract {label}" · smartpipe cluster')
|
|
69
|
+
return ExitCode.OK
|
|
70
|
+
|
|
71
|
+
rows = [
|
|
72
|
+
{
|
|
73
|
+
"field": name,
|
|
74
|
+
"type": "|".join(sorted(state.types - {"null"}) or ["null"]),
|
|
75
|
+
"coverage": f"{round(100 * state.non_null / scanned)}%",
|
|
76
|
+
"example": state.example if state.example is not None else "",
|
|
77
|
+
}
|
|
78
|
+
for name, state in fields.items()
|
|
79
|
+
]
|
|
80
|
+
if tty.stdout_is_tty(): # pragma: no cover — piped in tests; the table is trivial
|
|
81
|
+
widths = {
|
|
82
|
+
key: max(len(key), *(len(str(row[key])) for row in rows))
|
|
83
|
+
for key in ("field", "type", "coverage")
|
|
84
|
+
}
|
|
85
|
+
from smartpipe.cli.screens import heading
|
|
86
|
+
|
|
87
|
+
header = (
|
|
88
|
+
f"{'field'.ljust(widths['field'])} {'type'.ljust(widths['type'])} "
|
|
89
|
+
f"{'coverage'.ljust(widths['coverage'])} example"
|
|
90
|
+
)
|
|
91
|
+
stdout.write(heading(header) + "\n")
|
|
92
|
+
for row in rows:
|
|
93
|
+
stdout.write(
|
|
94
|
+
f"{str(row['field']).ljust(widths['field'])} "
|
|
95
|
+
f"{str(row['type']).ljust(widths['type'])} "
|
|
96
|
+
f"{str(row['coverage']).ljust(widths['coverage'])} {row['example']}\n"
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
for row in rows:
|
|
100
|
+
stdout.write(json.dumps(row, separators=(",", ":")) + "\n")
|
|
101
|
+
if capped:
|
|
102
|
+
diagnostics.note(f"getschema: first {_SCAN_CAP:,} rows — --all scans everything")
|
|
103
|
+
best = max(
|
|
104
|
+
(name for name in fields if name != "text"),
|
|
105
|
+
key=lambda name: fields[name].non_null,
|
|
106
|
+
default=None,
|
|
107
|
+
)
|
|
108
|
+
if best is not None:
|
|
109
|
+
diagnostics.note(f"try: smartpipe chart {best} · smartpipe where '{best} …'")
|
|
110
|
+
return ExitCode.OK
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _type_name(value: object) -> str:
|
|
114
|
+
match value:
|
|
115
|
+
case None:
|
|
116
|
+
return "null"
|
|
117
|
+
case bool():
|
|
118
|
+
return "boolean"
|
|
119
|
+
case int():
|
|
120
|
+
return "integer"
|
|
121
|
+
case float():
|
|
122
|
+
return "number"
|
|
123
|
+
case str():
|
|
124
|
+
return "string"
|
|
125
|
+
case list():
|
|
126
|
+
return "array"
|
|
127
|
+
case _:
|
|
128
|
+
return "object"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _render_example(value: object) -> str:
|
|
132
|
+
rendered = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
133
|
+
if len(rendered) > _EXAMPLE_WIDTH:
|
|
134
|
+
return rendered[: _EXAMPLE_WIDTH - 1] + "…"
|
|
135
|
+
return rendered
|