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.
Files changed (65) hide show
  1. jev_mcp/__init__.py +1 -0
  2. jev_mcp/__main__.py +3 -0
  3. jev_mcp/domain/__init__.py +32 -0
  4. jev_mcp/domain/answers.py +25 -0
  5. jev_mcp/domain/json.py +49 -0
  6. jev_mcp/domain/questions.py +75 -0
  7. jev_mcp/domain/usage.py +16 -0
  8. jev_mcp/errors.py +59 -0
  9. jev_mcp/extract/__init__.py +1 -0
  10. jev_mcp/extract/candidates.py +75 -0
  11. jev_mcp/extract/dialect.py +400 -0
  12. jev_mcp/extract/executor.py +118 -0
  13. jev_mcp/extract/worker.py +198 -0
  14. jev_mcp/ids.py +49 -0
  15. jev_mcp/limits.py +218 -0
  16. jev_mcp/policy/__init__.py +98 -0
  17. jev_mcp/policy/actions.py +41 -0
  18. jev_mcp/policy/claims.py +103 -0
  19. jev_mcp/policy/extract.py +73 -0
  20. jev_mcp/policy/ranking.py +41 -0
  21. jev_mcp/policy/review.py +73 -0
  22. jev_mcp/policy/screen.py +48 -0
  23. jev_mcp/policy/thresholds.py +74 -0
  24. jev_mcp/providers/__init__.py +26 -0
  25. jev_mcp/providers/base.py +236 -0
  26. jev_mcp/providers/cloudflare.py +59 -0
  27. jev_mcp/providers/compatible.py +43 -0
  28. jev_mcp/providers/openrouter.py +47 -0
  29. jev_mcp/providers/resolver.py +106 -0
  30. jev_mcp/providers/typesafe.py +127 -0
  31. jev_mcp/py.typed +0 -0
  32. jev_mcp/serialize.py +199 -0
  33. jev_mcp/server.py +176 -0
  34. jev_mcp/settings.py +73 -0
  35. jev_mcp/stdio.py +99 -0
  36. jev_mcp/telemetry.py +223 -0
  37. jev_mcp/text.py +42 -0
  38. jev_mcp/tools/__init__.py +20 -0
  39. jev_mcp/tools/arguments.py +447 -0
  40. jev_mcp/tools/base.py +153 -0
  41. jev_mcp/tools/classify.py +187 -0
  42. jev_mcp/tools/common.py +96 -0
  43. jev_mcp/tools/compare.py +143 -0
  44. jev_mcp/tools/decide.py +206 -0
  45. jev_mcp/tools/extract.py +262 -0
  46. jev_mcp/tools/find.py +113 -0
  47. jev_mcp/tools/gate.py +236 -0
  48. jev_mcp/tools/observed.py +69 -0
  49. jev_mcp/tools/rerank.py +139 -0
  50. jev_mcp/tools/review.py +236 -0
  51. jev_mcp/tools/screen.py +126 -0
  52. jev_mcp/tools/toolset.py +92 -0
  53. jev_mcp/tools/verify.py +141 -0
  54. jev_mcp/validation/__init__.py +25 -0
  55. jev_mcp/validation/caps.py +93 -0
  56. jev_mcp/validation/choice.py +65 -0
  57. jev_mcp/validation/extract.py +48 -0
  58. jev_mcp/validation/noul.py +15 -0
  59. jev_mcp/validation/numbers.py +21 -0
  60. jev_mcp/validation/score.py +20 -0
  61. jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
  62. jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
  63. jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
  64. jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
  65. 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"