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,161 @@
|
|
|
1
|
+
"""Chunking math for the recursive ``reduce`` (spec §3.5) — pure.
|
|
2
|
+
|
|
3
|
+
The reduce tree "just works": when the input exceeds what the model can hold,
|
|
4
|
+
smartpipe splits it into chunks, summarizes each, and recurses on the summaries —
|
|
5
|
+
no flags, no strategy selection. This module owns the arithmetic (token estimate,
|
|
6
|
+
context budget, item-boundary splitting); the verb drives the recursion, since
|
|
7
|
+
each level's size isn't known until the model produces the notes.
|
|
8
|
+
|
|
9
|
+
Token estimation is deliberately crude (≈4 chars/token) and used with a safety
|
|
10
|
+
factor — being conservative just means more levels, never a truncated call.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"budget_for",
|
|
23
|
+
"chunk_indices",
|
|
24
|
+
"estimate_tokens",
|
|
25
|
+
"fits_in_one",
|
|
26
|
+
"halve",
|
|
27
|
+
"is_context_overflow",
|
|
28
|
+
"mean_pool",
|
|
29
|
+
"split_text",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Conservative per-provider input windows; ollama is deliberately small because we
|
|
33
|
+
# can't cheaply know a local model's context length (a smaller budget is always safe).
|
|
34
|
+
_CONTEXT: dict[str, int] = {
|
|
35
|
+
"ollama": 8000,
|
|
36
|
+
"openai": 128000,
|
|
37
|
+
"anthropic": 200000,
|
|
38
|
+
"mistral": 128000,
|
|
39
|
+
"gemini": 128000, # conservative: flash models carry ≥1M, but budget for the floor
|
|
40
|
+
"openrouter": 32000, # unknowable per-model — the safe floor for routed models
|
|
41
|
+
}
|
|
42
|
+
_SAFETY = 0.6
|
|
43
|
+
_CHARS_PER_TOKEN = 4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def estimate_tokens(text: str) -> int:
|
|
47
|
+
return math.ceil(len(text) / _CHARS_PER_TOKEN) if text else 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def budget_for(provider: str, *, prompt_overhead: int, window: int | None = None) -> int:
|
|
51
|
+
"""Token budget for one call. ``window`` (from a live probe or the
|
|
52
|
+
SMARTPIPE_CONTEXT_TOKENS override) beats the static table when present."""
|
|
53
|
+
resolved = window if window is not None else _CONTEXT.get(provider, _CONTEXT["ollama"])
|
|
54
|
+
return int(resolved * _SAFETY) - prompt_overhead
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_OVERFLOW_MARKERS = (
|
|
58
|
+
"context_length",
|
|
59
|
+
"context length",
|
|
60
|
+
"context window",
|
|
61
|
+
"maximum context",
|
|
62
|
+
"too long",
|
|
63
|
+
"too large",
|
|
64
|
+
"input length",
|
|
65
|
+
"prompt is too long",
|
|
66
|
+
"exceeds the limit",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_context_overflow(message: str) -> bool:
|
|
71
|
+
"""Does this per-item error text look like a context-window overflow?
|
|
72
|
+
|
|
73
|
+
The classifier that lets reduce self-correct (D26): estimates are hints,
|
|
74
|
+
the wire's own rejection is ground truth — matched loosely on the phrases
|
|
75
|
+
the five wired providers actually use."""
|
|
76
|
+
lowered = message.lower()
|
|
77
|
+
return any(marker in lowered for marker in _OVERFLOW_MARKERS)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def halve(chunk: tuple[int, ...]) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
|
81
|
+
"""Split a chunk of item indexes into two non-empty halves (len must be ≥ 2)."""
|
|
82
|
+
middle = len(chunk) // 2
|
|
83
|
+
return chunk[:middle], chunk[middle:]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fits_in_one(sizes: Sequence[int], budget: int) -> bool:
|
|
87
|
+
return sum(sizes) <= budget
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def chunk_indices(sizes: Sequence[int], budget: int) -> tuple[tuple[int, ...], ...]:
|
|
91
|
+
"""Group consecutive item indexes into chunks whose total estimate fits the
|
|
92
|
+
budget. An item is never split; one that alone exceeds the budget gets its own
|
|
93
|
+
(over-budget) chunk rather than being dropped."""
|
|
94
|
+
chunks: list[tuple[int, ...]] = []
|
|
95
|
+
current: list[int] = []
|
|
96
|
+
current_size = 0
|
|
97
|
+
for index, size in enumerate(sizes):
|
|
98
|
+
if current and current_size + size > budget:
|
|
99
|
+
chunks.append(tuple(current))
|
|
100
|
+
current = []
|
|
101
|
+
current_size = 0
|
|
102
|
+
current.append(index)
|
|
103
|
+
current_size += size
|
|
104
|
+
if current:
|
|
105
|
+
chunks.append(tuple(current))
|
|
106
|
+
return tuple(chunks)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def split_text(text: str, budget: int) -> tuple[str, ...]:
|
|
110
|
+
"""Break one oversized text into ≤-budget chunks (D26 layer 3).
|
|
111
|
+
|
|
112
|
+
Paragraph boundaries first, then lines, then a hard character cut — and the
|
|
113
|
+
chunks concatenate back to the original text exactly (nothing added, nothing
|
|
114
|
+
lost; separators travel with their preceding piece)."""
|
|
115
|
+
if estimate_tokens(text) <= budget:
|
|
116
|
+
return (text,)
|
|
117
|
+
pieces = _carrying_separators(text, r"\n\n+")
|
|
118
|
+
if len(pieces) == 1:
|
|
119
|
+
pieces = _carrying_separators(text, r"\n")
|
|
120
|
+
chunks: list[str] = []
|
|
121
|
+
current = ""
|
|
122
|
+
for piece in pieces:
|
|
123
|
+
for part in _hard_cut(piece, budget):
|
|
124
|
+
if current and estimate_tokens(current + part) > budget:
|
|
125
|
+
chunks.append(current)
|
|
126
|
+
current = ""
|
|
127
|
+
current += part
|
|
128
|
+
if current:
|
|
129
|
+
chunks.append(current)
|
|
130
|
+
return tuple(chunks)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _carrying_separators(text: str, pattern: str) -> list[str]:
|
|
134
|
+
"""Split by a separator regex, attaching each separator to the piece before
|
|
135
|
+
it, so ``"".join(result) == text``."""
|
|
136
|
+
import re
|
|
137
|
+
|
|
138
|
+
tokens = re.split(f"({pattern})", text)
|
|
139
|
+
pieces: list[str] = []
|
|
140
|
+
for token in tokens:
|
|
141
|
+
if pieces and re.fullmatch(pattern, token or " ") and token:
|
|
142
|
+
pieces[-1] += token
|
|
143
|
+
elif token:
|
|
144
|
+
pieces.append(token)
|
|
145
|
+
return pieces or [text]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _hard_cut(piece: str, budget: int) -> tuple[str, ...]:
|
|
149
|
+
"""A single piece that alone exceeds the budget is cut at character bounds."""
|
|
150
|
+
limit = max(budget * _CHARS_PER_TOKEN, 1)
|
|
151
|
+
if len(piece) <= limit:
|
|
152
|
+
return (piece,)
|
|
153
|
+
return tuple(piece[i : i + limit] for i in range(0, len(piece), limit))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def mean_pool(vectors: Sequence[tuple[float, ...]]) -> tuple[float, ...]:
|
|
157
|
+
"""Component-wise mean of chunk embeddings — the standard whole-document
|
|
158
|
+
vector when one text had to be embedded in pieces (D26)."""
|
|
159
|
+
assert vectors, "mean_pool needs at least one vector"
|
|
160
|
+
count = len(vectors)
|
|
161
|
+
return tuple(sum(component) / count for component in zip(*vectors, strict=True))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Vector clustering for distinct/outliers/cluster/diff (D38) — pure, stdlib.
|
|
2
|
+
|
|
3
|
+
Hand-rolled on purpose (frozen dependency budget: no sklearn, ever). Leader
|
|
4
|
+
clustering is greedy and input-order stable: deterministic re-runs are a
|
|
5
|
+
feature — the answer must not change under the user.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from smartpipe.engine.ranking import cosine
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
__all__ = ["adaptive_threshold", "knn_mean_distance", "leader_clusters", "merge_to_k"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def leader_clusters(vectors: Sequence[tuple[float, ...]], *, threshold: float) -> list[list[int]]:
|
|
21
|
+
"""Greedy leader clustering: each vector joins the FIRST cluster whose
|
|
22
|
+
leader (its first member) is ≥ ``threshold`` cosine-similar, else founds
|
|
23
|
+
its own. Returns clusters as index lists, in founding order."""
|
|
24
|
+
clusters: list[list[int]] = []
|
|
25
|
+
for index, vector in enumerate(vectors):
|
|
26
|
+
for members in clusters:
|
|
27
|
+
if cosine(vectors[members[0]], vector) >= threshold:
|
|
28
|
+
members.append(index)
|
|
29
|
+
break
|
|
30
|
+
else:
|
|
31
|
+
clusters.append([index])
|
|
32
|
+
return clusters
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def knn_mean_distance(vectors: Sequence[tuple[float, ...]], *, k: int) -> list[float]:
|
|
36
|
+
"""Mean cosine DISTANCE (1 - similarity) to each vector's k nearest
|
|
37
|
+
neighbors — the weirdness score for ``outliers``. Robust on multi-cluster
|
|
38
|
+
corpora where centroid distance lies. O(n²): fine at corpus sizes where a
|
|
39
|
+
human will read the answer."""
|
|
40
|
+
n = len(vectors)
|
|
41
|
+
neighbors = min(k, n - 1)
|
|
42
|
+
if neighbors <= 0:
|
|
43
|
+
return [0.0] * n
|
|
44
|
+
scores: list[float] = []
|
|
45
|
+
for index, vector in enumerate(vectors):
|
|
46
|
+
distances = sorted(
|
|
47
|
+
1.0 - cosine(vector, other)
|
|
48
|
+
for position, other in enumerate(vectors)
|
|
49
|
+
if position != index
|
|
50
|
+
)
|
|
51
|
+
scores.append(sum(distances[:neighbors]) / neighbors)
|
|
52
|
+
return scores
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def merge_to_k(
|
|
56
|
+
vectors: Sequence[tuple[float, ...]], clusters: list[list[int]], *, k: int
|
|
57
|
+
) -> list[list[int]]:
|
|
58
|
+
"""Force exactly ``k`` clusters: repeatedly merge the SMALLEST into the
|
|
59
|
+
cluster whose leader is most similar to its leader (ties: earliest).
|
|
60
|
+
Deterministic; returns a new list, founding order preserved."""
|
|
61
|
+
merged = [list(members) for members in clusters]
|
|
62
|
+
while len(merged) > max(1, k):
|
|
63
|
+
smallest = min(range(len(merged)), key=lambda index: (len(merged[index]), index))
|
|
64
|
+
leader = vectors[merged[smallest][0]]
|
|
65
|
+
best = max(
|
|
66
|
+
(index for index in range(len(merged)) if index != smallest),
|
|
67
|
+
key=lambda index: (cosine(vectors[merged[index][0]], leader), -index),
|
|
68
|
+
)
|
|
69
|
+
merged[best].extend(merged[smallest])
|
|
70
|
+
del merged[smallest]
|
|
71
|
+
return merged
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_PAIR_BUDGET = 20_000 # pairwise sample cap — clustering stays interactive
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def adaptive_threshold(vectors: Sequence[tuple[float, ...]]) -> float:
|
|
78
|
+
"""A grouping threshold derived from the corpus itself: median pairwise
|
|
79
|
+
similarity (the cross-theme background) plus 35% of the gap to the 95th
|
|
80
|
+
percentile (the same-theme tail). Fixed thresholds can't serve every
|
|
81
|
+
embedder's geometry — gemini's same-theme pairs sit near 0.7 where
|
|
82
|
+
synthetic unit vectors sit near 1.0 (measured, D38/05). Deterministic
|
|
83
|
+
given the corpus; pairs are stride-sampled past the budget."""
|
|
84
|
+
n = len(vectors)
|
|
85
|
+
if n < 3:
|
|
86
|
+
return 0.99 # nothing to group — only near-identity folds
|
|
87
|
+
pairs = [(i, j) for i in range(n) for j in range(i + 1, n)]
|
|
88
|
+
if len(pairs) > _PAIR_BUDGET:
|
|
89
|
+
stride = len(pairs) // _PAIR_BUDGET + 1
|
|
90
|
+
pairs = pairs[::stride]
|
|
91
|
+
similarities = sorted(cosine(vectors[i], vectors[j]) for i, j in pairs)
|
|
92
|
+
median = similarities[len(similarities) // 2]
|
|
93
|
+
p95 = similarities[min(len(similarities) - 1, int(len(similarities) * 0.95))]
|
|
94
|
+
return median + 0.35 * (p95 - median)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""The ``where`` predicate grammar (D38/01): a closed, KQL-flavored menu.
|
|
2
|
+
|
|
3
|
+
Pure: parse once into a small AST, evaluate per item with zero I/O. Missing
|
|
4
|
+
fields evaluate false (KQL behavior — streams keep flowing) but are tallied so
|
|
5
|
+
the verb can disclose them at the end; silence must never lie.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Literal, assert_never
|
|
14
|
+
|
|
15
|
+
from smartpipe.core.errors import UsageFault
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from smartpipe.io.items import Item
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"WHERE_MENU",
|
|
22
|
+
"Compare",
|
|
23
|
+
"Contains",
|
|
24
|
+
"FieldTally",
|
|
25
|
+
"Has",
|
|
26
|
+
"Matches",
|
|
27
|
+
"Predicate",
|
|
28
|
+
"evaluate",
|
|
29
|
+
"parse_predicate",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
WHERE_MENU = (
|
|
33
|
+
"error: can't parse the where predicate\n"
|
|
34
|
+
' Operators: FIELD has "word" · FIELD contains "text" · FIELD matches /re/\n'
|
|
35
|
+
' FIELD == VALUE · != · > · >= · < · <= (numbers or "strings")\n'
|
|
36
|
+
" Combine: and · or · not · ( ) FIELD is a record field, or text\n"
|
|
37
|
+
' Example: text has "ERROR" and not text contains "retry"\n'
|
|
38
|
+
" Semantic condition instead? smartpipe filter judges with a model."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class Has:
|
|
44
|
+
field: str
|
|
45
|
+
word: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Contains:
|
|
50
|
+
field: str
|
|
51
|
+
text: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class Matches:
|
|
56
|
+
field: str
|
|
57
|
+
pattern: re.Pattern[str]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Compare:
|
|
62
|
+
field: str
|
|
63
|
+
op: Literal["==", "!=", ">", ">=", "<", "<="]
|
|
64
|
+
value: str | float
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class And:
|
|
69
|
+
left: Predicate
|
|
70
|
+
right: Predicate
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class Or:
|
|
75
|
+
left: Predicate
|
|
76
|
+
right: Predicate
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Not:
|
|
81
|
+
inner: Predicate
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
Predicate = Has | Contains | Matches | Compare | And | Or | Not
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class FieldTally:
|
|
89
|
+
"""Mutable disclosure state owned by the verb: what silence would hide."""
|
|
90
|
+
|
|
91
|
+
missing: Counter[str] = field(default_factory=Counter[str])
|
|
92
|
+
non_numeric: Counter[str] = field(default_factory=Counter[str])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- tokenizer --------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
_TOKEN = re.compile(
|
|
98
|
+
r"""\s*(?:
|
|
99
|
+
(?P<lparen>\()
|
|
100
|
+
| (?P<rparen>\))
|
|
101
|
+
| (?P<op>==|!=|>=|<=|>|<)
|
|
102
|
+
| (?P<string>"(?P<sbody>[^"]*)")
|
|
103
|
+
| (?P<sopen>")
|
|
104
|
+
| (?P<regex>/(?P<rbody>(?:\\/|[^/])*)/)
|
|
105
|
+
| (?P<number>-?\d+(?:\.\d+)?)
|
|
106
|
+
| (?P<word>[A-Za-z_][A-Za-z0-9_.]*)
|
|
107
|
+
)""",
|
|
108
|
+
re.VERBOSE,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
_KEYWORDS = frozenset({"and", "or", "not", "has", "contains", "matches"})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _tokenize(text: str) -> list[tuple[str, str]]:
|
|
115
|
+
tokens: list[tuple[str, str]] = []
|
|
116
|
+
position = 0
|
|
117
|
+
while position < len(text):
|
|
118
|
+
match = _TOKEN.match(text, position)
|
|
119
|
+
if match is None:
|
|
120
|
+
remainder = text[position:].strip()
|
|
121
|
+
if not remainder:
|
|
122
|
+
break
|
|
123
|
+
raise UsageFault(WHERE_MENU + f"\n (stuck at: {remainder[:30]!r})")
|
|
124
|
+
if match.lastgroup is None or match.end() == position:
|
|
125
|
+
break
|
|
126
|
+
kind = match.lastgroup
|
|
127
|
+
if kind == "sopen":
|
|
128
|
+
raise UsageFault(WHERE_MENU + "\n (unclosed string literal)")
|
|
129
|
+
if kind == "string":
|
|
130
|
+
tokens.append(("string", match.group("sbody")))
|
|
131
|
+
elif kind == "regex":
|
|
132
|
+
tokens.append(("regex", match.group("rbody").replace("\\/", "/")))
|
|
133
|
+
elif kind == "word":
|
|
134
|
+
word = match.group("word")
|
|
135
|
+
tokens.append((word, word) if word in _KEYWORDS else ("field", word))
|
|
136
|
+
else:
|
|
137
|
+
tokens.append((kind, match.group(kind) or match.group(0).strip()))
|
|
138
|
+
position = match.end()
|
|
139
|
+
return tokens
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- recursive descent: or > and > not > leaf --------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _Parser:
|
|
146
|
+
def __init__(self, tokens: list[tuple[str, str]]) -> None:
|
|
147
|
+
self.tokens = tokens
|
|
148
|
+
self.position = 0
|
|
149
|
+
|
|
150
|
+
def peek(self) -> tuple[str, str] | None:
|
|
151
|
+
return self.tokens[self.position] if self.position < len(self.tokens) else None
|
|
152
|
+
|
|
153
|
+
def take(self) -> tuple[str, str]:
|
|
154
|
+
token = self.peek()
|
|
155
|
+
if token is None:
|
|
156
|
+
raise UsageFault(WHERE_MENU + "\n (predicate ends mid-expression)")
|
|
157
|
+
self.position += 1
|
|
158
|
+
return token
|
|
159
|
+
|
|
160
|
+
def parse_or(self) -> Predicate:
|
|
161
|
+
node = self.parse_and()
|
|
162
|
+
while (token := self.peek()) is not None and token[0] == "or":
|
|
163
|
+
self.take()
|
|
164
|
+
node = Or(node, self.parse_and())
|
|
165
|
+
return node
|
|
166
|
+
|
|
167
|
+
def parse_and(self) -> Predicate:
|
|
168
|
+
node = self.parse_not()
|
|
169
|
+
while (token := self.peek()) is not None and token[0] == "and":
|
|
170
|
+
self.take()
|
|
171
|
+
node = And(node, self.parse_not())
|
|
172
|
+
return node
|
|
173
|
+
|
|
174
|
+
def parse_not(self) -> Predicate:
|
|
175
|
+
if (token := self.peek()) is not None and token[0] == "not":
|
|
176
|
+
self.take()
|
|
177
|
+
return Not(self.parse_not())
|
|
178
|
+
return self.parse_leaf()
|
|
179
|
+
|
|
180
|
+
def parse_leaf(self) -> Predicate:
|
|
181
|
+
kind, value = self.take()
|
|
182
|
+
if kind == "lparen":
|
|
183
|
+
inner = self.parse_or()
|
|
184
|
+
closing = self.peek()
|
|
185
|
+
if closing is None or closing[0] != "rparen":
|
|
186
|
+
raise UsageFault(WHERE_MENU + "\n (missing closing ')')")
|
|
187
|
+
self.take()
|
|
188
|
+
return inner
|
|
189
|
+
if kind != "field":
|
|
190
|
+
raise UsageFault(WHERE_MENU + f"\n (expected a field name, got {value!r})")
|
|
191
|
+
return self.parse_operator(value)
|
|
192
|
+
|
|
193
|
+
def parse_operator(self, field_name: str) -> Predicate:
|
|
194
|
+
kind, value = self.take()
|
|
195
|
+
match kind:
|
|
196
|
+
case "has" | "contains":
|
|
197
|
+
text_kind, text_value = self.take()
|
|
198
|
+
if text_kind != "string":
|
|
199
|
+
raise UsageFault(WHERE_MENU + f'\n ({kind} needs a "quoted string")')
|
|
200
|
+
return (
|
|
201
|
+
Has(field_name, text_value)
|
|
202
|
+
if kind == "has"
|
|
203
|
+
else Contains(field_name, text_value)
|
|
204
|
+
)
|
|
205
|
+
case "matches":
|
|
206
|
+
regex_kind, regex_value = self.take()
|
|
207
|
+
if regex_kind != "regex":
|
|
208
|
+
raise UsageFault(WHERE_MENU + "\n (matches needs /a regex/)")
|
|
209
|
+
try:
|
|
210
|
+
return Matches(field_name, re.compile(regex_value))
|
|
211
|
+
except re.error as exc:
|
|
212
|
+
raise UsageFault(WHERE_MENU + f"\n (bad regex: {exc})") from exc
|
|
213
|
+
case "op":
|
|
214
|
+
literal_kind, literal_value = self.take()
|
|
215
|
+
if literal_kind == "number":
|
|
216
|
+
return Compare(field_name, _as_op(value), float(literal_value))
|
|
217
|
+
if literal_kind == "string":
|
|
218
|
+
return Compare(field_name, _as_op(value), literal_value)
|
|
219
|
+
if literal_kind == "field" and literal_value in ("true", "false"):
|
|
220
|
+
# booleans compare as their JSON spelling
|
|
221
|
+
return Compare(field_name, _as_op(value), literal_value)
|
|
222
|
+
raise UsageFault(WHERE_MENU + f'\n ({value} needs a number or "string")')
|
|
223
|
+
case _:
|
|
224
|
+
raise UsageFault(WHERE_MENU + f"\n (expected an operator after {field_name!r})")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _as_op(text: str) -> Literal["==", "!=", ">", ">=", "<", "<="]:
|
|
228
|
+
match text:
|
|
229
|
+
case "==" | "!=" | ">" | ">=" | "<" | "<=":
|
|
230
|
+
return text
|
|
231
|
+
case _ as unreachable: # pragma: no cover — the tokenizer only emits the six
|
|
232
|
+
raise AssertionError(unreachable)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_predicate(text: str) -> Predicate:
|
|
236
|
+
parser = _Parser(_tokenize(text))
|
|
237
|
+
node = parser.parse_or()
|
|
238
|
+
leftover = parser.peek()
|
|
239
|
+
if leftover is not None:
|
|
240
|
+
raise UsageFault(WHERE_MENU + f"\n (unexpected trailing {leftover[1]!r})")
|
|
241
|
+
return node
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --- evaluation --------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def evaluate(predicate: Predicate, item: Item, tally: FieldTally) -> bool:
|
|
248
|
+
match predicate:
|
|
249
|
+
case And(left, right):
|
|
250
|
+
return evaluate(left, item, tally) and evaluate(right, item, tally)
|
|
251
|
+
case Or(left, right):
|
|
252
|
+
return evaluate(left, item, tally) or evaluate(right, item, tally)
|
|
253
|
+
case Not(inner):
|
|
254
|
+
return not evaluate(inner, item, tally)
|
|
255
|
+
case Has(field_name, word):
|
|
256
|
+
value = _resolve(field_name, item, tally)
|
|
257
|
+
if value is None:
|
|
258
|
+
return False
|
|
259
|
+
boundary = rf"(?<![A-Za-z0-9_]){re.escape(word)}(?![A-Za-z0-9_])"
|
|
260
|
+
return re.search(boundary, _as_text(value), re.IGNORECASE) is not None
|
|
261
|
+
case Contains(field_name, needle):
|
|
262
|
+
value = _resolve(field_name, item, tally)
|
|
263
|
+
return value is not None and needle.lower() in _as_text(value).lower()
|
|
264
|
+
case Matches(field_name, pattern):
|
|
265
|
+
value = _resolve(field_name, item, tally)
|
|
266
|
+
return value is not None and pattern.search(_as_text(value)) is not None
|
|
267
|
+
case Compare(field_name, op, expected):
|
|
268
|
+
value = _resolve(field_name, item, tally)
|
|
269
|
+
if value is None:
|
|
270
|
+
return False
|
|
271
|
+
return _compare(field_name, value, op, expected, tally)
|
|
272
|
+
case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
|
|
273
|
+
assert_never(unreachable)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _resolve(field_name: str, item: Item, tally: FieldTally) -> object | None:
|
|
277
|
+
if field_name == "text":
|
|
278
|
+
return item.text
|
|
279
|
+
value = item.data.get(field_name) if item.data is not None else None
|
|
280
|
+
if value is None:
|
|
281
|
+
tally.missing[field_name] += 1
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _as_text(value: object) -> str:
|
|
286
|
+
return value if isinstance(value, str) else str(value)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _numeric(value: object) -> float | None:
|
|
290
|
+
if isinstance(value, bool): # bool is int; keep booleans out of arithmetic
|
|
291
|
+
return None
|
|
292
|
+
if isinstance(value, (int, float)):
|
|
293
|
+
return float(value)
|
|
294
|
+
if isinstance(value, str):
|
|
295
|
+
try:
|
|
296
|
+
return float(value)
|
|
297
|
+
except ValueError:
|
|
298
|
+
return None
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _compare(
|
|
303
|
+
field_name: str,
|
|
304
|
+
value: object,
|
|
305
|
+
op: Literal["==", "!=", ">", ">=", "<", "<="],
|
|
306
|
+
expected: str | float,
|
|
307
|
+
tally: FieldTally,
|
|
308
|
+
) -> bool:
|
|
309
|
+
ours = _numeric(value)
|
|
310
|
+
theirs = _numeric(expected)
|
|
311
|
+
if ours is not None and theirs is not None:
|
|
312
|
+
match op:
|
|
313
|
+
case "==":
|
|
314
|
+
return ours == theirs
|
|
315
|
+
case "!=":
|
|
316
|
+
return ours != theirs
|
|
317
|
+
case ">":
|
|
318
|
+
return ours > theirs
|
|
319
|
+
case ">=":
|
|
320
|
+
return ours >= theirs
|
|
321
|
+
case "<":
|
|
322
|
+
return ours < theirs
|
|
323
|
+
case "<=":
|
|
324
|
+
return ours <= theirs
|
|
325
|
+
if op in ("==", "!="):
|
|
326
|
+
# string equality — booleans/None compare as their JSON spelling
|
|
327
|
+
rendered = _as_text(value) if not isinstance(value, bool) else str(value).lower()
|
|
328
|
+
return (rendered == _as_text(expected)) is (op == "==")
|
|
329
|
+
tally.non_numeric[field_name] += 1 # ordered compare on a non-number: no match
|
|
330
|
+
return False
|