jev-mcp-python 0.1.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.
- jev_mcp/__init__.py +1 -0
- jev_mcp/__main__.py +3 -0
- jev_mcp/domain/__init__.py +32 -0
- jev_mcp/domain/answers.py +25 -0
- jev_mcp/domain/json.py +49 -0
- jev_mcp/domain/questions.py +75 -0
- jev_mcp/domain/usage.py +16 -0
- jev_mcp/errors.py +59 -0
- jev_mcp/extract/__init__.py +1 -0
- jev_mcp/extract/candidates.py +75 -0
- jev_mcp/extract/dialect.py +400 -0
- jev_mcp/extract/executor.py +118 -0
- jev_mcp/extract/worker.py +198 -0
- jev_mcp/ids.py +49 -0
- jev_mcp/limits.py +218 -0
- jev_mcp/policy/__init__.py +98 -0
- jev_mcp/policy/actions.py +41 -0
- jev_mcp/policy/claims.py +103 -0
- jev_mcp/policy/extract.py +73 -0
- jev_mcp/policy/ranking.py +41 -0
- jev_mcp/policy/review.py +73 -0
- jev_mcp/policy/screen.py +48 -0
- jev_mcp/policy/thresholds.py +74 -0
- jev_mcp/providers/__init__.py +26 -0
- jev_mcp/providers/base.py +236 -0
- jev_mcp/providers/cloudflare.py +59 -0
- jev_mcp/providers/compatible.py +43 -0
- jev_mcp/providers/openrouter.py +47 -0
- jev_mcp/providers/resolver.py +106 -0
- jev_mcp/providers/typesafe.py +127 -0
- jev_mcp/py.typed +0 -0
- jev_mcp/serialize.py +199 -0
- jev_mcp/server.py +176 -0
- jev_mcp/settings.py +73 -0
- jev_mcp/stdio.py +99 -0
- jev_mcp/telemetry.py +223 -0
- jev_mcp/text.py +42 -0
- jev_mcp/tools/__init__.py +20 -0
- jev_mcp/tools/arguments.py +447 -0
- jev_mcp/tools/base.py +153 -0
- jev_mcp/tools/classify.py +187 -0
- jev_mcp/tools/common.py +96 -0
- jev_mcp/tools/compare.py +143 -0
- jev_mcp/tools/decide.py +206 -0
- jev_mcp/tools/extract.py +262 -0
- jev_mcp/tools/find.py +113 -0
- jev_mcp/tools/gate.py +236 -0
- jev_mcp/tools/observed.py +69 -0
- jev_mcp/tools/rerank.py +139 -0
- jev_mcp/tools/review.py +236 -0
- jev_mcp/tools/screen.py +126 -0
- jev_mcp/tools/toolset.py +92 -0
- jev_mcp/tools/verify.py +141 -0
- jev_mcp/validation/__init__.py +25 -0
- jev_mcp/validation/caps.py +93 -0
- jev_mcp/validation/choice.py +65 -0
- jev_mcp/validation/extract.py +48 -0
- jev_mcp/validation/noul.py +15 -0
- jev_mcp/validation/numbers.py +21 -0
- jev_mcp/validation/score.py +20 -0
- jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
- jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
- jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
- jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
- jev_mcp_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""The production `RegexExecutor`: a pool of killable worker processes (ADR-0004, ADR-0016).
|
|
2
|
+
|
|
3
|
+
A slot is one process (`python -m jev_mcp.extract.worker`) running one pattern at a time, fed
|
|
4
|
+
length-prefixed pickles of plain values over its stdin and stdout, never the server's stdout. Each
|
|
5
|
+
pattern arrives with one absolute deadline, taken before queue admission and spent across admission,
|
|
6
|
+
worker start, IPC, matching, and the reply — no stage resets the clock (ADR-0016). On the deadline,
|
|
7
|
+
or on cancellation (ADR-0011), that slot's process is killed and a fresh one is started on the next
|
|
8
|
+
demand; other slots keep running. Admission is bounded: a waiter count at `queue_bound` is
|
|
9
|
+
`Saturated` at once, and a pattern the pool cannot admit before its deadline times out unrun.
|
|
10
|
+
|
|
11
|
+
Matching uses the standard library's `re`, a backtracking matcher like V8's Irregexp: patterns that
|
|
12
|
+
time out in the reference also time out here. The `regex` module short-circuits some of them
|
|
13
|
+
(`(a+)+$` finishes at once), which would turn the reference's `invalid_pattern` into a result
|
|
14
|
+
(ADR-0018).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import pickle
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from time import monotonic
|
|
23
|
+
from typing import Final
|
|
24
|
+
|
|
25
|
+
import anyio
|
|
26
|
+
from anyio.abc import Process
|
|
27
|
+
from anyio.streams.buffered import BufferedByteReceiveStream
|
|
28
|
+
|
|
29
|
+
from jev_mcp.extract.dialect import Translated
|
|
30
|
+
from jev_mcp.extract.executor import (
|
|
31
|
+
Invalid,
|
|
32
|
+
Matches,
|
|
33
|
+
MatchResult,
|
|
34
|
+
Saturated,
|
|
35
|
+
Timeout,
|
|
36
|
+
Unavailable,
|
|
37
|
+
match_all,
|
|
38
|
+
)
|
|
39
|
+
from jev_mcp.limits import EXTRACT, ExtractCaps
|
|
40
|
+
|
|
41
|
+
_START_TIMEOUT_S: Final = 30.0
|
|
42
|
+
_SELF_DEADLINE_GRACE_S: Final = 1.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _WorkerFailed(Exception):
|
|
46
|
+
def __init__(self, cause: Unavailable) -> None:
|
|
47
|
+
self.cause = cause
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main() -> None:
|
|
51
|
+
"""The worker process: answer one job at a time until stdin closes.
|
|
52
|
+
|
|
53
|
+
Each job also arms SIGALRM, whose default action ends the process: if the server dies without
|
|
54
|
+
killing a worker stuck in a pattern, the worker still ends shortly after the deadline. `re`
|
|
55
|
+
never returns to the interpreter mid-match, so only a signal's default action can stop it.
|
|
56
|
+
"""
|
|
57
|
+
signal.signal(signal.SIGINT, signal.SIG_IGN) # the server owns shutdown; it kills its workers
|
|
58
|
+
signal.signal(signal.SIGALRM, signal.SIG_DFL)
|
|
59
|
+
requests, replies = sys.stdin.buffer, sys.stdout.buffer
|
|
60
|
+
replies.write(_READY)
|
|
61
|
+
replies.flush()
|
|
62
|
+
while len(header := requests.read(_HEADER)) == _HEADER:
|
|
63
|
+
job = pickle.loads(requests.read(int.from_bytes(header))) # noqa: S301 - our parent
|
|
64
|
+
source, flags, units, deadline, max_candidates, max_units = job
|
|
65
|
+
signal.setitimer(signal.ITIMER_REAL, deadline + _SELF_DEADLINE_GRACE_S)
|
|
66
|
+
matches = match_all(Translated(source, flags), units, max_candidates, max_units)
|
|
67
|
+
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
68
|
+
reply = pickle.dumps((matches.candidates, matches.truncated, matches.too_long))
|
|
69
|
+
replies.write(len(reply).to_bytes(_HEADER) + reply)
|
|
70
|
+
replies.flush()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_READY: Final = b"R"
|
|
74
|
+
_HEADER: Final = 4
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Slot:
|
|
78
|
+
def __init__(self, process: Process) -> None:
|
|
79
|
+
assert process.stdin is not None and process.stdout is not None
|
|
80
|
+
self.process = process
|
|
81
|
+
self.requests = process.stdin
|
|
82
|
+
self.replies = BufferedByteReceiveStream(process.stdout)
|
|
83
|
+
|
|
84
|
+
async def kill(self) -> None:
|
|
85
|
+
with anyio.CancelScope(shield=True):
|
|
86
|
+
try:
|
|
87
|
+
self.process.kill()
|
|
88
|
+
except ProcessLookupError:
|
|
89
|
+
pass # already reaped (a worker can die on a bad pattern before kill lands); the goal holds
|
|
90
|
+
await self.process.aclose()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ProcessRegexExecutor:
|
|
94
|
+
"""At most `size` patterns run at once, each in its own killable process.
|
|
95
|
+
|
|
96
|
+
The caller's deadline is a whole-request budget (ADR-0016): admission, worker start, IPC, and
|
|
97
|
+
matching all spend it. `queue_bound` caps how many patterns may wait for a slot. Each job carries
|
|
98
|
+
the candidate caps from `caps` (`limits.EXTRACT`), so the worker holds no copy of them.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, size: int | None = None, queue_bound: int | None = None, caps: ExtractCaps = EXTRACT) -> None:
|
|
102
|
+
self._caps = caps
|
|
103
|
+
self.size = size or min(8, os.cpu_count() or 1)
|
|
104
|
+
self.queue_bound = queue_bound if queue_bound is not None else 8 * self.size
|
|
105
|
+
self._limit = anyio.Semaphore(self.size)
|
|
106
|
+
self._waiting = 0
|
|
107
|
+
self._idle: list[_Slot] = []
|
|
108
|
+
|
|
109
|
+
async def find(self, pattern: Translated, text: str, *, deadline: float) -> MatchResult:
|
|
110
|
+
"""Match `pattern` over unit-space `text`; every stage spends what remains of `deadline`."""
|
|
111
|
+
if self._waiting >= self.queue_bound:
|
|
112
|
+
return Saturated()
|
|
113
|
+
self._waiting += 1
|
|
114
|
+
try:
|
|
115
|
+
with anyio.fail_after(max(0.0, deadline - monotonic())):
|
|
116
|
+
await self._limit.acquire()
|
|
117
|
+
except TimeoutError:
|
|
118
|
+
return Timeout()
|
|
119
|
+
finally:
|
|
120
|
+
self._waiting -= 1
|
|
121
|
+
try:
|
|
122
|
+
return await self._find(pattern, text, deadline)
|
|
123
|
+
except TimeoutError:
|
|
124
|
+
return Timeout()
|
|
125
|
+
except _WorkerFailed as failed:
|
|
126
|
+
return Invalid(failed.cause)
|
|
127
|
+
finally:
|
|
128
|
+
self._limit.release()
|
|
129
|
+
|
|
130
|
+
async def _find(self, pattern: Translated, text: str, deadline: float) -> Matches:
|
|
131
|
+
remaining = deadline - monotonic()
|
|
132
|
+
if remaining <= 0:
|
|
133
|
+
raise TimeoutError
|
|
134
|
+
slot = self._idle.pop() if self._idle else await self._start(remaining)
|
|
135
|
+
healthy = False
|
|
136
|
+
try:
|
|
137
|
+
matches = await self._run(slot, pattern, text, deadline - monotonic())
|
|
138
|
+
healthy = True
|
|
139
|
+
return matches
|
|
140
|
+
finally:
|
|
141
|
+
if healthy:
|
|
142
|
+
self._idle.append(slot)
|
|
143
|
+
else:
|
|
144
|
+
await slot.kill()
|
|
145
|
+
|
|
146
|
+
async def _run(self, slot: _Slot, pattern: Translated, units: str, budget: float) -> Matches:
|
|
147
|
+
if budget <= 0:
|
|
148
|
+
raise TimeoutError
|
|
149
|
+
caps = self._caps
|
|
150
|
+
job = pickle.dumps(
|
|
151
|
+
(pattern.source, pattern.flags, units, budget, caps.candidates_per_field, caps.candidate_units)
|
|
152
|
+
)
|
|
153
|
+
try:
|
|
154
|
+
await slot.requests.send(len(job).to_bytes(_HEADER) + job)
|
|
155
|
+
with anyio.fail_after(budget):
|
|
156
|
+
size = int.from_bytes(await slot.replies.receive_exactly(_HEADER))
|
|
157
|
+
reply = await slot.replies.receive_exactly(size)
|
|
158
|
+
except TimeoutError:
|
|
159
|
+
raise # an OSError, but the deadline's, not a crash
|
|
160
|
+
except (anyio.IncompleteRead, anyio.EndOfStream, anyio.BrokenResourceError, OSError):
|
|
161
|
+
raise _WorkerFailed(Unavailable.NO_RESULT) from None
|
|
162
|
+
candidates, truncated, too_long = pickle.loads(reply) # noqa: S301 - our own child
|
|
163
|
+
return Matches(candidates, truncated, too_long)
|
|
164
|
+
|
|
165
|
+
async def _start(self, budget: float = _START_TIMEOUT_S) -> _Slot:
|
|
166
|
+
"""A ready worker. Raises `TimeoutError` if `budget` runs out first, `_WorkerFailed` on a crash."""
|
|
167
|
+
process = await anyio.open_process(
|
|
168
|
+
[sys.executable, "-m", __name__], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None
|
|
169
|
+
)
|
|
170
|
+
slot = _Slot(process)
|
|
171
|
+
try:
|
|
172
|
+
with anyio.fail_after(budget):
|
|
173
|
+
ready = await slot.replies.receive_exactly(len(_READY))
|
|
174
|
+
except TimeoutError:
|
|
175
|
+
# The budget ran out before the worker signalled readiness — a deadline, not a crash.
|
|
176
|
+
await slot.kill()
|
|
177
|
+
raise
|
|
178
|
+
except (anyio.IncompleteRead, anyio.EndOfStream):
|
|
179
|
+
await slot.kill()
|
|
180
|
+
raise _WorkerFailed(Unavailable.NOT_STARTED) from None
|
|
181
|
+
if ready != _READY:
|
|
182
|
+
await slot.kill()
|
|
183
|
+
raise _WorkerFailed(Unavailable.NOT_STARTED)
|
|
184
|
+
return slot
|
|
185
|
+
|
|
186
|
+
async def warm(self) -> None:
|
|
187
|
+
"""Start one worker ahead of demand."""
|
|
188
|
+
if not self._idle:
|
|
189
|
+
self._idle.append(await self._start())
|
|
190
|
+
|
|
191
|
+
async def aclose(self) -> None:
|
|
192
|
+
idle, self._idle = self._idle, []
|
|
193
|
+
for slot in idle:
|
|
194
|
+
await slot.kill()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|
jev_mcp/ids.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""`sanitize_id` and `ensure_unique_ids` (`lib.ts:13-45`)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
_UNSAFE = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
8
|
+
MAX_ID_LENGTH = 64
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sanitize_id(raw: str) -> str:
|
|
12
|
+
"""Collapse each run of characters outside `[A-Za-z0-9_.-]` to `_`, strip edge underscores, keep 64.
|
|
13
|
+
|
|
14
|
+
The result is ASCII, so code points and UTF-16 units coincide. The 64-unit slice runs after the
|
|
15
|
+
strip, so a cut id may end in `_`, as in the reference.
|
|
16
|
+
"""
|
|
17
|
+
return _UNSAFE.sub("_", raw).strip("_")[:MAX_ID_LENGTH]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UniqueIds(NamedTuple):
|
|
21
|
+
items: list[dict[str, object]]
|
|
22
|
+
"""Copies of the input items with `id` set; an existing `id` keeps its key position."""
|
|
23
|
+
renamed: dict[str, str]
|
|
24
|
+
"""Caller id → id used, for every non-empty caller id that changed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ensure_unique_ids(items: Sequence[Mapping[str, object]], fallback_prefix: str) -> UniqueIds:
|
|
28
|
+
"""Give every item a safe, unique id: sanitized, else `{fallback_prefix}{index}`; collisions get `_1`, `_2`, ….
|
|
29
|
+
|
|
30
|
+
A missing or null `id` counts as empty. Fallback ids are not checked against later caller ids
|
|
31
|
+
beyond the shared collision loop, exactly as in the reference.
|
|
32
|
+
"""
|
|
33
|
+
used: set[str] = set()
|
|
34
|
+
renamed: dict[str, str] = {}
|
|
35
|
+
out: list[dict[str, object]] = []
|
|
36
|
+
for index, item in enumerate(items):
|
|
37
|
+
raw = item.get("id")
|
|
38
|
+
raw_id = raw if isinstance(raw, str) else ""
|
|
39
|
+
base = sanitize_id(raw_id) or f"{fallback_prefix}{index}"
|
|
40
|
+
new_id = base
|
|
41
|
+
suffix = 1
|
|
42
|
+
while new_id in used:
|
|
43
|
+
new_id = f"{base}_{suffix}"
|
|
44
|
+
suffix += 1
|
|
45
|
+
used.add(new_id)
|
|
46
|
+
if raw_id and raw_id != new_id:
|
|
47
|
+
renamed[raw_id] = new_id
|
|
48
|
+
out.append({**item, "id": new_id})
|
|
49
|
+
return UniqueIds(out, renamed)
|
jev_mcp/limits.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""The frozen input caps, typed per tool (ADR-0014); the manifest is the oracle, not the source.
|
|
2
|
+
|
|
3
|
+
Every value is transcribed from `docs/reference/parity-manifest.json` `caps`, and
|
|
4
|
+
`tests/contract/test_limits.py` fails when the two disagree, so a re-freeze points here. VALUES
|
|
5
|
+
ONLY: UTF-16 measurement stays in `text.py` (ADR-0005) and applying a cap — reject is the schema
|
|
6
|
+
itself — lives in the tools and `validation/caps.py`. A tool that hardcodes one of these numbers is
|
|
7
|
+
a bug.
|
|
8
|
+
|
|
9
|
+
Text bounds are UTF-16 code units (JS `.length`); `None` marks a cap the reference deliberately
|
|
10
|
+
leaves open (the manifest records it as null: "do not add a bound"). The three behaviors of the
|
|
11
|
+
manifest prose: schema `min*`/`max*` are reject, `*_units` are truncate-to-cap or skip thresholds,
|
|
12
|
+
and the aggregate budgets are strict greater-than errors (the cap itself is allowed).
|
|
13
|
+
|
|
14
|
+
`jev_extract`'s candidate caps (`candidates_per_field`, `candidate_units`) reach the matcher from
|
|
15
|
+
here, carried in each executor job; `regex_timeout_ms` is enforced in `extract/candidates.py`, which
|
|
16
|
+
keeps its own copy, and the contract test fails on drift between the two.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Final
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class CandidatesCaps:
|
|
25
|
+
"""`candidatesSchema` (`index.ts:105-116`), shared by jev_find and jev_rerank."""
|
|
26
|
+
|
|
27
|
+
min_items: int
|
|
28
|
+
max_items: int
|
|
29
|
+
text_units: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class VerifyCaps:
|
|
34
|
+
"""jev_verify: only presence is capped; lengths are deliberately open (manifest nulls)."""
|
|
35
|
+
|
|
36
|
+
claims_min: int
|
|
37
|
+
claims_max: int | None
|
|
38
|
+
claim_units: int | None
|
|
39
|
+
evidence_min: int
|
|
40
|
+
evidence_max: int | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class ScreenCaps:
|
|
45
|
+
"""jev_screen: `text` only needs content; no length bound (manifest nulls)."""
|
|
46
|
+
|
|
47
|
+
text_min: int
|
|
48
|
+
text_max: int | None
|
|
49
|
+
purpose_max: int | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class FindCaps:
|
|
54
|
+
"""jev_find: candidate bounds are the shared `CANDIDATES`."""
|
|
55
|
+
|
|
56
|
+
top_k_min: int
|
|
57
|
+
top_k_max: int
|
|
58
|
+
top_k_default: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ClassifyCaps:
|
|
63
|
+
"""jev_classify: per-entry text truncation plus the item-class budget error."""
|
|
64
|
+
|
|
65
|
+
items_min: int
|
|
66
|
+
items_max: int
|
|
67
|
+
item_units: int
|
|
68
|
+
classes_min: int
|
|
69
|
+
classes_max: int
|
|
70
|
+
class_description_units: int
|
|
71
|
+
item_class_pairs: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class DecideCaps:
|
|
76
|
+
"""jev_decide: every bound is a schema reject; nothing is truncated."""
|
|
77
|
+
|
|
78
|
+
decision_min: int
|
|
79
|
+
decision_max: int
|
|
80
|
+
evidence_min: int
|
|
81
|
+
evidence_max: int
|
|
82
|
+
priorities_min: int
|
|
83
|
+
priorities_max: int
|
|
84
|
+
candidates_min: int
|
|
85
|
+
candidates_max: int
|
|
86
|
+
candidate_id_max: int
|
|
87
|
+
candidate_description_min: int
|
|
88
|
+
candidate_description_max: int
|
|
89
|
+
requirements_min: int
|
|
90
|
+
requirements_max: int
|
|
91
|
+
requirement_min: int
|
|
92
|
+
requirement_max: int
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class RerankCaps:
|
|
97
|
+
"""jev_rerank: candidate bounds are the shared `CANDIDATES`."""
|
|
98
|
+
|
|
99
|
+
query_min: int
|
|
100
|
+
query_max: int
|
|
101
|
+
aggregate_candidate_units: int
|
|
102
|
+
top_k_min: int
|
|
103
|
+
top_k_max: int
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class CompareCaps:
|
|
108
|
+
"""jev_compare: passages are schema-rejected above the cap; the truncate call is a no-op guard."""
|
|
109
|
+
|
|
110
|
+
passage_min: int
|
|
111
|
+
passage_max: int
|
|
112
|
+
aspects_min: int
|
|
113
|
+
aspects_max: int
|
|
114
|
+
aspect_min: int
|
|
115
|
+
aspect_max: int
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class ExtractCaps:
|
|
120
|
+
"""jev_extract: schema rejects, one aggregate budget error, and the worker's pipeline caps."""
|
|
121
|
+
|
|
122
|
+
document_min: int
|
|
123
|
+
document_max: int
|
|
124
|
+
fields_min: int
|
|
125
|
+
fields_max: int
|
|
126
|
+
field_id_max: int
|
|
127
|
+
pattern_min: int
|
|
128
|
+
pattern_max: int
|
|
129
|
+
flags_max: int
|
|
130
|
+
description_min: int
|
|
131
|
+
description_max: int
|
|
132
|
+
candidates_per_field: int
|
|
133
|
+
candidate_units: int
|
|
134
|
+
aggregate_candidate_units: int
|
|
135
|
+
regex_timeout_ms: int
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class ReviewCaps:
|
|
140
|
+
"""jev_review: request, diff, and tests are each truncated at the doc cap, never schema-rejected."""
|
|
141
|
+
|
|
142
|
+
doc_units: int
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class GateCaps:
|
|
147
|
+
"""jev_gate: claim/item/text caps and the two aggregate isError budgets."""
|
|
148
|
+
|
|
149
|
+
claims_min: int
|
|
150
|
+
claims_max: int
|
|
151
|
+
claim_units: int
|
|
152
|
+
evidence_items: int
|
|
153
|
+
aggregate_evidence_units: int
|
|
154
|
+
doc_units: int
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
CANDIDATES: Final = CandidatesCaps(min_items=1, max_items=250, text_units=2000)
|
|
158
|
+
"""jev_find `"candidates": "1..250 reject"` / `"candidate_text": "2000 truncate"`; jev_rerank
|
|
159
|
+
`"candidates": "..250 reject"` — one shared schema and one shared truncation."""
|
|
160
|
+
|
|
161
|
+
VERIFY: Final = VerifyCaps(claims_min=1, claims_max=None, claim_units=None, evidence_min=1, evidence_max=None)
|
|
162
|
+
SCREEN: Final = ScreenCaps(text_min=1, text_max=None, purpose_max=None)
|
|
163
|
+
FIND: Final = FindCaps(top_k_min=1, top_k_max=50, top_k_default=5)
|
|
164
|
+
CLASSIFY: Final = ClassifyCaps(
|
|
165
|
+
items_min=1,
|
|
166
|
+
items_max=64,
|
|
167
|
+
item_units=2000,
|
|
168
|
+
classes_min=2,
|
|
169
|
+
classes_max=250,
|
|
170
|
+
class_description_units=2000,
|
|
171
|
+
item_class_pairs=8_000,
|
|
172
|
+
)
|
|
173
|
+
DECIDE: Final = DecideCaps(
|
|
174
|
+
decision_min=1,
|
|
175
|
+
decision_max=1500,
|
|
176
|
+
evidence_min=1,
|
|
177
|
+
evidence_max=12_000,
|
|
178
|
+
priorities_min=1,
|
|
179
|
+
priorities_max=2000,
|
|
180
|
+
candidates_min=2,
|
|
181
|
+
candidates_max=6,
|
|
182
|
+
candidate_id_max=64,
|
|
183
|
+
candidate_description_min=1,
|
|
184
|
+
candidate_description_max=2000,
|
|
185
|
+
requirements_min=0,
|
|
186
|
+
requirements_max=3,
|
|
187
|
+
requirement_min=1,
|
|
188
|
+
requirement_max=500,
|
|
189
|
+
)
|
|
190
|
+
RERANK: Final = RerankCaps(query_min=1, query_max=2000, aggregate_candidate_units=100_000, top_k_min=1, top_k_max=250)
|
|
191
|
+
COMPARE: Final = CompareCaps(
|
|
192
|
+
passage_min=1, passage_max=20_000, aspects_min=0, aspects_max=10, aspect_min=1, aspect_max=200
|
|
193
|
+
)
|
|
194
|
+
EXTRACT: Final = ExtractCaps(
|
|
195
|
+
document_min=1,
|
|
196
|
+
document_max=50_000,
|
|
197
|
+
fields_min=1,
|
|
198
|
+
fields_max=32,
|
|
199
|
+
field_id_max=64,
|
|
200
|
+
pattern_min=1,
|
|
201
|
+
pattern_max=500,
|
|
202
|
+
flags_max=8,
|
|
203
|
+
description_min=1,
|
|
204
|
+
description_max=2000,
|
|
205
|
+
candidates_per_field=20,
|
|
206
|
+
candidate_units=2000,
|
|
207
|
+
aggregate_candidate_units=50_000,
|
|
208
|
+
regex_timeout_ms=1000,
|
|
209
|
+
)
|
|
210
|
+
REVIEW: Final = ReviewCaps(doc_units=50_000)
|
|
211
|
+
GATE: Final = GateCaps(
|
|
212
|
+
claims_min=1,
|
|
213
|
+
claims_max=16,
|
|
214
|
+
claim_units=2000,
|
|
215
|
+
evidence_items=16,
|
|
216
|
+
aggregate_evidence_units=200_000,
|
|
217
|
+
doc_units=50_000,
|
|
218
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Pure policy mapping validated judgments to actions (ADR-0002). Validation rejects; policy decides."""
|
|
2
|
+
|
|
3
|
+
from jev_mcp.policy.actions import (
|
|
4
|
+
Action,
|
|
5
|
+
Decision,
|
|
6
|
+
classification_decision,
|
|
7
|
+
require_complete_context,
|
|
8
|
+
verify_action,
|
|
9
|
+
worst_action,
|
|
10
|
+
)
|
|
11
|
+
from jev_mcp.policy.claims import (
|
|
12
|
+
GATE_REASON_CODES,
|
|
13
|
+
ClaimJudgment,
|
|
14
|
+
ClaimVerdict,
|
|
15
|
+
RequirementCheck,
|
|
16
|
+
claim_action,
|
|
17
|
+
contradicts_recommendation,
|
|
18
|
+
gate_reason_codes,
|
|
19
|
+
)
|
|
20
|
+
from jev_mcp.policy.extract import (
|
|
21
|
+
EXTRACT_REASON_CODES,
|
|
22
|
+
ExtractFieldDecision,
|
|
23
|
+
ExtractFieldEvidence,
|
|
24
|
+
ExtractJudgment,
|
|
25
|
+
ExtractReasonCode,
|
|
26
|
+
ExtractStatus,
|
|
27
|
+
decide_extract_field,
|
|
28
|
+
)
|
|
29
|
+
from jev_mcp.policy.ranking import ExistsVerdict, exists_verdict, rank_candidates, rerank_by_score
|
|
30
|
+
from jev_mcp.policy.review import REVIEW_WEIGHTS, min_confidence, review_action, review_composite
|
|
31
|
+
from jev_mcp.policy.screen import ScreenAction, ScreenRecommendation, screen_fail_closed, screen_recommendation
|
|
32
|
+
from jev_mcp.policy.thresholds import (
|
|
33
|
+
DEFAULT_AUTO_ACCEPT,
|
|
34
|
+
DEFAULT_CLASSIFY_AUTO_ACCEPT,
|
|
35
|
+
DEFAULT_COMPOSITE_FLOOR,
|
|
36
|
+
DEFAULT_MINIMUM_MARGIN,
|
|
37
|
+
DEFAULT_REVIEW_AT_CAP,
|
|
38
|
+
DEFAULT_SCREEN_BLOCK_AT,
|
|
39
|
+
DEFAULT_SCREEN_REVIEW_AT,
|
|
40
|
+
EXISTS_ABSENT_BELOW,
|
|
41
|
+
EXISTS_FOUND_AT,
|
|
42
|
+
SCREEN_RELEVANCE_SKIP_BELOW,
|
|
43
|
+
SCREEN_SUBSTANCE_SKIP_BELOW,
|
|
44
|
+
THRESHOLD_INVARIANT_MESSAGE,
|
|
45
|
+
PolicyThresholds,
|
|
46
|
+
resolve_policy_thresholds,
|
|
47
|
+
validate_policy_thresholds,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DEFAULT_AUTO_ACCEPT",
|
|
52
|
+
"DEFAULT_CLASSIFY_AUTO_ACCEPT",
|
|
53
|
+
"DEFAULT_COMPOSITE_FLOOR",
|
|
54
|
+
"DEFAULT_MINIMUM_MARGIN",
|
|
55
|
+
"DEFAULT_REVIEW_AT_CAP",
|
|
56
|
+
"DEFAULT_SCREEN_BLOCK_AT",
|
|
57
|
+
"DEFAULT_SCREEN_REVIEW_AT",
|
|
58
|
+
"EXISTS_ABSENT_BELOW",
|
|
59
|
+
"EXISTS_FOUND_AT",
|
|
60
|
+
"EXTRACT_REASON_CODES",
|
|
61
|
+
"GATE_REASON_CODES",
|
|
62
|
+
"REVIEW_WEIGHTS",
|
|
63
|
+
"SCREEN_RELEVANCE_SKIP_BELOW",
|
|
64
|
+
"SCREEN_SUBSTANCE_SKIP_BELOW",
|
|
65
|
+
"THRESHOLD_INVARIANT_MESSAGE",
|
|
66
|
+
"Action",
|
|
67
|
+
"ClaimJudgment",
|
|
68
|
+
"ClaimVerdict",
|
|
69
|
+
"Decision",
|
|
70
|
+
"ExistsVerdict",
|
|
71
|
+
"ExtractFieldDecision",
|
|
72
|
+
"ExtractFieldEvidence",
|
|
73
|
+
"ExtractJudgment",
|
|
74
|
+
"ExtractReasonCode",
|
|
75
|
+
"ExtractStatus",
|
|
76
|
+
"PolicyThresholds",
|
|
77
|
+
"RequirementCheck",
|
|
78
|
+
"ScreenAction",
|
|
79
|
+
"ScreenRecommendation",
|
|
80
|
+
"claim_action",
|
|
81
|
+
"classification_decision",
|
|
82
|
+
"contradicts_recommendation",
|
|
83
|
+
"decide_extract_field",
|
|
84
|
+
"exists_verdict",
|
|
85
|
+
"gate_reason_codes",
|
|
86
|
+
"min_confidence",
|
|
87
|
+
"rank_candidates",
|
|
88
|
+
"require_complete_context",
|
|
89
|
+
"rerank_by_score",
|
|
90
|
+
"resolve_policy_thresholds",
|
|
91
|
+
"review_action",
|
|
92
|
+
"review_composite",
|
|
93
|
+
"screen_fail_closed",
|
|
94
|
+
"screen_recommendation",
|
|
95
|
+
"validate_policy_thresholds",
|
|
96
|
+
"verify_action",
|
|
97
|
+
"worst_action",
|
|
98
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Actions and the rules that combine them (`lib.ts:60-62`, `lib.ts:126-133`, `lib.ts:288-360`)."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
type Action = Literal["auto", "review", "escalate"]
|
|
7
|
+
"""`PolicyAction`: auto stands alone, review needs confirmation, escalate must not proceed as is."""
|
|
8
|
+
|
|
9
|
+
type Decision = Literal["auto", "review"]
|
|
10
|
+
"""The two-tier action of verify, classify, compare, and extract: they have no escalate tier."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_action(confidence: float, auto_accept: float) -> Decision:
|
|
14
|
+
"""`verifyAction`: auto iff confidence >= auto_accept.
|
|
15
|
+
|
|
16
|
+
Unknown confidence never reaches this function: jev_verify maps it to review at the call site
|
|
17
|
+
(`index.ts:216`), and that check belongs to the tool.
|
|
18
|
+
"""
|
|
19
|
+
return "auto" if confidence >= auto_accept else "review"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classification_decision(
|
|
23
|
+
top_probability: float, margin: float, auto_accept: float, minimum_margin: float
|
|
24
|
+
) -> Decision:
|
|
25
|
+
"""`classificationDecision`: auto iff top_probability >= auto_accept and margin >= minimum_margin."""
|
|
26
|
+
return "auto" if top_probability >= auto_accept and margin >= minimum_margin else "review"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_complete_context(action: Action, truncated: bool) -> Action:
|
|
30
|
+
"""`requireCompleteContext`: truncated input demotes auto to review and leaves stronger actions alone."""
|
|
31
|
+
return "review" if truncated and action == "auto" else action
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def worst_action(actions: Iterable[Action]) -> Action:
|
|
35
|
+
"""`worstAction`: escalate > review > auto. No actions at all is auto, as in the reference."""
|
|
36
|
+
seen = set(actions)
|
|
37
|
+
if "escalate" in seen:
|
|
38
|
+
return "escalate"
|
|
39
|
+
if "review" in seen:
|
|
40
|
+
return "review"
|
|
41
|
+
return "auto"
|