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,87 @@
|
|
|
1
|
+
"""``smartpipe filter`` — keep items matching a semantic condition."""
|
|
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.input_options import input_options, input_spec, resolve_prompt
|
|
13
|
+
from smartpipe.cli.interrupts import graceful_interrupts, settle_budget
|
|
14
|
+
from smartpipe.core.errors import ExitCode
|
|
15
|
+
from smartpipe.verbs.filter import FilterRequest, run_filter
|
|
16
|
+
|
|
17
|
+
__all__ = ["filter_command"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command(name="filter")
|
|
21
|
+
@click.argument("condition", required=False)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--prompt-file",
|
|
24
|
+
"prompt_file",
|
|
25
|
+
type=click.Path(path_type=Path),
|
|
26
|
+
help="Read the condition from a file (the @file shorthand does the same).",
|
|
27
|
+
)
|
|
28
|
+
@click.option("--not", "invert", is_flag=True, help="Keep items that do NOT match (like grep -v).")
|
|
29
|
+
@click.option("--model", "model_flag", help="Model for this run.")
|
|
30
|
+
@click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
|
|
31
|
+
@click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
|
|
32
|
+
@click.option(
|
|
33
|
+
"--allow-captions",
|
|
34
|
+
"allow_captions",
|
|
35
|
+
is_flag=True,
|
|
36
|
+
help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
|
|
37
|
+
)
|
|
38
|
+
@input_options
|
|
39
|
+
def filter_command(
|
|
40
|
+
condition: str | None,
|
|
41
|
+
prompt_file: Path | None,
|
|
42
|
+
invert: bool,
|
|
43
|
+
model_flag: str | None,
|
|
44
|
+
concurrency_flag: int | None,
|
|
45
|
+
max_calls: int | None,
|
|
46
|
+
allow_captions: bool,
|
|
47
|
+
in_patterns: tuple[str, ...],
|
|
48
|
+
from_files: bool,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Keep items matching a plain-English condition. Semantic grep.
|
|
51
|
+
|
|
52
|
+
\b
|
|
53
|
+
Examples:
|
|
54
|
+
cat reviews.txt | smartpipe filter "the reviewer is sarcastic"
|
|
55
|
+
cat tickets.jsonl | smartpipe filter "{priority} is wrong given {description}"
|
|
56
|
+
smartpipe filter "mentions a security issue" --in 'logs/*.txt'
|
|
57
|
+
|
|
58
|
+
Output is the matching input items, unchanged and in order (in file mode, the
|
|
59
|
+
matching filenames). Zero matches is a successful (exit 0) empty result.
|
|
60
|
+
Deterministic condition (field == value, text has "word")? where is free.
|
|
61
|
+
"""
|
|
62
|
+
request = FilterRequest(
|
|
63
|
+
allow_captions=allow_captions,
|
|
64
|
+
condition=resolve_prompt(condition, prompt_file),
|
|
65
|
+
invert=invert,
|
|
66
|
+
model_flag=model_flag,
|
|
67
|
+
concurrency_flag=concurrency_flag,
|
|
68
|
+
input=input_spec(in_patterns, from_files=from_files),
|
|
69
|
+
)
|
|
70
|
+
code = asyncio.run(_run(request, max_calls))
|
|
71
|
+
if code is not ExitCode.OK:
|
|
72
|
+
raise SystemExit(int(code))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _run(request: FilterRequest, max_calls: int | None) -> ExitCode:
|
|
76
|
+
from smartpipe.container import build_container
|
|
77
|
+
|
|
78
|
+
async with (
|
|
79
|
+
graceful_interrupts() as stop,
|
|
80
|
+
build_container(os.environ, max_calls=max_calls, stop=stop) as container,
|
|
81
|
+
):
|
|
82
|
+
if not request.allow_captions and container.config.allow_captions:
|
|
83
|
+
from dataclasses import replace as _replace
|
|
84
|
+
|
|
85
|
+
request = _replace(request, allow_captions=True) # profile consent (D35)
|
|
86
|
+
code = await run_filter(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
|
|
87
|
+
return settle_budget(container.budget, code)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""``smartpipe getschema`` — what fields does this stream even have?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from smartpipe.core.errors import ExitCode
|
|
10
|
+
from smartpipe.verbs.getschema import GetSchemaRequest, run_getschema
|
|
11
|
+
|
|
12
|
+
__all__ = ["getschema_command"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(name="getschema")
|
|
16
|
+
@click.option("--all", "scan_all", is_flag=True, help="Scan every row (default: first 10,000).")
|
|
17
|
+
def getschema_command(scan_all: bool) -> None:
|
|
18
|
+
"""Report the stream's fields, types, and coverage. Free — never calls a model.
|
|
19
|
+
|
|
20
|
+
\b
|
|
21
|
+
Examples:
|
|
22
|
+
cat data.jsonl | smartpipe getschema
|
|
23
|
+
smartpipe getschema --all < big.jsonl
|
|
24
|
+
|
|
25
|
+
A table on a terminal, NDJSON when piped. Mixed types show as unions
|
|
26
|
+
(string|number) — that's the dirt worth seeing. Plain-text input gets a
|
|
27
|
+
one-line answer instead of an error. The footer suggests the next move.
|
|
28
|
+
"""
|
|
29
|
+
code = run_getschema(GetSchemaRequest(scan_all=scan_all), stdin=sys.stdin, stdout=sys.stdout)
|
|
30
|
+
if code is not ExitCode.OK: # pragma: no cover — getschema always OKs
|
|
31
|
+
raise SystemExit(int(code))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Shared verb options — file inputs (``--in``/``--from-files``) and ``--fields``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from smartpipe.core.errors import UsageFault
|
|
10
|
+
from smartpipe.io.inputs import InputSpec
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
__all__ = ["fields_option", "input_options", "input_spec", "parse_fields", "resolve_prompt"]
|
|
17
|
+
|
|
18
|
+
_Command = TypeVar("_Command", bound="Callable[..., object]")
|
|
19
|
+
|
|
20
|
+
_FIELDS_HINT = (
|
|
21
|
+
" --fields is a comma-separated list of columns, each named once.\n"
|
|
22
|
+
" Example: --fields name,email"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def input_options(command: _Command) -> _Command:
|
|
27
|
+
"""Attach ``--in`` (glob of files as items) and ``--from-files`` (stdin names files)."""
|
|
28
|
+
command = click.option(
|
|
29
|
+
"--in",
|
|
30
|
+
"in_patterns",
|
|
31
|
+
multiple=True,
|
|
32
|
+
metavar="GLOB",
|
|
33
|
+
help="Read each matching file as one item (repeatable). e.g. --in 'docs/*.pdf'",
|
|
34
|
+
)(command)
|
|
35
|
+
command = click.option(
|
|
36
|
+
"--from-files",
|
|
37
|
+
"from_files",
|
|
38
|
+
is_flag=True,
|
|
39
|
+
help="Treat each stdin line as a filename; read each file as one item.",
|
|
40
|
+
)(command)
|
|
41
|
+
return command
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def input_spec(in_patterns: tuple[str, ...], *, from_files: bool) -> InputSpec:
|
|
45
|
+
return InputSpec(patterns=tuple(in_patterns), from_files=from_files)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def fields_option(command: _Command) -> _Command:
|
|
49
|
+
"""Attach ``--fields a,b`` — select + order the columns of structured output."""
|
|
50
|
+
return click.option(
|
|
51
|
+
"--fields",
|
|
52
|
+
"fields",
|
|
53
|
+
metavar="A,B,...",
|
|
54
|
+
callback=_fields_callback,
|
|
55
|
+
help="Select and order output columns (structured output only). e.g. --fields name,email",
|
|
56
|
+
)(command)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_fields(raw: str) -> tuple[str, ...]:
|
|
60
|
+
"""``" a , b "`` → ``("a", "b")``; empty or duplicate names are usage errors."""
|
|
61
|
+
names = tuple(name.strip() for name in raw.split(","))
|
|
62
|
+
if any(not name for name in names):
|
|
63
|
+
raise UsageFault(f"--fields got an empty field name\n{_FIELDS_HINT}")
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
for name in names:
|
|
66
|
+
if name in seen:
|
|
67
|
+
raise UsageFault(f"--fields names {name!r} more than once\n{_FIELDS_HINT}")
|
|
68
|
+
seen.add(name)
|
|
69
|
+
return names
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fields_callback(
|
|
73
|
+
ctx: click.Context, param: click.Parameter, value: str | None
|
|
74
|
+
) -> tuple[str, ...] | None:
|
|
75
|
+
del ctx, param # click's callback signature; the parse needs neither
|
|
76
|
+
return None if value is None else parse_fields(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_prompt(argument: str | None, file_flag: Path | None) -> str:
|
|
80
|
+
"""D23: ``@file`` shorthand + ``--prompt-file`` — both spellings, one resolver.
|
|
81
|
+
|
|
82
|
+
Only a LEADING ``@`` is special; ``@@x`` escapes a literal ``@``. Missing and
|
|
83
|
+
empty files fail free at argv time (D18), before anything could cost money.
|
|
84
|
+
"""
|
|
85
|
+
if argument is not None and file_flag is not None:
|
|
86
|
+
raise UsageFault("a prompt argument and --prompt-file both given — use one")
|
|
87
|
+
if file_flag is not None:
|
|
88
|
+
return _read_prompt_file(file_flag)
|
|
89
|
+
if argument is None:
|
|
90
|
+
raise UsageFault("no prompt given — write one, or point at a file: --prompt-file FILE")
|
|
91
|
+
if argument.startswith("@@"):
|
|
92
|
+
return argument[1:] # the escape: a literal leading @
|
|
93
|
+
if argument.startswith("@"):
|
|
94
|
+
from pathlib import Path as _Path
|
|
95
|
+
|
|
96
|
+
return _read_prompt_file(_Path(argument[1:]))
|
|
97
|
+
return argument
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read_prompt_file(path: Path) -> str:
|
|
101
|
+
if not path.exists():
|
|
102
|
+
raise UsageFault(
|
|
103
|
+
f"prompt file not found: {path}\n"
|
|
104
|
+
" @file reads the prompt from a file; --prompt-file FILE is the explicit form.\n"
|
|
105
|
+
" A literal leading @ escapes as @@."
|
|
106
|
+
)
|
|
107
|
+
text = path.read_text(encoding="utf-8").removesuffix("\n")
|
|
108
|
+
if not text.strip():
|
|
109
|
+
raise UsageFault(
|
|
110
|
+
f"prompt file is empty: {path}\n"
|
|
111
|
+
" An empty prompt is never intended — write the prompt, or drop the @."
|
|
112
|
+
)
|
|
113
|
+
return text
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Graceful Ctrl-C for per-item verbs (ux.md §12).
|
|
2
|
+
|
|
3
|
+
First SIGINT sets the ``stop`` event — the runner stops intake and drains in-flight
|
|
4
|
+
work; a watchdog caps the drain. Second SIGINT hard-exits 130 immediately (the user
|
|
5
|
+
means it). Whole-set verbs don't enter this context and keep the immediate
|
|
6
|
+
KeyboardInterrupt → 130 path — no partial result exists for them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from smartpipe.core.errors import ExitCode
|
|
20
|
+
from smartpipe.io import diagnostics
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import AsyncGenerator
|
|
24
|
+
|
|
25
|
+
from smartpipe.models.budget import CallBudget
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"drain_cap",
|
|
29
|
+
"graceful_interrupts",
|
|
30
|
+
"settle_budget",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_DEFAULT_DRAIN_SECONDS = 10.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def drain_cap() -> float:
|
|
37
|
+
# SMARTPIPE_DRAIN_SECONDS is a test seam (the drain-timeout e2e would otherwise
|
|
38
|
+
# take the full 10 s); not documented as public surface.
|
|
39
|
+
raw = os.environ.get("SMARTPIPE_DRAIN_SECONDS", "").strip()
|
|
40
|
+
try:
|
|
41
|
+
return float(raw) if raw else _DEFAULT_DRAIN_SECONDS
|
|
42
|
+
except ValueError:
|
|
43
|
+
return _DEFAULT_DRAIN_SECONDS
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _watchdog(cap: float, task: asyncio.Task[object]) -> None:
|
|
47
|
+
await asyncio.sleep(cap)
|
|
48
|
+
diagnostics.drain_timed_out()
|
|
49
|
+
task.cancel()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@asynccontextmanager
|
|
53
|
+
async def graceful_interrupts() -> AsyncGenerator[asyncio.Event]:
|
|
54
|
+
stop = asyncio.Event()
|
|
55
|
+
loop = asyncio.get_running_loop()
|
|
56
|
+
task = asyncio.current_task()
|
|
57
|
+
assert task is not None # always inside asyncio.run here
|
|
58
|
+
sigints = 0
|
|
59
|
+
watchdog: asyncio.Task[None] | None = None
|
|
60
|
+
|
|
61
|
+
def on_sigint() -> None:
|
|
62
|
+
nonlocal sigints, watchdog
|
|
63
|
+
sigints += 1
|
|
64
|
+
if sigints == 1:
|
|
65
|
+
stop.set()
|
|
66
|
+
watchdog = loop.create_task(_watchdog(drain_cap(), task))
|
|
67
|
+
else: # the user means it — flush what we can and go
|
|
68
|
+
with contextlib.suppress(Exception):
|
|
69
|
+
sys.stdout.flush()
|
|
70
|
+
sys.stderr.flush()
|
|
71
|
+
os._exit(int(ExitCode.INTERRUPTED))
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
loop.add_signal_handler(signal.SIGINT, on_sigint)
|
|
75
|
+
except (NotImplementedError, RuntimeError): # pragma: no cover — Windows loop
|
|
76
|
+
yield stop # never set: falls back to KeyboardInterrupt → 130 (documented)
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
yield stop
|
|
80
|
+
finally:
|
|
81
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
82
|
+
if watchdog is not None:
|
|
83
|
+
watchdog.cancel()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def settle_budget(budget: CallBudget | None, code: ExitCode) -> ExitCode:
|
|
87
|
+
"""D18: a run whose --max-calls budget fired never exits 0 — completeness
|
|
88
|
+
can't be trusted; the note names the cause after the drain summary."""
|
|
89
|
+
if budget is None or not budget.exhausted:
|
|
90
|
+
return code
|
|
91
|
+
diagnostics.note(f"stopped by --max-calls ({budget.calls} calls made)")
|
|
92
|
+
return ExitCode.PARTIAL if code is ExitCode.OK else code
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""``smartpipe join`` — the CLI surface: flags in, verb out (D21)."""
|
|
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.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.join import JoinRequest, run_join
|
|
23
|
+
|
|
24
|
+
__all__ = ["join_command"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command(name="join")
|
|
28
|
+
@click.argument("predicate", required=False)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--prompt-file",
|
|
31
|
+
"prompt_file",
|
|
32
|
+
type=click.Path(path_type=Path),
|
|
33
|
+
help="Read the predicate from a file (the @file shorthand does the same).",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--right",
|
|
37
|
+
"right",
|
|
38
|
+
required=True,
|
|
39
|
+
type=click.Path(dir_okay=False, path_type=Path, allow_dash=True),
|
|
40
|
+
help="The finite side to index (JSONL or plain lines). stdin is the left side.",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--k",
|
|
44
|
+
"k",
|
|
45
|
+
type=int,
|
|
46
|
+
default=5,
|
|
47
|
+
show_default=True,
|
|
48
|
+
help="Candidates judged per left item (the recall knob — see the docs).",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--threshold",
|
|
52
|
+
type=float,
|
|
53
|
+
help="Similarity floor (0-1) a candidate must clear before judging.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--kind",
|
|
57
|
+
type=click.Choice(["inner", "leftouter", "anti"]),
|
|
58
|
+
default="inner",
|
|
59
|
+
show_default=True,
|
|
60
|
+
help="inner: matched pairs · leftouter: all left rows (null right) · "
|
|
61
|
+
"anti: only UNMATCHED left rows, verbatim.",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--unmatched",
|
|
65
|
+
"unmatched",
|
|
66
|
+
type=click.Path(path_type=Path),
|
|
67
|
+
help="Write left items with zero matches to FILE, verbatim (inner only).",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--model",
|
|
71
|
+
"model_flag",
|
|
72
|
+
shell_complete=complete_chat_models,
|
|
73
|
+
help="Chat model for the judge calls.",
|
|
74
|
+
)
|
|
75
|
+
@click.option(
|
|
76
|
+
"--embed-model",
|
|
77
|
+
"embed_model_flag",
|
|
78
|
+
shell_complete=complete_embed_models,
|
|
79
|
+
help="Embedding model for both sides.",
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--output",
|
|
83
|
+
type=click.Choice([fmt.value for fmt in OutputFormat]),
|
|
84
|
+
default=OutputFormat.AUTO.value,
|
|
85
|
+
show_default=True,
|
|
86
|
+
help="Output format.",
|
|
87
|
+
)
|
|
88
|
+
@click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel left items.")
|
|
89
|
+
@click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
|
|
90
|
+
@fields_option
|
|
91
|
+
@click.option(
|
|
92
|
+
"--allow-captions",
|
|
93
|
+
"allow_captions",
|
|
94
|
+
is_flag=True,
|
|
95
|
+
help="Let a CLOUD model convert images/audio to text (paid; local models do it free).",
|
|
96
|
+
)
|
|
97
|
+
@input_options
|
|
98
|
+
def join_command(
|
|
99
|
+
predicate: str | None,
|
|
100
|
+
prompt_file: Path | None,
|
|
101
|
+
right: Path,
|
|
102
|
+
k: int,
|
|
103
|
+
threshold: float | None,
|
|
104
|
+
unmatched: Path | None,
|
|
105
|
+
kind: str,
|
|
106
|
+
model_flag: str | None,
|
|
107
|
+
embed_model_flag: str | None,
|
|
108
|
+
output: str,
|
|
109
|
+
concurrency_flag: int | None,
|
|
110
|
+
max_calls: int | None,
|
|
111
|
+
allow_captions: bool,
|
|
112
|
+
fields: tuple[str, ...] | None,
|
|
113
|
+
in_patterns: tuple[str, ...],
|
|
114
|
+
from_files: bool,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Match stdin against a second input, semantically. Emits matched pairs.
|
|
117
|
+
|
|
118
|
+
\b
|
|
119
|
+
Examples:
|
|
120
|
+
cat tickets.jsonl | smartpipe join "{left.text} concerns {right.name}" --right products.jsonl
|
|
121
|
+
tail -f events.log | smartpipe join "{left.text} involves {right.name}" --right people.jsonl
|
|
122
|
+
cat orders.jsonl | smartpipe join "the same purchase" --right invoices.jsonl --kind anti
|
|
123
|
+
|
|
124
|
+
Each brace names a side's field ({left.x} / {right.x}; .text is the whole
|
|
125
|
+
item). The right side is embedded once and indexed; each left item is
|
|
126
|
+
compared to its --k nearest candidates and only those pairs are judged by
|
|
127
|
+
the chat model — so cost is lines x k, never lines x right-size.
|
|
128
|
+
Output: {"left": {...}, "right": {...}, "_score": ...} per matched pair.
|
|
129
|
+
"""
|
|
130
|
+
request = JoinRequest(
|
|
131
|
+
allow_captions=allow_captions,
|
|
132
|
+
predicate=resolve_prompt(predicate, prompt_file),
|
|
133
|
+
right=right,
|
|
134
|
+
k=k,
|
|
135
|
+
threshold=threshold,
|
|
136
|
+
unmatched=unmatched,
|
|
137
|
+
kind=kind,
|
|
138
|
+
model_flag=model_flag,
|
|
139
|
+
embed_model_flag=embed_model_flag,
|
|
140
|
+
concurrency_flag=concurrency_flag,
|
|
141
|
+
output=OutputFormat(output),
|
|
142
|
+
input=input_spec(in_patterns, from_files=from_files),
|
|
143
|
+
fields=fields,
|
|
144
|
+
)
|
|
145
|
+
code = asyncio.run(_run(request, max_calls))
|
|
146
|
+
if code is not ExitCode.OK:
|
|
147
|
+
raise SystemExit(int(code))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _run(request: JoinRequest, max_calls: int | None) -> ExitCode:
|
|
151
|
+
from smartpipe.container import build_container
|
|
152
|
+
|
|
153
|
+
async with (
|
|
154
|
+
graceful_interrupts() as stop,
|
|
155
|
+
build_container(os.environ, max_calls=max_calls, stop=stop) as container,
|
|
156
|
+
):
|
|
157
|
+
if not request.allow_captions and container.config.allow_captions:
|
|
158
|
+
from dataclasses import replace as _replace
|
|
159
|
+
|
|
160
|
+
request = _replace(request, allow_captions=True) # profile consent (D35)
|
|
161
|
+
code = await run_join(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
|
|
162
|
+
return settle_budget(container.budget, code)
|
smartpipe/cli/map_cmd.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""``smartpipe map`` — the CLI surface: flags in, verb out."""
|
|
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.map import MapRequest, run_map
|
|
23
|
+
|
|
24
|
+
__all__ = ["map_command"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command(name="map")
|
|
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
|
+
"--explode",
|
|
37
|
+
"explode_field",
|
|
38
|
+
metavar="FIELD",
|
|
39
|
+
help="One row per element of a list-valued FIELD.",
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--tally",
|
|
43
|
+
"tally_field",
|
|
44
|
+
metavar="FIELD",
|
|
45
|
+
help="Count FIELD's values (live tally on stderr).",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--schema-from",
|
|
49
|
+
"schema_dsl",
|
|
50
|
+
metavar="DSL",
|
|
51
|
+
help='Schema from a mini-DSL: "vendor string; total number >= 0".',
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--schema",
|
|
55
|
+
"schema_path",
|
|
56
|
+
type=click.Path(path_type=Path),
|
|
57
|
+
help="Enforce a JSON Schema on the output.",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--model",
|
|
61
|
+
"model_flag",
|
|
62
|
+
shell_complete=complete_chat_models,
|
|
63
|
+
help="Model for this run (e.g. ollama/qwen3:8b, claude-opus-4-8).",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--output",
|
|
67
|
+
type=click.Choice([fmt.value for fmt in OutputFormat]),
|
|
68
|
+
default=OutputFormat.AUTO.value,
|
|
69
|
+
show_default=True,
|
|
70
|
+
help="Output format.",
|
|
71
|
+
)
|
|
72
|
+
@click.option(
|
|
73
|
+
"--frame-every",
|
|
74
|
+
"frame_every",
|
|
75
|
+
type=float,
|
|
76
|
+
metavar="SECONDS",
|
|
77
|
+
help="Video density guarantee: one frame per period (lifts the 24-frame cap).",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--max-frames",
|
|
81
|
+
"max_frames",
|
|
82
|
+
type=int,
|
|
83
|
+
help="Video frame budget (default 24; the smaller of the two flags wins).",
|
|
84
|
+
)
|
|
85
|
+
@click.option("--concurrency", "concurrency_flag", type=int, help="Max parallel model calls.")
|
|
86
|
+
@click.option("--max-calls", "max_calls", type=int, help="Stop after N model calls (cost cap).")
|
|
87
|
+
@fields_option
|
|
88
|
+
@input_options
|
|
89
|
+
def map_command(
|
|
90
|
+
prompt: str | None,
|
|
91
|
+
frame_every: float | None,
|
|
92
|
+
max_frames: int | None,
|
|
93
|
+
prompt_file: Path | None,
|
|
94
|
+
schema_path: Path | None,
|
|
95
|
+
schema_dsl: str | None,
|
|
96
|
+
tally_field: str | None,
|
|
97
|
+
explode_field: str | None,
|
|
98
|
+
model_flag: str | None,
|
|
99
|
+
output: str,
|
|
100
|
+
concurrency_flag: int | None,
|
|
101
|
+
max_calls: int | None,
|
|
102
|
+
fields: tuple[str, ...] | None,
|
|
103
|
+
in_patterns: tuple[str, ...],
|
|
104
|
+
from_files: bool,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Transform each input item with a prompt. One item in, one result out.
|
|
107
|
+
|
|
108
|
+
\b
|
|
109
|
+
Examples:
|
|
110
|
+
echo "hello" | smartpipe map "translate to Spanish"
|
|
111
|
+
cat reviews.jsonl | smartpipe map "Extract {product, sentiment}"
|
|
112
|
+
smartpipe map "Summarize this document" --in 'reports/*.pdf'
|
|
113
|
+
smartpipe map "What does the caller want?" --in 'calls/*.mp3'
|
|
114
|
+
|
|
115
|
+
You usually need NO flags: braces in the prompt name the JSON fields you
|
|
116
|
+
want back; plain prompts return plain text; and media is first-class —
|
|
117
|
+
images, audio, video, and the figures inside PDFs go to the model natively
|
|
118
|
+
when it supports them, converted (and disclosed) when it doesn't.
|
|
119
|
+
|
|
120
|
+
Everything else is opt-in refinement: schemas when braces aren't enough,
|
|
121
|
+
--tally/--explode/--fields to shape output, --max-calls to cap spend.
|
|
122
|
+
"""
|
|
123
|
+
request = MapRequest(
|
|
124
|
+
prompt=resolve_prompt(prompt, prompt_file),
|
|
125
|
+
schema_path=schema_path,
|
|
126
|
+
schema_dsl=schema_dsl,
|
|
127
|
+
tally_field=tally_field,
|
|
128
|
+
explode_field=explode_field,
|
|
129
|
+
frame_every=frame_every,
|
|
130
|
+
max_frames=max_frames,
|
|
131
|
+
model_flag=model_flag,
|
|
132
|
+
output=OutputFormat(output),
|
|
133
|
+
concurrency_flag=concurrency_flag,
|
|
134
|
+
input=input_spec(in_patterns, from_files=from_files),
|
|
135
|
+
fields=fields,
|
|
136
|
+
)
|
|
137
|
+
code = asyncio.run(_run(request, max_calls))
|
|
138
|
+
if code is not ExitCode.OK:
|
|
139
|
+
raise SystemExit(int(code))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _run(request: MapRequest, max_calls: int | None) -> ExitCode:
|
|
143
|
+
from smartpipe.container import build_container
|
|
144
|
+
|
|
145
|
+
async with (
|
|
146
|
+
graceful_interrupts() as stop,
|
|
147
|
+
build_container(os.environ, max_calls=max_calls, stop=stop) as container,
|
|
148
|
+
):
|
|
149
|
+
code = await run_map(request, container, stdin=sys.stdin, stdout=sys.stdout, stop=stop)
|
|
150
|
+
return settle_budget(container.budget, code)
|