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,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