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,100 @@
1
+ """``smartpipe diff`` — what distinguishes two sets of items."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from smartpipe.cli.completions import complete_chat_models, complete_embed_models
13
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
14
+ from smartpipe.core.errors import ExitCode
15
+ from smartpipe.verbs.diff import DiffRequest, run_diff
16
+
17
+ __all__ = ["diff_command"]
18
+
19
+
20
+ @click.command(name="diff")
21
+ @click.option(
22
+ "--right",
23
+ "right",
24
+ type=click.Path(path_type=Path),
25
+ required=True,
26
+ help="The comparison set (JSONL or plain lines). Left is stdin.",
27
+ )
28
+ @click.option("--top", type=int, help="Show at most N discriminating themes.")
29
+ @click.option("--all", "show_all", is_flag=True, help="Also show themes shared by both sides.")
30
+ @click.option(
31
+ "--model",
32
+ "model_flag",
33
+ shell_complete=complete_chat_models,
34
+ help="Chat model for theme labels.",
35
+ )
36
+ @click.option(
37
+ "--embed-model",
38
+ "embed_flag",
39
+ shell_complete=complete_embed_models,
40
+ help="Embedding model for grouping.",
41
+ )
42
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
43
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
44
+ @click.option(
45
+ "--allow-captions",
46
+ "allow_captions",
47
+ is_flag=True,
48
+ help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
49
+ )
50
+ def diff_command(
51
+ right: Path,
52
+ top: int | None,
53
+ show_all: bool,
54
+ model_flag: str | None,
55
+ embed_flag: str | None,
56
+ concurrency_flag: int | None,
57
+ max_calls: int | None,
58
+ allow_captions: bool,
59
+ ) -> None:
60
+ """Semantic diff of two item SETS — not a line diff.
61
+
62
+ \b
63
+ Examples:
64
+ smartpipe diff --right errors-before.log < errors-during.log
65
+ smartpipe diff --right outputs-v1.jsonl < outputs-v2.jsonl
66
+ smartpipe diff --right v1-train.jsonl < v2-train.jsonl # dataset drift
67
+
68
+ Embeds both sides, groups the union by meaning, and reports the themes
69
+ that over-index on one side — with both shares shown as evidence and
70
+ examples from the dominant side. Balanced themes are omitted (a note
71
+ says how many; --all shows them): the answer to "what's different"
72
+ shouldn't bury you in what's the same.
73
+ """
74
+ request = DiffRequest(
75
+ right=right,
76
+ top=top,
77
+ show_all=show_all,
78
+ model_flag=model_flag,
79
+ embed_flag=embed_flag,
80
+ concurrency_flag=concurrency_flag,
81
+ allow_captions=allow_captions,
82
+ )
83
+ code = asyncio.run(_run(request, max_calls))
84
+ if code is not ExitCode.OK:
85
+ raise SystemExit(int(code))
86
+
87
+
88
+ async def _run(request: DiffRequest, max_calls: int | None) -> ExitCode:
89
+ from dataclasses import replace
90
+
91
+ from smartpipe.container import build_container
92
+
93
+ async with (
94
+ graceful_interrupts() as stop,
95
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
96
+ ):
97
+ if not request.allow_captions and container.config.allow_captions:
98
+ request = replace(request, allow_captions=True) # profile consent (D35)
99
+ code = await run_diff(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
100
+ return settle_budget(container.budget, code)
@@ -0,0 +1,94 @@
1
+ """``smartpipe distinct`` — fold near-duplicates; keep the first of each."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from smartpipe.cli.completions import complete_embed_models
12
+ from smartpipe.cli.input_options import input_options, input_spec
13
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
14
+ from smartpipe.core.errors import ExitCode
15
+ from smartpipe.verbs.distinct import DistinctRequest, run_distinct
16
+
17
+ __all__ = ["distinct_command"]
18
+
19
+
20
+ @click.command(name="distinct")
21
+ @click.option("--show-groups", is_flag=True, help="Emit group records instead (audit the folds).")
22
+ @click.option(
23
+ "--threshold",
24
+ type=float,
25
+ default=0.90,
26
+ show_default=True,
27
+ help="Cosine similarity at which two items are the same thing.",
28
+ )
29
+ @click.option(
30
+ "--embed-model",
31
+ "model_flag",
32
+ shell_complete=complete_embed_models,
33
+ help="Embedding model for this run.",
34
+ )
35
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
36
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
37
+ @click.option(
38
+ "--allow-captions",
39
+ "allow_captions",
40
+ is_flag=True,
41
+ help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
42
+ )
43
+ @input_options
44
+ def distinct_command(
45
+ show_groups: bool,
46
+ threshold: float,
47
+ model_flag: str | None,
48
+ concurrency_flag: int | None,
49
+ max_calls: int | None,
50
+ allow_captions: bool,
51
+ in_patterns: tuple[str, ...],
52
+ from_files: bool,
53
+ ) -> None:
54
+ """Fold near-duplicate items — the same thing worded differently is one item.
55
+
56
+ \b
57
+ Examples:
58
+ cat tickets.txt | smartpipe distinct > unique.txt
59
+ cat alerts.jsonl | smartpipe distinct --show-groups # audit what folded
60
+ smartpipe distinct < candidates.jsonl > train-clean.jsonl
61
+
62
+ Exact duplicates fold for free (no model calls); the rest are embedded
63
+ once and grouped by meaning. First occurrence wins; output keeps input
64
+ order and bytes. The receipt on stderr says exactly what was folded.
65
+
66
+ Run distinct BEFORE map/filter: every duplicate you fold is a model call
67
+ you don't pay for downstream.
68
+ """
69
+ request = DistinctRequest(
70
+ show_groups=show_groups,
71
+ threshold=threshold,
72
+ model_flag=model_flag,
73
+ concurrency_flag=concurrency_flag,
74
+ allow_captions=allow_captions,
75
+ input=input_spec(in_patterns, from_files=from_files),
76
+ )
77
+ code = asyncio.run(_run(request, max_calls))
78
+ if code is not ExitCode.OK:
79
+ raise SystemExit(int(code))
80
+
81
+
82
+ async def _run(request: DistinctRequest, max_calls: int | None) -> ExitCode:
83
+ from dataclasses import replace
84
+
85
+ from smartpipe.container import build_container
86
+
87
+ async with (
88
+ graceful_interrupts() as stop,
89
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
90
+ ):
91
+ if not request.allow_captions and container.config.allow_captions:
92
+ request = replace(request, allow_captions=True) # profile consent (D35)
93
+ code = await run_distinct(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
94
+ return settle_budget(container.budget, code)
@@ -0,0 +1,207 @@
1
+ """``smartpipe doctor`` — every no-cost setup check, one screen, exit 0/1 (D18).
2
+
3
+ The report is the result, so it goes to stdout. Never a paid model call: the only
4
+ network touch is the existing free 2 s Ollama probe. Key lines report presence,
5
+ never values (validating a key costs a call — that's the live runbook's job).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ import click
16
+
17
+ from smartpipe.config.doctor import CheckResult, doctor_exit_code, render_report
18
+ from smartpipe.config.paths import config_path, human_path
19
+ from smartpipe.core.errors import ExitCode, SempipeError
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Mapping
23
+
24
+ from smartpipe.config.store import Config
25
+
26
+ __all__ = ["doctor_command"]
27
+
28
+ _KEY_VARS = ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "MISTRAL_API_KEY")
29
+ # D46: nothing is optional — these ship in core; a missing one = broken install
30
+ _BUNDLED = (
31
+ ("documents", "markitdown"),
32
+ ("whisper", "faster_whisper"),
33
+ ("embeddings", "fastembed"),
34
+ ("anthropic", "anthropic"),
35
+ ("charts", "svgwrite"),
36
+ ("ffmpeg", "imageio_ffmpeg"),
37
+ )
38
+
39
+ # absent-by-necessity on 3.14 until onnxruntime/av publish wheels (D46)
40
+ _WAITING_ON_314_WHEELS = {"documents", "whisper", "embeddings"}
41
+ _RC_FILES = {"zsh": "~/.zshrc", "bash": "~/.bashrc"}
42
+
43
+
44
+ @click.command(name="doctor")
45
+ @click.option(
46
+ "--probe",
47
+ is_flag=True,
48
+ help="Also send 4 tiny PAID calls to chart which modalities really work.",
49
+ )
50
+ def doctor_command(probe: bool) -> None:
51
+ """Check that smartpipe is set up and ready — without spending a model call.
52
+
53
+ \b
54
+ Verifies: config parses · Ollama reachable · configured models installed ·
55
+ API keys present (never printed) · ChatGPT login · optional extras ·
56
+ shell completions. Exit 0 when everything is green.
57
+ --probe adds the modality matrix: real (tiny) calls, announced first.
58
+ """
59
+ results = asyncio.run(_gather(os.environ))
60
+ click.echo(render_report(results))
61
+ if not probe:
62
+ click.secho(
63
+ "\n⚠ these checks verify SETUP, not ABILITY — run `smartpipe doctor --probe`\n"
64
+ " to test what your models can actually see and hear (4 tiny paid calls)",
65
+ fg="yellow",
66
+ bold=True,
67
+ )
68
+ if probe:
69
+ from smartpipe.cli.probe_cmd import run_probe
70
+
71
+ click.echo("")
72
+ click.echo(asyncio.run(run_probe(os.environ)))
73
+ code = doctor_exit_code(results)
74
+ if code is not ExitCode.OK:
75
+ raise SystemExit(int(code))
76
+
77
+
78
+ async def _gather(env: Mapping[str, str]) -> list[CheckResult]:
79
+ config, config_result = _check_config(env)
80
+ results = [config_result]
81
+ names = await _probe(env)
82
+ results.append(_check_ollama(env, names))
83
+ results.append(_check_model("chat", _effective_chat(env, config), names))
84
+ results.append(_check_model("embed", _effective_embed(env, config), names))
85
+ results.append(_check_keys(env))
86
+ results.append(_check_login(env))
87
+ results.append(_check_extras())
88
+ results.append(_check_completions(env))
89
+ return results
90
+
91
+
92
+ def _check_config(env: Mapping[str, str]) -> tuple[Config | None, CheckResult]:
93
+ from smartpipe.config.store import load_config
94
+
95
+ path = config_path(env)
96
+ try:
97
+ config = load_config(path)
98
+ except SempipeError as exc: # doctor reports sickness, it doesn't die of it
99
+ summary = str(exc).splitlines()[0].removeprefix("error: ")
100
+ return None, CheckResult("config", "fail", f"{human_path(path)}: {summary}")
101
+ if not path.exists():
102
+ return config, CheckResult("config", "skip", "no config file (defaults apply)")
103
+ described = config.model or "no default model"
104
+ return config, CheckResult("config", "ok", f"{human_path(path)} parses (model: {described})")
105
+
106
+
107
+ async def _probe(env: Mapping[str, str]) -> tuple[str, ...] | None:
108
+ from smartpipe.models.http_support import make_client
109
+ from smartpipe.models.ollama import ollama_model_names, resolve_host
110
+
111
+ async with make_client() as client:
112
+ return await ollama_model_names(client, resolve_host(env))
113
+
114
+
115
+ def _check_ollama(env: Mapping[str, str], names: tuple[str, ...] | None) -> CheckResult:
116
+ from smartpipe.models.ollama import resolve_host
117
+
118
+ host = resolve_host(env)
119
+ if names is None:
120
+ return CheckResult(
121
+ "ollama", "fail", f"not reachable at {host} — fix: ollama serve (or set OLLAMA_HOST)"
122
+ )
123
+ return CheckResult("ollama", "ok", f"reachable at {host} ({len(names)} models)")
124
+
125
+
126
+ def _effective_chat(env: Mapping[str, str], config: Config | None) -> str | None:
127
+ return env.get("SMARTPIPE_MODEL", "").strip() or (config.model if config else None)
128
+
129
+
130
+ def _effective_embed(env: Mapping[str, str], config: Config | None) -> str | None:
131
+ configured = env.get("SMARTPIPE_EMBED_MODEL", "").strip() or (
132
+ config.embed_model if config else None
133
+ )
134
+ return configured or "nomic-embed-text" # the documented default
135
+
136
+
137
+ def _check_model(
138
+ section: str, configured: str | None, names: tuple[str, ...] | None
139
+ ) -> CheckResult:
140
+ from smartpipe.core.errors import UsageFault
141
+ from smartpipe.models.base import parse_model_ref
142
+
143
+ if configured is None:
144
+ return CheckResult(
145
+ section, "fail", "no model configured — fix: smartpipe config model ollama/qwen3:8b"
146
+ )
147
+ try:
148
+ ref = parse_model_ref(configured)
149
+ except UsageFault as exc:
150
+ return CheckResult(section, "fail", str(exc).splitlines()[0])
151
+ if ref.provider != "ollama":
152
+ return CheckResult(section, "skip", f"{ref} is a cloud model (key presence below)")
153
+ if names is None:
154
+ return CheckResult(section, "skip", f"{ref.name} — can't verify, Ollama unreachable")
155
+ if ref.name in names:
156
+ return CheckResult(section, "ok", f"{configured} is installed")
157
+ return CheckResult(
158
+ section, "fail", f"{ref.name} not in ollama list — fix: ollama pull {ref.name}"
159
+ )
160
+
161
+
162
+ def _check_keys(env: Mapping[str, str]) -> CheckResult:
163
+ parts = (f"{var} {'set' if env.get(var, '').strip() else 'not set'}" for var in _KEY_VARS)
164
+ return CheckResult("keys", "skip", " · ".join(parts))
165
+
166
+
167
+ def _check_login(env: Mapping[str, str]) -> CheckResult:
168
+ from smartpipe.config.credentials import credentials_path, load_oauth
169
+
170
+ if load_oauth(credentials_path(env), "openai") is not None:
171
+ return CheckResult("login", "ok", "ChatGPT login present (refreshes automatically)")
172
+ return CheckResult("login", "skip", "no ChatGPT login — optional: smartpipe auth login")
173
+
174
+
175
+ def _check_extras() -> CheckResult:
176
+ import sys
177
+ from importlib.util import find_spec
178
+
179
+ installed = {name: find_spec(module) is not None for name, module in _BUNDLED}
180
+ if all(installed.values()):
181
+ return CheckResult("extras", "ok", "everything ships in the box: " + " · ".join(installed))
182
+ marks = " · ".join(f"{name} {'✓' if present else '✗'}" for name, present in installed.items())
183
+ missing = {name for name, present in installed.items() if not present}
184
+ if sys.version_info >= (3, 14) and missing <= _WAITING_ON_314_WHEELS:
185
+ return CheckResult("extras", "skip", f"{marks} — waiting on upstream Python 3.14 wheels")
186
+ return CheckResult("extras", "fail", f"{marks} — broken install; reinstall smartpipe")
187
+
188
+
189
+ def _check_completions(env: Mapping[str, str]) -> CheckResult:
190
+ shell = Path(env.get("SHELL", "")).name
191
+ if shell == "fish":
192
+ candidate = Path("~/.config/fish/completions/smartpipe.fish").expanduser()
193
+ if candidate.exists():
194
+ return CheckResult("terminal", "ok", "completions installed for fish")
195
+ return CheckResult("terminal", "skip", "no fish completions — optional: see install docs")
196
+ rc_name = _RC_FILES.get(shell)
197
+ if rc_name is None:
198
+ return CheckResult("terminal", "skip", "unknown shell — completions not checked")
199
+ rc_path = Path(rc_name).expanduser()
200
+ try:
201
+ rc_text = rc_path.read_text(encoding="utf-8")
202
+ installed = "_SMARTPIPE_COMPLETE" in rc_text or "_SMARTPIPE_COMPLETE" in rc_text
203
+ except OSError:
204
+ installed = False
205
+ if installed:
206
+ return CheckResult("terminal", "ok", f"completions installed for {shell}")
207
+ return CheckResult("terminal", "skip", f"no {shell} completions — optional: see install docs")
@@ -0,0 +1,44 @@
1
+ """Hidden debug verb: pass input through the io spine untouched.
2
+
3
+ Not listed in help on purpose — it exists so anyone (including future us,
4
+ debugging a support question) can see exactly how smartpipe itemizes and
5
+ serializes a given input: ``cat mystery.jsonl | smartpipe echo --output json``.
6
+ It is also the standing integration test of the whole io layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import sys
14
+
15
+ import click
16
+
17
+ from smartpipe.io import readers, tty
18
+ from smartpipe.io.writers import OutputFormat, WriterConfig, make_writer, resolve_format
19
+
20
+ __all__ = ["echo_command"]
21
+
22
+
23
+ @click.command(name="echo", hidden=True)
24
+ @click.option(
25
+ "--output",
26
+ "output_format",
27
+ type=click.Choice([fmt.value for fmt in OutputFormat]),
28
+ default=OutputFormat.AUTO.value,
29
+ show_default=True,
30
+ help="Output format.",
31
+ )
32
+ def echo_command(output_format: str) -> None:
33
+ """Pass stdin through smartpipe's item pipeline unchanged (debugging aid)."""
34
+ asyncio.run(_run(OutputFormat(output_format)))
35
+
36
+
37
+ async def _run(flag: OutputFormat) -> None:
38
+ readers.ensure_not_a_tty(sys.stdin)
39
+ mode = resolve_format(flag, os.environ, stdout_tty=tty.stdout_is_tty(), structured=False)
40
+ config = WriterConfig(mode=mode, color=tty.stdout_supports_color(), width=tty.terminal_width())
41
+ writer = make_writer(config, sys.stdout)
42
+ async for item in readers.stdin_items(sys.stdin):
43
+ writer.write_passthrough(item)
44
+ writer.flush()
@@ -0,0 +1,80 @@
1
+ """``smartpipe embed`` — convert items to vector embeddings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+
9
+ import click
10
+
11
+ from smartpipe.cli.completions import complete_embed_models
12
+ from smartpipe.cli.input_options import fields_option, input_options, input_spec
13
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
14
+ from smartpipe.core.errors import ExitCode
15
+ from smartpipe.verbs.embed import EmbedRequest, run_embed
16
+
17
+ __all__ = ["embed_command"]
18
+
19
+
20
+ @click.command(name="embed")
21
+ @click.option(
22
+ "--embed-model",
23
+ "model_flag",
24
+ shell_complete=complete_embed_models,
25
+ help="Embedding model (e.g. nomic-embed-text).",
26
+ )
27
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
28
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
29
+ @fields_option
30
+ @click.option(
31
+ "--allow-captions",
32
+ "allow_captions",
33
+ is_flag=True,
34
+ help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
35
+ )
36
+ @input_options
37
+ def embed_command(
38
+ model_flag: str | None,
39
+ concurrency_flag: int | None,
40
+ max_calls: int | None,
41
+ allow_captions: bool,
42
+ fields: tuple[str, ...] | None,
43
+ in_patterns: tuple[str, ...],
44
+ from_files: bool,
45
+ ) -> None:
46
+ """Convert each item to a vector embedding (NDJSON out).
47
+
48
+ \b
49
+ Examples:
50
+ cat docs/*.md | smartpipe embed > corpus.embeddings
51
+ smartpipe embed --in 'docs/*.pdf' > corpus.embeddings
52
+
53
+ This is the only command that never touches a chat model — it uses the
54
+ embedding model, and exists to feed 'top_k'.
55
+ """
56
+ request = EmbedRequest(
57
+ allow_captions=allow_captions,
58
+ model_flag=model_flag,
59
+ concurrency_flag=concurrency_flag,
60
+ input=input_spec(in_patterns, from_files=from_files),
61
+ fields=fields,
62
+ )
63
+ code = asyncio.run(_run(request, max_calls))
64
+ if code is not ExitCode.OK:
65
+ raise SystemExit(int(code))
66
+
67
+
68
+ async def _run(request: EmbedRequest, max_calls: int | None) -> ExitCode:
69
+ from smartpipe.container import build_container
70
+
71
+ async with (
72
+ graceful_interrupts() as stop,
73
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
74
+ ):
75
+ if not request.allow_captions and container.config.allow_captions:
76
+ from dataclasses import replace as _replace
77
+
78
+ request = _replace(request, allow_captions=True) # profile consent (D35)
79
+ code = await run_embed(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
80
+ return settle_budget(container.budget, code)
@@ -0,0 +1,138 @@
1
+ """``smartpipe extend`` — your record, plus columns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from smartpipe.cli.completions import complete_chat_models
13
+ from smartpipe.cli.input_options import (
14
+ fields_option,
15
+ input_options,
16
+ input_spec,
17
+ resolve_prompt,
18
+ )
19
+ from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
20
+ from smartpipe.core.errors import ExitCode
21
+ from smartpipe.io.writers import OutputFormat
22
+ from smartpipe.verbs.extend import ExtendRequest, run_extend
23
+
24
+ __all__ = ["extend_command"]
25
+
26
+
27
+ @click.command(name="extend")
28
+ @click.argument("prompt", required=False)
29
+ @click.option(
30
+ "--prompt-file",
31
+ "prompt_file",
32
+ type=click.Path(path_type=Path),
33
+ help="Prompt from a file (@file works too).",
34
+ )
35
+ @click.option(
36
+ "--schema",
37
+ "schema_path",
38
+ type=click.Path(path_type=Path),
39
+ help="Shape the ADDED fields with a JSON Schema.",
40
+ )
41
+ @click.option(
42
+ "--schema-from",
43
+ "schema_dsl",
44
+ metavar="DSL",
45
+ help='Added fields from a mini-DSL: "vendor string; total number >= 0".',
46
+ )
47
+ @click.option("--tally", "tally_field", metavar="FIELD", help="Count FIELD's values (stderr).")
48
+ @click.option(
49
+ "--explode",
50
+ "explode_field",
51
+ metavar="FIELD",
52
+ help="One row per element of a list-valued FIELD (original fields ride along).",
53
+ )
54
+ @click.option(
55
+ "--model", "model_flag", shell_complete=complete_chat_models, help="Model for this run."
56
+ )
57
+ @click.option(
58
+ "--output",
59
+ type=click.Choice([fmt.value for fmt in OutputFormat]),
60
+ default=OutputFormat.AUTO.value,
61
+ show_default=True,
62
+ help="Output format.",
63
+ )
64
+ @click.option(
65
+ "--frame-every",
66
+ "frame_every",
67
+ type=float,
68
+ metavar="SECONDS",
69
+ help="Video density guarantee: one frame per period (lifts the 24-frame cap).",
70
+ )
71
+ @click.option(
72
+ "--max-frames",
73
+ "max_frames",
74
+ type=int,
75
+ help="Video frame budget (default 24; the smaller of the two flags wins).",
76
+ )
77
+ @click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
78
+ @click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
79
+ @fields_option
80
+ @input_options
81
+ def extend_command(
82
+ prompt: str | None,
83
+ frame_every: float | None,
84
+ max_frames: int | None,
85
+ prompt_file: Path | None,
86
+ schema_path: Path | None,
87
+ schema_dsl: str | None,
88
+ tally_field: str | None,
89
+ explode_field: str | None,
90
+ model_flag: str | None,
91
+ output: str,
92
+ concurrency_flag: int | None,
93
+ max_calls: int | None,
94
+ fields: tuple[str, ...] | None,
95
+ in_patterns: tuple[str, ...],
96
+ from_files: bool,
97
+ ) -> None:
98
+ """Add extracted fields to each record — everything it had survives.
99
+
100
+ \b
101
+ Before: {"id": 812, "body": "app crashes when saving"}
102
+ $ cat tickets.jsonl | smartpipe extend "Add {sentiment enum(pos, neg, neutral), product string}"
103
+ After: {"id": 812, "body": "app crashes when saving", "sentiment": "neg", "product": "app"}
104
+
105
+ Same prompt language as map (typed braces, --schema, --schema-from), but
106
+ the output is your record PLUS the new columns — drop it into the middle
107
+ of an existing pipeline. Plain text lines become {"text": ..., ...}.
108
+ Existing fields with the same name are overwritten (noted on stderr) so
109
+ re-running enrichment stays idempotent.
110
+ """
111
+ request = ExtendRequest(
112
+ prompt=resolve_prompt(prompt, prompt_file),
113
+ schema_path=schema_path,
114
+ schema_dsl=schema_dsl,
115
+ tally_field=tally_field,
116
+ explode_field=explode_field,
117
+ frame_every=frame_every,
118
+ max_frames=max_frames,
119
+ model_flag=model_flag,
120
+ output=OutputFormat(output),
121
+ concurrency_flag=concurrency_flag,
122
+ fields=fields,
123
+ input=input_spec(in_patterns, from_files=from_files),
124
+ )
125
+ code = asyncio.run(_run(request, max_calls))
126
+ if code is not ExitCode.OK:
127
+ raise SystemExit(int(code))
128
+
129
+
130
+ async def _run(request: ExtendRequest, max_calls: int | None) -> ExitCode:
131
+ from smartpipe.container import build_container
132
+
133
+ async with (
134
+ graceful_interrupts() as stop,
135
+ build_container(os.environ, max_calls=max_calls, stop=stop) as container,
136
+ ):
137
+ code = await run_extend(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
138
+ return settle_budget(container.budget, code)