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,153 @@
1
+ """The ``chart`` verb: NDJSON in, a bar chart out. Free — no model calls.
2
+
3
+ The chart IS the result, so it goes to stdout; ``--save`` additionally writes a
4
+ dependency-free SVG. Counts a field across records (or tallies whole lines),
5
+ which makes it the natural tail for the tools upstream:
6
+
7
+ … | smartpipe map "Extract {label}" | smartpipe chart label
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections import Counter
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Protocol
15
+
16
+ from smartpipe.core.errors import ExitCode, UsageFault
17
+ from smartpipe.engine.chart import render_bars, render_svg, render_svg_panels
18
+ from smartpipe.io import diagnostics
19
+ from smartpipe.io.items import item_from_line
20
+
21
+ if TYPE_CHECKING:
22
+ from pathlib import Path
23
+ from typing import TextIO
24
+
25
+ __all__ = ["ChartRequest", "run_chart"]
26
+
27
+ _DEFAULT_TOP = 20
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ChartRequest:
32
+ field: str | None = None # None: tally whole lines
33
+ top: int | None = None
34
+ save: Path | None = None
35
+ title: str | None = None
36
+ facets: tuple[str, ...] = () # --facet a,b,c: several panels, one pass
37
+ by_time: str | None = None # --by-time FIELD:BUCKET — chronological bars (D38/13)
38
+
39
+
40
+ class ChartContext(Protocol):
41
+ """chart needs nothing from the container — the Protocol keeps the shape."""
42
+
43
+
44
+ def run_chart(request: ChartRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
45
+ if request.facets and request.field is not None:
46
+ raise UsageFault("--facet replaces the FIELD argument — pass one or the other")
47
+ if request.by_time is not None and (request.facets or request.field is not None):
48
+ raise UsageFault("--by-time replaces FIELD/--facet — pass one of the three")
49
+ if request.by_time is not None:
50
+ return _run_by_time(request, stdin=stdin, stdout=stdout)
51
+ if request.facets:
52
+ return _run_facets(request, stdin=stdin, stdout=stdout)
53
+ counts: Counter[str] = Counter()
54
+ for index, line in enumerate(stdin):
55
+ if not line.strip():
56
+ continue
57
+ item = item_from_line(line, index)
58
+ if request.field is not None:
59
+ value = item.data.get(request.field) if item.data is not None else None
60
+ counts["(missing)" if value is None else str(value)] += 1
61
+ else:
62
+ counts[item.text.strip()] += 1
63
+ ranked = counts.most_common(request.top or _DEFAULT_TOP)
64
+ dropped = len(counts) - len(ranked)
65
+ stdout.write(render_bars(ranked) + "\n")
66
+ if dropped > 0:
67
+ diagnostics.note(f"{dropped} more values below the top {len(ranked)} (--top widens)")
68
+ if request.save is not None:
69
+ request.save.write_text(
70
+ render_svg(ranked, title=request.title or request.field), encoding="utf-8"
71
+ )
72
+ diagnostics.note(f"chart saved: {request.save} (SVG — opens anywhere, converts to png)")
73
+ return ExitCode.OK
74
+
75
+
76
+ def _run_facets(request: ChartRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
77
+ tallies: dict[str, Counter[str]] = {facet: Counter() for facet in request.facets}
78
+ for index, line in enumerate(stdin):
79
+ if not line.strip():
80
+ continue
81
+ item = item_from_line(line, index)
82
+ for facet, counter in tallies.items():
83
+ value = item.data.get(facet) if item.data is not None else None
84
+ counter["(missing)" if value is None else str(value)] += 1
85
+ limit = request.top or _DEFAULT_TOP
86
+ panels: list[tuple[str, list[tuple[str, int]]]] = []
87
+ for facet, counter in tallies.items():
88
+ ranked = counter.most_common(limit)
89
+ panels.append((facet, ranked))
90
+ dropped = len(counter) - len(ranked)
91
+ if dropped > 0:
92
+ diagnostics.note(f"{facet}: {dropped} more values below the top {len(ranked)}")
93
+ rule_width = 46
94
+ from smartpipe.cli.screens import heading
95
+
96
+ sections = [
97
+ heading(f"── {facet} {'─' * max(1, rule_width - len(facet) - 4)}")
98
+ + "\n"
99
+ + render_bars(ranked)
100
+ for facet, ranked in panels
101
+ ]
102
+ stdout.write("\n".join(sections) + "\n")
103
+ if request.save is not None:
104
+ request.save.write_text(render_svg_panels(panels, title=request.title), encoding="utf-8")
105
+ diagnostics.note(f"chart saved: {request.save} (SVG — opens anywhere, converts to png)")
106
+ return ExitCode.OK
107
+
108
+
109
+ def _run_by_time(request: ChartRequest, *, stdin: TextIO, stdout: TextIO) -> ExitCode:
110
+ from smartpipe.engine.timebin import bucket_label, parse_bucket, parse_timestamp
111
+
112
+ assert request.by_time is not None
113
+ field, colon, bucket_text = request.by_time.partition(":")
114
+ if not colon or not field.strip():
115
+ raise UsageFault(
116
+ "--by-time takes FIELD:BUCKET — e.g. --by-time ts:1h\n"
117
+ " Buckets: 1m · 5m · 15m · 1h · 6h · 1d"
118
+ )
119
+ bucket = parse_bucket(bucket_text)
120
+ field = field.strip()
121
+ counts: Counter[int] = Counter()
122
+ unparseable = 0
123
+ for index, line in enumerate(stdin):
124
+ if not line.strip():
125
+ continue
126
+ item = item_from_line(line, index)
127
+ value = item.data.get(field) if item.data is not None else None
128
+ epoch = parse_timestamp(value)
129
+ if epoch is None:
130
+ unparseable += 1
131
+ continue
132
+ counts[int(epoch // bucket) * bucket] += 1
133
+ if not counts:
134
+ stdout.write("(nothing to chart)\n")
135
+ else:
136
+ # chronological, zero-filled: gaps in a time series are signal
137
+ first, last = min(counts), max(counts)
138
+ rows = [
139
+ (bucket_label(float(moment), bucket), counts.get(moment, 0))
140
+ for moment in range(first, last + bucket, bucket)
141
+ ]
142
+ stdout.write(render_bars(rows) + "\n")
143
+ if request.save is not None:
144
+ request.save.write_text(
145
+ render_svg(rows, title=request.title or field), encoding="utf-8"
146
+ )
147
+ diagnostics.note(f"chart saved: {request.save} (SVG — opens anywhere, converts to png)")
148
+ if unparseable:
149
+ diagnostics.note(
150
+ f"{unparseable:,} rows with unparseable '{field}' — "
151
+ "ISO-8601 or epoch only; preprocess with jq/date"
152
+ )
153
+ return ExitCode.OK
@@ -0,0 +1,220 @@
1
+ """The ``cluster`` verb: group by meaning, label each group (D38/05).
2
+
3
+ KQL's ``autocluster``/``reduce by`` done semantically: N embeddings + one
4
+ label call per cluster — never N chat calls. The Monday slide (P3), the
5
+ phishing lure families (P6), and the qualitative codebook (P10) as one verb.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Protocol
13
+
14
+ from smartpipe.core.errors import ExitCode, ItemError, UsageFault
15
+ from smartpipe.engine.chunking import mean_pool
16
+ from smartpipe.engine.clustering import adaptive_threshold, leader_clusters, merge_to_k
17
+ from smartpipe.engine.ranking import cosine
18
+ from smartpipe.engine.runner import Done, FailurePolicy
19
+ from smartpipe.engine.schema import validate_and_coerce
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.writers import RenderMode, WriterConfig, make_writer
24
+ from smartpipe.models.base import ChatModel, CompletionRequest
25
+ from smartpipe.verbs.common import embed_in_batches
26
+ from smartpipe.verbs.convert import make_converter
27
+ from smartpipe.verbs.embed import optional_chat
28
+
29
+ if TYPE_CHECKING:
30
+ from typing import TextIO
31
+
32
+ from smartpipe.io.inputs import InputSpec
33
+ from smartpipe.io.items import Item
34
+ from smartpipe.models.base import EmbeddingModel, ModelRef
35
+ from smartpipe.models.stt import RemoteTranscriber
36
+
37
+ __all__ = ["ClusterRequest", "label_cluster", "run_cluster"]
38
+
39
+ _EXAMPLES = 3
40
+
41
+ _LABEL_SCHEMA: dict[str, object] = {
42
+ "type": "object",
43
+ "properties": {
44
+ "label": {"type": "string", "description": "3-6 words naming what unites the items"}
45
+ },
46
+ "required": ["label"],
47
+ "additionalProperties": False,
48
+ }
49
+
50
+ _LABEL_SYSTEM = (
51
+ "You name clusters of similar items. Reply with a short, specific label "
52
+ "(3-6 words) capturing what unites them — content, not form."
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class ClusterRequest:
58
+ k: int | None = None
59
+ top: int | None = None
60
+ explode: str | None = None # "members" is the only value
61
+ model_flag: str | None = None # chat, for labels
62
+ embed_flag: str | None = None
63
+ concurrency_flag: int | None = None
64
+ allow_captions: bool = False
65
+ input: InputSpec = STDIN
66
+
67
+
68
+ class ClusterContext(Protocol):
69
+ def remote_transcriber(self, chat_ref: ModelRef | None = None) -> RemoteTranscriber | None: ...
70
+ async def chat_model(self, flag: str | None = None) -> ChatModel: ...
71
+ async def embedding_model(self, flag: str | None = None) -> EmbeddingModel: ...
72
+ def concurrency(self, flag: int | None = None) -> int: ...
73
+
74
+
75
+ async def run_cluster(
76
+ request: ClusterRequest,
77
+ context: ClusterContext,
78
+ *,
79
+ stdin: TextIO,
80
+ stdout: TextIO,
81
+ stop: asyncio.Event | None = None,
82
+ ) -> ExitCode:
83
+ if request.explode is not None and request.explode != "members":
84
+ raise UsageFault("--explode takes exactly 'members' (one row per input item)")
85
+ if request.k is not None and request.k < 1:
86
+ raise UsageFault("--k needs a positive cluster count")
87
+ model = await context.embedding_model(request.embed_flag)
88
+ items_iter, _total = readers.resolve_items(request.input, stdin, stop=stop)
89
+ items = [item async for item in items_iter]
90
+ if not items:
91
+ return ExitCode.OK
92
+ diagnostics.note(
93
+ f"cluster: ~{len(items):,} embeddings + one label call per cluster (typically < 20)"
94
+ )
95
+
96
+ log = diagnostics.DegradationLog()
97
+ converter_chat = await optional_chat(context)
98
+ converter = make_converter(
99
+ converter_chat,
100
+ allow_paid=request.allow_captions,
101
+ log=log,
102
+ stt=context.remote_transcriber(converter_chat.ref if converter_chat else None),
103
+ )
104
+ clustered_items: list[Item] = []
105
+ vectors: list[tuple[float, ...]] = []
106
+ outcomes = embed_in_batches(
107
+ model,
108
+ items,
109
+ failure_policy=FailurePolicy(),
110
+ stop=stop,
111
+ log=log,
112
+ converter=converter,
113
+ )
114
+ async for outcome in outcomes:
115
+ if isinstance(outcome, Done):
116
+ embedded, vector = outcome.value
117
+ clustered_items.append(embedded)
118
+ vectors.append(vector)
119
+ else:
120
+ diagnostics.warn(f"excluded: {describe_source(outcome.source)} ({outcome.reason})")
121
+ log.finish()
122
+ if not vectors:
123
+ return ExitCode.ALL_FAILED
124
+
125
+ # the threshold adapts to the embedder's geometry (measured: gemini's
126
+ # same-theme pairs sit near 0.7; a fixed bar can't serve every model)
127
+ clusters = leader_clusters(vectors, threshold=adaptive_threshold(vectors))
128
+ if request.k is not None and len(clusters) > request.k:
129
+ clusters = merge_to_k(vectors, clusters, k=request.k)
130
+ clusters = sorted(clusters, key=len, reverse=True)
131
+
132
+ labels = await _label_clusters(context, request, clusters, clustered_items)
133
+
134
+ writer = make_writer(
135
+ WriterConfig(mode=RenderMode.NDJSON, color=False, width=80, fields=None), stdout
136
+ )
137
+ if request.explode == "members":
138
+ member_label: dict[int, str] = {}
139
+ for members, label in zip(clusters, labels, strict=True):
140
+ for member in members:
141
+ member_label[member] = label
142
+ for position, item in enumerate(clustered_items):
143
+ record: dict[str, object]
144
+ if item.data is not None:
145
+ record = {key: value for key, value in item.data.items() if key != "vector"}
146
+ else:
147
+ record = {"text": item.raw}
148
+ record["cluster"] = member_label[position]
149
+ writer.write_record(record)
150
+ writer.flush()
151
+ return ExitCode.OK
152
+
153
+ total = len(clustered_items)
154
+ shown = clusters if request.top is None else clusters[: request.top]
155
+ for members, label in zip(shown, labels[: len(shown)], strict=True):
156
+ writer.write_record(
157
+ {
158
+ "cluster": label,
159
+ "size": len(members),
160
+ "share": round(len(members) / total, 2),
161
+ "examples": _examples(members, clustered_items, vectors),
162
+ }
163
+ )
164
+ folded = clusters[len(shown) :]
165
+ if folded:
166
+ size = sum(len(members) for members in folded)
167
+ writer.write_record(
168
+ {"cluster": "(other)", "size": size, "share": round(size / total, 2), "examples": []}
169
+ )
170
+ writer.flush()
171
+ return ExitCode.OK
172
+
173
+
174
+ async def _label_clusters(
175
+ context: ClusterContext,
176
+ request: ClusterRequest,
177
+ clusters: list[list[int]],
178
+ items: list[Item],
179
+ ) -> list[str]:
180
+ if request.model_flag is not None:
181
+ chat: ChatModel | None = await context.chat_model(request.model_flag)
182
+ else:
183
+ chat = await optional_chat(context)
184
+ if chat is None:
185
+ diagnostics.note(
186
+ "no chat model — clusters are unnamed (cluster 1, 2, …); "
187
+ "configure one for real labels: smartpipe config"
188
+ )
189
+ return [f"cluster {number}" for number in range(1, len(clusters) + 1)]
190
+ labels: list[str] = []
191
+ for number, members in enumerate(clusters, start=1):
192
+ texts = [items[member].text for member in members[:8]]
193
+ labels.append(await label_cluster(chat, texts, fallback=f"cluster {number}"))
194
+ return labels
195
+
196
+
197
+ async def label_cluster(chat: ChatModel, texts: list[str], *, fallback: str) -> str:
198
+ """One temperature-0 label call for a group of similar texts (shared by
199
+ cluster and diff). Failures keep the fallback, disclosed."""
200
+ quotes = "\n".join(f"- {text[:200]}" for text in texts)
201
+ try:
202
+ reply = await chat.complete(
203
+ CompletionRequest(
204
+ system=_LABEL_SYSTEM,
205
+ user=f"Items in this cluster:\n{quotes}",
206
+ json_schema=_LABEL_SCHEMA,
207
+ max_tokens=64,
208
+ )
209
+ )
210
+ verdict = validate_and_coerce(reply, _LABEL_SCHEMA)
211
+ return str(verdict["label"]).strip() or fallback
212
+ except ItemError as exc:
213
+ diagnostics.warn(f"{fallback} label failed ({exc}) — kept numbered")
214
+ return fallback
215
+
216
+
217
+ def _examples(members: list[int], items: list[Item], vectors: list[tuple[float, ...]]) -> list[str]:
218
+ centroid = mean_pool([vectors[member] for member in members])
219
+ nearest = sorted(members, key=lambda member: -cosine(vectors[member], centroid))
220
+ return [items[member].text[:160] for member in nearest[:_EXAMPLES]]