smartpipe-cli 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,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)
|