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,601 @@
|
|
|
1
|
+
"""Brace-grammar parsing (plan/ux.md "Brace grammar", plan/decisions.md D13).
|
|
2
|
+
|
|
3
|
+
The tokenizer is verb-neutral: it splits a prompt into literal text and brace
|
|
4
|
+
groups. What a brace group *means* differs by verb — in ``map`` the groups name
|
|
5
|
+
output fields; in ``filter``/``reduce`` a single-field group interpolates an
|
|
6
|
+
input value — but both build on these tokens. This module owns only the parse
|
|
7
|
+
and the verb-neutral helpers; per-verb prompt assembly lives alongside.
|
|
8
|
+
|
|
9
|
+
Grammar:
|
|
10
|
+
prompt ::= (text | brace_group | "{{" | "}}")*
|
|
11
|
+
brace_group ::= "{" ws ident (ws "," ws ident)* ws "}"
|
|
12
|
+
ident ::= [A-Za-z_][A-Za-z0-9_]*
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from typing import TYPE_CHECKING, Literal
|
|
22
|
+
|
|
23
|
+
from smartpipe.core.errors import ItemError, UsageFault
|
|
24
|
+
from smartpipe.engine.schema import shorthand_to_schema
|
|
25
|
+
from smartpipe.models.base import ( # shared request value types, not behavior
|
|
26
|
+
CompletionRequest,
|
|
27
|
+
ImageData,
|
|
28
|
+
MediaData,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Sequence
|
|
33
|
+
|
|
34
|
+
from smartpipe.io.items import Item
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"FILTER_JUDGE_SYSTEM",
|
|
38
|
+
"IMAGE_ITEM_PREFIX",
|
|
39
|
+
"JOIN_JUDGE_SYSTEM",
|
|
40
|
+
"JUDGE_SCHEMA",
|
|
41
|
+
"MAP_JSON_SYSTEM",
|
|
42
|
+
"MAP_PLAIN_SYSTEM",
|
|
43
|
+
"REDUCE_FINAL_JSON_SYSTEM",
|
|
44
|
+
"REDUCE_FINAL_SYSTEM",
|
|
45
|
+
"REDUCE_INTERMEDIATE_SYSTEM",
|
|
46
|
+
"SCHEMA_DRAFT_SYSTEM",
|
|
47
|
+
"BraceToken",
|
|
48
|
+
"MapPlan",
|
|
49
|
+
"TextToken",
|
|
50
|
+
"Token",
|
|
51
|
+
"brace_fields",
|
|
52
|
+
"brace_notes",
|
|
53
|
+
"brace_props",
|
|
54
|
+
"build_filter_request",
|
|
55
|
+
"build_judge_request",
|
|
56
|
+
"build_map_request",
|
|
57
|
+
"build_reduce_final",
|
|
58
|
+
"build_reduce_intermediate",
|
|
59
|
+
"build_repair_request",
|
|
60
|
+
"build_schema_request",
|
|
61
|
+
"has_brace",
|
|
62
|
+
"interpolate_fields",
|
|
63
|
+
"interpolate_join",
|
|
64
|
+
"parse_join_predicate",
|
|
65
|
+
"parse_prompt",
|
|
66
|
+
"plan_map",
|
|
67
|
+
"reject_comma_groups",
|
|
68
|
+
"render",
|
|
69
|
+
"to_instruction",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
MAP_PLAIN_SYSTEM = (
|
|
73
|
+
"You transform text. Reply with ONLY the transformed text for the item — "
|
|
74
|
+
"no preamble, no quotes, no commentary."
|
|
75
|
+
)
|
|
76
|
+
MAP_JSON_SYSTEM = (
|
|
77
|
+
"Extract exactly the requested fields as a single JSON object matching the schema. "
|
|
78
|
+
"Reply with ONLY the JSON object — no preamble, no code fences, no commentary."
|
|
79
|
+
)
|
|
80
|
+
IMAGE_ITEM_PREFIX = "The item is an image. " # stage-07 contract, verbatim
|
|
81
|
+
_PLAIN_MAX_TOKENS = 4096
|
|
82
|
+
_STRUCTURED_MAX_TOKENS = 8192
|
|
83
|
+
|
|
84
|
+
FILTER_JUDGE_SYSTEM = (
|
|
85
|
+
"You judge whether an item satisfies a condition. "
|
|
86
|
+
'Reply with ONLY JSON: {"match": true} if it satisfies the condition, '
|
|
87
|
+
'or {"match": false} if it does not. No preamble, no explanation.'
|
|
88
|
+
)
|
|
89
|
+
JUDGE_SCHEMA: dict[str, object] = {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {"match": {"type": "boolean"}},
|
|
92
|
+
"required": ["match"],
|
|
93
|
+
"additionalProperties": False,
|
|
94
|
+
}
|
|
95
|
+
_JUDGE_MAX_TOKENS = 64
|
|
96
|
+
|
|
97
|
+
REDUCE_FINAL_SYSTEM = (
|
|
98
|
+
"You synthesize many items into a single result. Follow the user's instruction "
|
|
99
|
+
"exactly and reply with only the result — no preamble, no meta-commentary."
|
|
100
|
+
)
|
|
101
|
+
REDUCE_FINAL_JSON_SYSTEM = (
|
|
102
|
+
"You synthesize many items into a single JSON object matching the schema. "
|
|
103
|
+
"Reply with ONLY the JSON object — no preamble, no code fences."
|
|
104
|
+
)
|
|
105
|
+
REDUCE_INTERMEDIATE_SYSTEM = (
|
|
106
|
+
"You are condensing PART of a larger collection. Produce dense notes that "
|
|
107
|
+
"preserve every detail relevant to the stated goal. Do NOT write a conclusion "
|
|
108
|
+
"or a final answer — only notes that a later step will combine with others."
|
|
109
|
+
)
|
|
110
|
+
_REDUCE_MAX_TOKENS = 8192
|
|
111
|
+
|
|
112
|
+
_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
113
|
+
_SIDED_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?\Z")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class TextToken:
|
|
118
|
+
text: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class BraceToken:
|
|
123
|
+
fields: tuple[str, ...]
|
|
124
|
+
raw: str # the original "{a, b}" text, kept for error messages and round-trips
|
|
125
|
+
notes: tuple[str | None, ...] = () # rung-2 descriptions, aligned with fields (D22)
|
|
126
|
+
props: tuple[Mapping[str, object] | None, ...] = () # inline types (D37)
|
|
127
|
+
|
|
128
|
+
def note_for(self, position: int) -> str | None:
|
|
129
|
+
return self.notes[position] if position < len(self.notes) else None
|
|
130
|
+
|
|
131
|
+
def prop_for(self, position: int) -> Mapping[str, object] | None:
|
|
132
|
+
return self.props[position] if position < len(self.props) else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
Token = TextToken | BraceToken
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_prompt(
|
|
139
|
+
text: str, *, ident: re.Pattern[str] = _IDENT, allow_descriptions: bool = False
|
|
140
|
+
) -> tuple[Token, ...]:
|
|
141
|
+
tokens: list[Token] = []
|
|
142
|
+
literal: list[str] = []
|
|
143
|
+
index = 0
|
|
144
|
+
length = len(text)
|
|
145
|
+
|
|
146
|
+
def flush() -> None:
|
|
147
|
+
if literal:
|
|
148
|
+
tokens.append(TextToken("".join(literal)))
|
|
149
|
+
literal.clear()
|
|
150
|
+
|
|
151
|
+
while index < length:
|
|
152
|
+
char = text[index]
|
|
153
|
+
pair = text[index : index + 2]
|
|
154
|
+
if pair == "{{":
|
|
155
|
+
literal.append("{")
|
|
156
|
+
index += 2
|
|
157
|
+
elif pair == "}}":
|
|
158
|
+
literal.append("}")
|
|
159
|
+
index += 2
|
|
160
|
+
elif char == "{":
|
|
161
|
+
flush()
|
|
162
|
+
token, index = _parse_group(text, index, ident, allow_descriptions=allow_descriptions)
|
|
163
|
+
tokens.append(token)
|
|
164
|
+
elif char == "}":
|
|
165
|
+
raise UsageFault("unexpected '}' in prompt — use '}}' for a literal brace")
|
|
166
|
+
else:
|
|
167
|
+
literal.append(char)
|
|
168
|
+
index += 1
|
|
169
|
+
flush()
|
|
170
|
+
return tuple(tokens)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def brace_fields(tokens: tuple[Token, ...]) -> tuple[str, ...]:
|
|
174
|
+
"""Every field named across all brace groups, deduped, first-seen order."""
|
|
175
|
+
seen: dict[str, None] = {}
|
|
176
|
+
for token in tokens:
|
|
177
|
+
if isinstance(token, BraceToken):
|
|
178
|
+
for field in token.fields:
|
|
179
|
+
seen.setdefault(field, None)
|
|
180
|
+
return tuple(seen)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _merged_brace_bits(
|
|
184
|
+
tokens: tuple[Token, ...],
|
|
185
|
+
) -> tuple[list[str], dict[str, str], dict[str, Mapping[str, object]]]:
|
|
186
|
+
"""Fields across all groups, deduped in first-seen order; a field typed
|
|
187
|
+
twice DIFFERENTLY is an error (strict mode also rejects duplicate
|
|
188
|
+
``required`` entries, so the dedupe is correctness, not cosmetics)."""
|
|
189
|
+
fields: list[str] = []
|
|
190
|
+
notes: dict[str, str] = {}
|
|
191
|
+
props: dict[str, Mapping[str, object]] = {}
|
|
192
|
+
for token in tokens:
|
|
193
|
+
if not isinstance(token, BraceToken):
|
|
194
|
+
continue
|
|
195
|
+
for position, name in enumerate(token.fields):
|
|
196
|
+
if name not in fields:
|
|
197
|
+
fields.append(name)
|
|
198
|
+
note = token.note_for(position)
|
|
199
|
+
if note is not None:
|
|
200
|
+
notes.setdefault(name, note)
|
|
201
|
+
prop = token.prop_for(position)
|
|
202
|
+
if prop is not None:
|
|
203
|
+
known = props.get(name)
|
|
204
|
+
if known is not None and known != prop:
|
|
205
|
+
raise UsageFault(
|
|
206
|
+
f"field {name!r} is typed twice differently\n"
|
|
207
|
+
f" first: {_type_words(known)} — then: {_type_words(prop)}\n"
|
|
208
|
+
" Give a field one type; repeats without a type are fine."
|
|
209
|
+
)
|
|
210
|
+
props[name] = prop
|
|
211
|
+
return fields, notes, props
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _type_words(prop: Mapping[str, object]) -> str:
|
|
215
|
+
from smartpipe.core.jsontools import as_items, as_record
|
|
216
|
+
|
|
217
|
+
kind = prop.get("type")
|
|
218
|
+
if isinstance(kind, str):
|
|
219
|
+
items = as_record(prop.get("items"))
|
|
220
|
+
if items is not None:
|
|
221
|
+
inner = items.get("type")
|
|
222
|
+
return f"{inner}[]" if isinstance(inner, str) else "array"
|
|
223
|
+
return kind
|
|
224
|
+
values = as_items(prop.get("enum"))
|
|
225
|
+
if values is not None:
|
|
226
|
+
return "enum(" + ", ".join(str(value) for value in values) + ")"
|
|
227
|
+
return str(dict(prop))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def brace_props(tokens: tuple[Token, ...]) -> dict[str, Mapping[str, object]]:
|
|
231
|
+
"""field → inline type (D37), for every typed field across all groups."""
|
|
232
|
+
props: dict[str, Mapping[str, object]] = {}
|
|
233
|
+
for token in tokens:
|
|
234
|
+
if isinstance(token, BraceToken):
|
|
235
|
+
for position, name in enumerate(token.fields):
|
|
236
|
+
typed = token.prop_for(position)
|
|
237
|
+
if typed is not None:
|
|
238
|
+
props[name] = typed
|
|
239
|
+
return props
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def brace_notes(tokens: tuple[Token, ...]) -> dict[str, str]:
|
|
243
|
+
"""field → rung-2 description, for every described field across all groups."""
|
|
244
|
+
notes: dict[str, str] = {}
|
|
245
|
+
for token in tokens:
|
|
246
|
+
if isinstance(token, BraceToken):
|
|
247
|
+
for position, name in enumerate(token.fields):
|
|
248
|
+
described = token.note_for(position)
|
|
249
|
+
if described is not None:
|
|
250
|
+
notes[name] = described
|
|
251
|
+
return notes
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def has_brace(tokens: tuple[Token, ...]) -> bool:
|
|
255
|
+
return any(isinstance(token, BraceToken) for token in tokens)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def render(tokens: tuple[Token, ...]) -> str:
|
|
259
|
+
"""Reconstruct a prompt string; ``parse(render(parse(x))) == parse(x)``."""
|
|
260
|
+
parts = [
|
|
261
|
+
token.text.replace("{", "{{").replace("}", "}}")
|
|
262
|
+
if isinstance(token, TextToken)
|
|
263
|
+
else token.raw
|
|
264
|
+
for token in tokens
|
|
265
|
+
]
|
|
266
|
+
return "".join(parts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def to_instruction(tokens: tuple[Token, ...]) -> str:
|
|
270
|
+
"""The model-facing instruction: literal text as-is, brace groups as their
|
|
271
|
+
comma-joined field names (``Extract {a, b}`` → ``Extract a, b``); a rung-2
|
|
272
|
+
description rides its field as guidance (``vendor (the supplier name)``)."""
|
|
273
|
+
|
|
274
|
+
def group(token: BraceToken) -> str:
|
|
275
|
+
rendered = (
|
|
276
|
+
f"{name} ({note})" if (note := token.note_for(position)) is not None else name
|
|
277
|
+
for position, name in enumerate(token.fields)
|
|
278
|
+
)
|
|
279
|
+
return ", ".join(rendered)
|
|
280
|
+
|
|
281
|
+
parts = [token.text if isinstance(token, TextToken) else group(token) for token in tokens]
|
|
282
|
+
return "".join(parts)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@dataclass(frozen=True, slots=True)
|
|
286
|
+
class MapPlan:
|
|
287
|
+
mode: Literal["plain", "structured"]
|
|
288
|
+
schema: Mapping[str, object] | None
|
|
289
|
+
system: str
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def plan_map(tokens: tuple[Token, ...], *, schema: Mapping[str, object] | None) -> MapPlan:
|
|
293
|
+
"""Decide plain vs structured: an explicit --schema wins; else braces imply a
|
|
294
|
+
synthesized schema; else plain text (spec §3.1)."""
|
|
295
|
+
if schema is not None:
|
|
296
|
+
return MapPlan("structured", schema, MAP_JSON_SYSTEM)
|
|
297
|
+
if has_brace(tokens):
|
|
298
|
+
fields, notes, props = _merged_brace_bits(tokens)
|
|
299
|
+
synthesized = shorthand_to_schema(fields, descriptions=notes, types=props)
|
|
300
|
+
return MapPlan("structured", synthesized, MAP_JSON_SYSTEM)
|
|
301
|
+
return MapPlan("plain", None, MAP_PLAIN_SYSTEM)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def build_map_request(
|
|
305
|
+
plan: MapPlan,
|
|
306
|
+
instruction: str,
|
|
307
|
+
item_text: str,
|
|
308
|
+
*,
|
|
309
|
+
media: tuple[MediaData, ...] = (),
|
|
310
|
+
) -> CompletionRequest:
|
|
311
|
+
max_tokens = _STRUCTURED_MAX_TOKENS if plan.mode == "structured" else _PLAIN_MAX_TOKENS
|
|
312
|
+
prefixed = any(isinstance(part, ImageData) for part in media)
|
|
313
|
+
system = f"{IMAGE_ITEM_PREFIX}{plan.system}" if prefixed else plan.system
|
|
314
|
+
return CompletionRequest(
|
|
315
|
+
system=system,
|
|
316
|
+
user=f"{instruction}\n\n{item_text}" if item_text else instruction,
|
|
317
|
+
json_schema=plan.schema,
|
|
318
|
+
max_tokens=max_tokens,
|
|
319
|
+
media=media,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def build_repair_request(
|
|
324
|
+
original: CompletionRequest, *, bad_reply: str, error: str
|
|
325
|
+
) -> CompletionRequest:
|
|
326
|
+
"""Re-ask with the validator's complaint so the model can self-correct once."""
|
|
327
|
+
user = (
|
|
328
|
+
f"{original.user}\n\n"
|
|
329
|
+
f"Your previous reply was:\n{bad_reply}\n\n"
|
|
330
|
+
f"That was invalid: {error}\n"
|
|
331
|
+
"Reply again with ONLY a corrected JSON object."
|
|
332
|
+
)
|
|
333
|
+
return replace(original, user=user)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def reject_comma_groups(tokens: tuple[Token, ...]) -> None:
|
|
337
|
+
"""In filter/reduce, ``{field}`` reads one input field — comma-groups are a
|
|
338
|
+
map-only shorthand (plan/decisions.md D13). Fail fast, don't guess."""
|
|
339
|
+
for token in tokens:
|
|
340
|
+
if isinstance(token, BraceToken) and len(token.fields) > 1:
|
|
341
|
+
raise UsageFault(
|
|
342
|
+
f"{token.raw} — comma-separated braces only work in 'map'\n"
|
|
343
|
+
" In map, braces name the output fields. In filter and reduce, {field}\n"
|
|
344
|
+
" inserts a field from each input item, one field per brace group."
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def interpolate_fields(tokens: tuple[Token, ...], data: Mapping[str, object] | None) -> str:
|
|
349
|
+
"""Substitute each single-field ``{field}`` with the item's value. Raises
|
|
350
|
+
``ItemError`` (→ skip-and-warn) when the item isn't JSON or lacks the field."""
|
|
351
|
+
parts: list[str] = []
|
|
352
|
+
for token in tokens:
|
|
353
|
+
if isinstance(token, TextToken):
|
|
354
|
+
parts.append(token.text)
|
|
355
|
+
continue
|
|
356
|
+
field = token.fields[0] # comma-groups already rejected
|
|
357
|
+
if data is None:
|
|
358
|
+
raise ItemError(f"no field '{field}' (this item isn't JSON)")
|
|
359
|
+
if field not in data:
|
|
360
|
+
available = ", ".join(data) if data else "no fields"
|
|
361
|
+
raise ItemError(f"no field '{field}'; this item has: {available}")
|
|
362
|
+
parts.append(_render_value(data[field]))
|
|
363
|
+
return "".join(parts)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def build_filter_request(condition: str, item_text: str) -> CompletionRequest:
|
|
367
|
+
return CompletionRequest(
|
|
368
|
+
system=FILTER_JUDGE_SYSTEM,
|
|
369
|
+
user=f"Condition: {condition}\n\nItem:\n{item_text}",
|
|
370
|
+
json_schema=JUDGE_SCHEMA,
|
|
371
|
+
max_tokens=_JUDGE_MAX_TOKENS,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
SCHEMA_DRAFT_SYSTEM = (
|
|
376
|
+
"You design JSON Schemas (draft 2020-12). Reply with ONLY the JSON Schema "
|
|
377
|
+
"object — no preamble, no code fences. The top level must be an object schema "
|
|
378
|
+
'with "type": "object", "properties", "required", and '
|
|
379
|
+
'"additionalProperties": false. Mark a field optional only when the request '
|
|
380
|
+
"implies it. Prefer precise types, enums for closed sets, and short "
|
|
381
|
+
'"description" strings.'
|
|
382
|
+
)
|
|
383
|
+
_SCHEMA_DRAFT_MAX_TOKENS = 2048
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def build_schema_request(description: str) -> CompletionRequest:
|
|
387
|
+
"""Rung 4 (D22): exactly one drafting call; the reply is meta-validated
|
|
388
|
+
before stdout ever sees a byte."""
|
|
389
|
+
return CompletionRequest(
|
|
390
|
+
system=SCHEMA_DRAFT_SYSTEM,
|
|
391
|
+
user=f"Design a JSON Schema for: {description}",
|
|
392
|
+
max_tokens=_SCHEMA_DRAFT_MAX_TOKENS,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
JOIN_JUDGE_SYSTEM = (
|
|
397
|
+
"You judge whether a statement about a pair of items is true. "
|
|
398
|
+
'Reply with ONLY a JSON object: {"match": true} or {"match": false}.'
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
_JOIN_EXAMPLE = (
|
|
402
|
+
' Example: smartpipe join "ticket {left.text} concerns {right.name}" --right products.jsonl'
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def parse_join_predicate(text: str) -> tuple[Token, ...]:
|
|
407
|
+
"""The join grammar (D21): filter-rule braces, side-qualified. Every brace
|
|
408
|
+
names a side; the predicate must read both sides (one-sided predicates match
|
|
409
|
+
everything or nothing — a mistake, refused up front)."""
|
|
410
|
+
tokens = parse_prompt(text, ident=_SIDED_IDENT)
|
|
411
|
+
reject_comma_groups(tokens)
|
|
412
|
+
sides: set[str] = set()
|
|
413
|
+
for token in tokens:
|
|
414
|
+
if not isinstance(token, BraceToken):
|
|
415
|
+
continue
|
|
416
|
+
field = token.fields[0]
|
|
417
|
+
side, dot, _name = field.partition(".")
|
|
418
|
+
if not dot:
|
|
419
|
+
raise UsageFault(
|
|
420
|
+
f"{{{field}}} is ambiguous in join — say {{left.{field}}} or {{right.{field}}}\n"
|
|
421
|
+
" join reads two inputs; each brace names a side's field.\n" + _JOIN_EXAMPLE
|
|
422
|
+
)
|
|
423
|
+
if side not in ("left", "right"):
|
|
424
|
+
raise UsageFault(
|
|
425
|
+
f"{token.raw} — the side must be left or right\n"
|
|
426
|
+
" join reads two inputs; each brace names a side's field.\n" + _JOIN_EXAMPLE
|
|
427
|
+
)
|
|
428
|
+
sides.add(side)
|
|
429
|
+
for missing in ("left", "right"):
|
|
430
|
+
if missing not in sides:
|
|
431
|
+
raise UsageFault(
|
|
432
|
+
f"the predicate never mentions the {missing} side — "
|
|
433
|
+
f"say {{{missing}.field}} somewhere\n"
|
|
434
|
+
" join judges PAIRS; a predicate that reads one side matches everything "
|
|
435
|
+
"or nothing."
|
|
436
|
+
)
|
|
437
|
+
return tokens
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def interpolate_join(tokens: tuple[Token, ...], left: Item, right: Item) -> str:
|
|
441
|
+
"""Substitute ``{left.x}``/``{right.x}`` from the pair. ``.text`` falls back to
|
|
442
|
+
the item's whole text; any other missing field is an ``ItemError`` (pair-skip)."""
|
|
443
|
+
items = {"left": left, "right": right}
|
|
444
|
+
parts: list[str] = []
|
|
445
|
+
for token in tokens:
|
|
446
|
+
if isinstance(token, TextToken):
|
|
447
|
+
parts.append(token.text)
|
|
448
|
+
continue
|
|
449
|
+
side, _dot, name = token.fields[0].partition(".")
|
|
450
|
+
item = items[side]
|
|
451
|
+
parts.append(_join_value(side, name, item))
|
|
452
|
+
return "".join(parts)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _join_value(side: str, name: str, item: Item) -> str:
|
|
456
|
+
if item.data is not None and name in item.data:
|
|
457
|
+
return _render_value(item.data[name])
|
|
458
|
+
if name == "text":
|
|
459
|
+
return item.text # the pinned fallback: the whole item as text
|
|
460
|
+
available = ", ".join(item.data) if item.data else "no fields (not JSON)"
|
|
461
|
+
raise ItemError(f"{side} has no field '{name}'; it has: {available}")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def build_judge_request(tokens: tuple[Token, ...], left: Item, right: Item) -> CompletionRequest:
|
|
465
|
+
statement = interpolate_join(tokens, left, right)
|
|
466
|
+
return CompletionRequest(
|
|
467
|
+
system=JOIN_JUDGE_SYSTEM,
|
|
468
|
+
user=f"Statement about a pair of items:\n{statement}\n\nIs the statement true?",
|
|
469
|
+
json_schema=JUDGE_SCHEMA,
|
|
470
|
+
max_tokens=_JUDGE_MAX_TOKENS,
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def build_reduce_final(
|
|
475
|
+
instruction: str, texts: Sequence[str], schema: Mapping[str, object] | None
|
|
476
|
+
) -> CompletionRequest:
|
|
477
|
+
system = REDUCE_FINAL_JSON_SYSTEM if schema is not None else REDUCE_FINAL_SYSTEM
|
|
478
|
+
return CompletionRequest(
|
|
479
|
+
system=system,
|
|
480
|
+
user=f"{instruction}\n\nItems:\n{_numbered(texts)}",
|
|
481
|
+
json_schema=schema,
|
|
482
|
+
max_tokens=_REDUCE_MAX_TOKENS,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def build_reduce_intermediate(goal: str, texts: Sequence[str]) -> CompletionRequest:
|
|
487
|
+
return CompletionRequest(
|
|
488
|
+
system=REDUCE_INTERMEDIATE_SYSTEM,
|
|
489
|
+
user=f"Goal: {goal}\n\nItems:\n{_numbered(texts)}",
|
|
490
|
+
json_schema=None,
|
|
491
|
+
max_tokens=_REDUCE_MAX_TOKENS,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _numbered(texts: Sequence[str]) -> str:
|
|
496
|
+
return "\n\n---\n\n".join(f"[{index + 1}] {text}" for index, text in enumerate(texts))
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _render_value(value: object) -> str:
|
|
500
|
+
if isinstance(value, str):
|
|
501
|
+
return value
|
|
502
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _find_close(text: str, start: int) -> int:
|
|
506
|
+
close = text.find("}", start + 1)
|
|
507
|
+
if close == -1:
|
|
508
|
+
raise UsageFault("unclosed '{' in prompt — did you mean '{{' for a literal brace?")
|
|
509
|
+
return close
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _split_top_level(inner: str, raw: str) -> list[str]:
|
|
513
|
+
"""Split on commas OUTSIDE parentheses — enum(a, b) survives whole (D37).
|
|
514
|
+
|
|
515
|
+
Unbalanced parens are a loud error: a stray "(" would otherwise swallow
|
|
516
|
+
every following comma (and the fields behind them) into one description."""
|
|
517
|
+
parts: list[str] = []
|
|
518
|
+
depth = 0
|
|
519
|
+
current = ""
|
|
520
|
+
for char in inner:
|
|
521
|
+
if char == "(":
|
|
522
|
+
depth += 1
|
|
523
|
+
elif char == ")":
|
|
524
|
+
depth -= 1
|
|
525
|
+
if depth < 0:
|
|
526
|
+
raise UsageFault(
|
|
527
|
+
f"unbalanced parentheses in field group: {raw}\n"
|
|
528
|
+
" Every '(' needs a ')' — enum(a, b) is the only paren form."
|
|
529
|
+
)
|
|
530
|
+
if char == "," and depth == 0:
|
|
531
|
+
parts.append(current)
|
|
532
|
+
current = ""
|
|
533
|
+
else:
|
|
534
|
+
current += char
|
|
535
|
+
if depth != 0:
|
|
536
|
+
raise UsageFault(
|
|
537
|
+
f"unbalanced parentheses in field group: {raw}\n"
|
|
538
|
+
" Every '(' needs a ')' — enum(a, b) is the only paren form."
|
|
539
|
+
)
|
|
540
|
+
parts.append(current)
|
|
541
|
+
return parts
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _parse_group(
|
|
545
|
+
text: str, start: int, ident: re.Pattern[str], *, allow_descriptions: bool = False
|
|
546
|
+
) -> tuple[BraceToken, int]:
|
|
547
|
+
close = _find_close(text, start)
|
|
548
|
+
raw = text[start : close + 1]
|
|
549
|
+
inner = text[start + 1 : close]
|
|
550
|
+
parts = tuple(part.strip() for part in _split_top_level(inner, raw))
|
|
551
|
+
names: list[str] = []
|
|
552
|
+
notes: list[str | None] = []
|
|
553
|
+
props: list[Mapping[str, object] | None] = []
|
|
554
|
+
for part in parts:
|
|
555
|
+
head, colon, description = part.partition(":")
|
|
556
|
+
head = head.strip()
|
|
557
|
+
if not allow_descriptions:
|
|
558
|
+
names.append(part)
|
|
559
|
+
notes.append(None)
|
|
560
|
+
props.append(None)
|
|
561
|
+
continue
|
|
562
|
+
name, _space, type_text = head.partition(" ")
|
|
563
|
+
type_text = type_text.strip()
|
|
564
|
+
prop: Mapping[str, object] | None = None
|
|
565
|
+
if type_text:
|
|
566
|
+
# D37: an inline type, straight from the --schema-from vocabulary
|
|
567
|
+
from smartpipe.engine.schema_dsl import TYPE_MENU, type_token
|
|
568
|
+
|
|
569
|
+
prop = type_token(type_text)
|
|
570
|
+
if prop is None:
|
|
571
|
+
if type_text.startswith("enum(") and type_text.endswith(")"):
|
|
572
|
+
raise UsageFault(
|
|
573
|
+
f"{{{part}}} — enum needs at least one value\n"
|
|
574
|
+
" Example: status enum(paid, unpaid)"
|
|
575
|
+
)
|
|
576
|
+
raise UsageFault(
|
|
577
|
+
f"{{{part}}} — {type_text!r} isn't a type\n"
|
|
578
|
+
f" Types: {TYPE_MENU}\n"
|
|
579
|
+
" Constraints (>=, lengths, optional) live in --schema-from or --schema."
|
|
580
|
+
)
|
|
581
|
+
if colon:
|
|
582
|
+
description = description.strip()
|
|
583
|
+
if not description:
|
|
584
|
+
raise UsageFault(
|
|
585
|
+
f"{{{part}}} names field {name!r} with an empty description\n"
|
|
586
|
+
f" Write a description after the colon, or drop the colon: {{{name}}}"
|
|
587
|
+
)
|
|
588
|
+
names.append(name)
|
|
589
|
+
notes.append(description)
|
|
590
|
+
else:
|
|
591
|
+
names.append(name)
|
|
592
|
+
notes.append(None)
|
|
593
|
+
props.append(prop)
|
|
594
|
+
if any(not ident.match(name) for name in names):
|
|
595
|
+
raise UsageFault(
|
|
596
|
+
f"invalid field group: {raw}\n"
|
|
597
|
+
" field names must be identifiers (letters, digits, underscores), comma-separated"
|
|
598
|
+
)
|
|
599
|
+
described = tuple(notes) if any(note is not None for note in notes) else ()
|
|
600
|
+
typed = tuple(props) if any(prop is not None for prop in props) else ()
|
|
601
|
+
return BraceToken(tuple(names), raw, described, typed), close + 1
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Similarity ranking for ``top_k`` (plan/architecture.md, spec §3.4) — pure math.
|
|
2
|
+
|
|
3
|
+
Brute-force cosine: no vector database, the embeddings live in the pipe (D16). The
|
|
4
|
+
zero-vector guard matters — a cosine with a zero vector is defined as 0, never NaN,
|
|
5
|
+
so an empty or degenerate embedding can't poison the ranking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
__all__ = ["board_insert", "cosine", "rank", "select", "unit_score"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
|
20
|
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
|
21
|
+
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
|
|
22
|
+
return dot / norm if norm else 0.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def unit_score(cos: float) -> float:
|
|
26
|
+
"""Map cosine [-1, 1] to a [0, 1] score (spec §3.4)."""
|
|
27
|
+
return max(0.0, min(1.0, (1.0 + cos) / 2.0))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rank(
|
|
31
|
+
query: Sequence[float], vectors: Sequence[Sequence[float]]
|
|
32
|
+
) -> tuple[tuple[int, float], ...]:
|
|
33
|
+
"""Score every vector against the query, sorted best-first; ties by input order."""
|
|
34
|
+
scored = [(index, unit_score(cosine(query, vector))) for index, vector in enumerate(vectors)]
|
|
35
|
+
scored.sort(key=lambda pair: (-pair[1], pair[0]))
|
|
36
|
+
return tuple(scored)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def select(
|
|
40
|
+
ranked: Sequence[tuple[int, float]], *, k: int | None, threshold: float | None
|
|
41
|
+
) -> tuple[tuple[int, float], ...]:
|
|
42
|
+
"""Apply a similarity threshold and/or a top-K limit (spec §3.4)."""
|
|
43
|
+
kept = ranked if threshold is None else [p for p in ranked if p[1] >= threshold]
|
|
44
|
+
limited = kept if k is None else kept[:k]
|
|
45
|
+
return tuple(limited)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
BoardEntry = tuple[float, int] # (score, arrival) — payloads live with the caller
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def board_insert(
|
|
52
|
+
board: tuple[BoardEntry, ...], score: float, arrival: int, k: int
|
|
53
|
+
) -> tuple[tuple[BoardEntry, ...], bool]:
|
|
54
|
+
"""Insert a candidate into a top-K board (stage-08 §4.3, pure).
|
|
55
|
+
|
|
56
|
+
Rank by score descending, ties broken by earlier arrival. Returns the new
|
|
57
|
+
board and whether membership/order changed — the leaderboard repaints or
|
|
58
|
+
emits a snapshot only on change.
|
|
59
|
+
"""
|
|
60
|
+
merged = sorted((*board, (score, arrival)), key=lambda e: (-e[0], e[1]))[:k]
|
|
61
|
+
new_board = tuple(merged)
|
|
62
|
+
return new_board, new_board != board
|