adjudicate 0.3.0__tar.gz

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.
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Brož
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: adjudicate
3
+ Version: 0.3.0
4
+ Summary: Bounded, auditable text edits chosen by a language model: detector proposes, model picks, applier splices.
5
+ Project-URL: Homepage, https://github.com/AndrewBroz/adjudicate
6
+ Project-URL: Issues, https://github.com/AndrewBroz/adjudicate/issues
7
+ Author: Andrew Brož
8
+ License-Expression: MIT AND LGPL-2.0-or-later AND LicenseRef-SCOWL
9
+ License-File: LICENSE
10
+ License-File: adjudicate/dict/LICENSE-en_GB.txt
11
+ License-File: adjudicate/dict/LICENSE-en_US.txt
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Topic :: Text Processing :: Linguistic
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: spylls>=0.1.7
19
+ Description-Content-Type: text/markdown
20
+
21
+ # adjudicate
22
+
23
+ A small library for one pattern: a detector proposes a span in a document
24
+ and a short list of candidate replacements; a local language model picks
25
+ one of them, or KEEP, from the surrounding sentence and a rule of thumb;
26
+ the applier splices the choice in with case preserved. Every change is a
27
+ bounded, logged, reasoned edit. The model never rewrites prose.
28
+
29
+ It is the toolkit behind [stylefix](https://github.com/AndrewBroz/stylefix)
30
+ (US/UK spelling and style) and [proofix](https://github.com/AndrewBroz/proofix)
31
+ (typos, punctuation, commas). It is published because they depend on it; the
32
+ API may change between 0.x releases.
33
+
34
+ ```
35
+ pip install adjudicate
36
+ ```
37
+
38
+ Modules: `llm` (OpenAI-compatible client and endpoint resolution), `core`
39
+ (Item, Decision, Cache, `adjudicate`), `apply` (Edit, `apply_edits`),
40
+ `context` (sentence context, Markdown prose regions), `dictionary`
41
+ (Hunspell lookups over the bundled en_US and en_GB dictionaries), `setup`
42
+ (`run_setup` and `check_endpoint`, behind the tools' `--setup` and
43
+ `--check-endpoint`).
44
+
45
+ ## Choosing the model endpoint
46
+
47
+ `resolve_endpoint(app, preset="auto", url=None, model=None, key=None,
48
+ think=False, timeout=None)` merges settings from, highest first:
49
+
50
+ 1. its arguments (the calling tool's `--url`, `--model`, `--timeout`)
51
+ 2. `<APP>_LLM_URL`, `<APP>_LLM_MODEL`, `<APP>_LLM_KEY`, e.g. `STYLEFIX_LLM_URL`
52
+ 3. `~/.config/<app>/config.toml`
53
+ 4. `ADJUDICATE_LLM_URL`, `ADJUDICATE_LLM_MODEL`, `ADJUDICATE_LLM_KEY`
54
+ 5. `~/.config/adjudicate/config.toml`, shared by every tool
55
+
56
+ (`$XDG_CONFIG_HOME` replaces `~/.config` when set.) Layers merge field by
57
+ field, except that a layer naming a `url` drops the `model`, key and
58
+ `thinking_switch` of the layers below it, since those described another
59
+ server. With no `url` anywhere, a local Ollama is used if it is running with
60
+ a model pulled; otherwise there is no model and callers fall back to
61
+ listing items for review.
62
+
63
+ ```toml
64
+ [llm]
65
+ url = "https://api.example.com/v1" # any OpenAI-compatible base URL
66
+ model = "some-model" # optional: the server's first listed model
67
+ api_key_env = "EXAMPLE_API_KEY" # or api_key = "..."; not both
68
+ timeout = 120 # seconds per request
69
+ thinking_switch = false # true for vLLM/LiteLLM serving a reasoning model
70
+ ```
71
+
72
+ `thinking_switch = true` sends `chat_template_kwargs: {enable_thinking:
73
+ false}`, which vLLM honours and strict APIs reject; if a server answers 400
74
+ or 422 to it, it is dropped for the rest of the run. Unknown keys, wrong
75
+ types, empty strings, a `timeout` that is not positive, and an
76
+ `api_key_env` naming an unset variable are errors (`ConfigError`), so a typo
77
+ is never silently ignored. An `api_key_env` is read only if it survives the
78
+ merge: a higher layer's `url` or key discards it first.
79
+
80
+ ## License
81
+
82
+ The code is MIT (see `LICENSE`). The dictionaries in `adjudicate/dict/`
83
+ keep their own licenses: en_US under the SCOWL notice in
84
+ `LICENSE-en_US.txt`, en_GB under the LGPL as stated in `LICENSE-en_GB.txt`.
@@ -0,0 +1,64 @@
1
+ # adjudicate
2
+
3
+ A small library for one pattern: a detector proposes a span in a document
4
+ and a short list of candidate replacements; a local language model picks
5
+ one of them, or KEEP, from the surrounding sentence and a rule of thumb;
6
+ the applier splices the choice in with case preserved. Every change is a
7
+ bounded, logged, reasoned edit. The model never rewrites prose.
8
+
9
+ It is the toolkit behind [stylefix](https://github.com/AndrewBroz/stylefix)
10
+ (US/UK spelling and style) and [proofix](https://github.com/AndrewBroz/proofix)
11
+ (typos, punctuation, commas). It is published because they depend on it; the
12
+ API may change between 0.x releases.
13
+
14
+ ```
15
+ pip install adjudicate
16
+ ```
17
+
18
+ Modules: `llm` (OpenAI-compatible client and endpoint resolution), `core`
19
+ (Item, Decision, Cache, `adjudicate`), `apply` (Edit, `apply_edits`),
20
+ `context` (sentence context, Markdown prose regions), `dictionary`
21
+ (Hunspell lookups over the bundled en_US and en_GB dictionaries), `setup`
22
+ (`run_setup` and `check_endpoint`, behind the tools' `--setup` and
23
+ `--check-endpoint`).
24
+
25
+ ## Choosing the model endpoint
26
+
27
+ `resolve_endpoint(app, preset="auto", url=None, model=None, key=None,
28
+ think=False, timeout=None)` merges settings from, highest first:
29
+
30
+ 1. its arguments (the calling tool's `--url`, `--model`, `--timeout`)
31
+ 2. `<APP>_LLM_URL`, `<APP>_LLM_MODEL`, `<APP>_LLM_KEY`, e.g. `STYLEFIX_LLM_URL`
32
+ 3. `~/.config/<app>/config.toml`
33
+ 4. `ADJUDICATE_LLM_URL`, `ADJUDICATE_LLM_MODEL`, `ADJUDICATE_LLM_KEY`
34
+ 5. `~/.config/adjudicate/config.toml`, shared by every tool
35
+
36
+ (`$XDG_CONFIG_HOME` replaces `~/.config` when set.) Layers merge field by
37
+ field, except that a layer naming a `url` drops the `model`, key and
38
+ `thinking_switch` of the layers below it, since those described another
39
+ server. With no `url` anywhere, a local Ollama is used if it is running with
40
+ a model pulled; otherwise there is no model and callers fall back to
41
+ listing items for review.
42
+
43
+ ```toml
44
+ [llm]
45
+ url = "https://api.example.com/v1" # any OpenAI-compatible base URL
46
+ model = "some-model" # optional: the server's first listed model
47
+ api_key_env = "EXAMPLE_API_KEY" # or api_key = "..."; not both
48
+ timeout = 120 # seconds per request
49
+ thinking_switch = false # true for vLLM/LiteLLM serving a reasoning model
50
+ ```
51
+
52
+ `thinking_switch = true` sends `chat_template_kwargs: {enable_thinking:
53
+ false}`, which vLLM honours and strict APIs reject; if a server answers 400
54
+ or 422 to it, it is dropped for the rest of the run. Unknown keys, wrong
55
+ types, empty strings, a `timeout` that is not positive, and an
56
+ `api_key_env` naming an unset variable are errors (`ConfigError`), so a typo
57
+ is never silently ignored. An `api_key_env` is read only if it survives the
58
+ merge: a higher layer's `url` or key discards it first.
59
+
60
+ ## License
61
+
62
+ The code is MIT (see `LICENSE`). The dictionaries in `adjudicate/dict/`
63
+ keep their own licenses: en_US under the SCOWL notice in
64
+ `LICENSE-en_US.txt`, en_GB under the LGPL as stated in `LICENSE-en_GB.txt`.
@@ -0,0 +1,18 @@
1
+ """adjudicate: bounded, auditable text edits chosen by a language model.
2
+
3
+ A detector proposes a span and a short list of candidate replacements; the
4
+ model picks one (or KEEP) from the sentence and a rule of thumb; the applier
5
+ splices the choice in. The toolkit behind stylefix and proofix.
6
+ """
7
+
8
+ from .apply import Edit, apply_edits, line_offsets, match_case, to_offsets # noqa: F401
9
+ from .context import (FENCE, FRONT_MATTER, HTML_ENTITY, at_sentence_start, # noqa: F401
10
+ inside_quotes, map_prose, paragraph_bounds,
11
+ prose_regions, protected_spans, sentence_context)
12
+ from .core import (Cache, Decision, Item, adjudicate, decide_batch, parse_response, # noqa: F401
13
+ render_batch, system_prompt, DEFAULT_KEEP_RULE, match_choice)
14
+ from .llm import ConfigError, Endpoint, LLMError, resolve_endpoint, strip_thinking # noqa: F401
15
+ from .setup import check_endpoint, run_setup # noqa: F401
16
+ from .dictionary import known, known_anywhere # noqa: F401
17
+
18
+ __version__ = "0.3.0"
@@ -0,0 +1,71 @@
1
+ """Splice replacements into text at Vale's (line, span) positions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Edit:
10
+ line: int # 1-indexed
11
+ start: int # 1-indexed inclusive column
12
+ end: int # 1-indexed inclusive column
13
+ original: str
14
+ replacement: str
15
+ source: str # check name or "adjudicated"
16
+
17
+
18
+ def match_case(original: str, replacement: str) -> str:
19
+ """Give `replacement` the casing pattern of `original`, unless the
20
+ replacement is already deliberately cased (it contains a capital), as
21
+ with abbreviations and names: "US" -> "United States", not "UNITED
22
+ STATES"; "us" -> "U.S." stays "U.S."."""
23
+ if any(ch.isupper() for ch in replacement):
24
+ return replacement
25
+ if original.isupper() and len(original) > 1:
26
+ return replacement.upper()
27
+ if original[:1].isupper():
28
+ return replacement[:1].upper() + replacement[1:]
29
+ return replacement
30
+
31
+
32
+ def line_offsets(text: str) -> list[int]:
33
+ """Absolute offset of the start of each line (index 0 = line 1)."""
34
+ offsets = [0]
35
+ for i, ch in enumerate(text):
36
+ if ch == "\n":
37
+ offsets.append(i + 1)
38
+ return offsets
39
+
40
+
41
+ def to_offsets(text: str, edit: Edit) -> tuple[int, int]:
42
+ starts = line_offsets(text)
43
+ base = starts[edit.line - 1]
44
+ return base + edit.start - 1, base + edit.end
45
+
46
+
47
+ def apply_edits(text: str, edits: list[Edit]) -> tuple[str, list[Edit]]:
48
+ """Apply edits whose span still contains the expected original text.
49
+
50
+ Returns the new text and the edits that were skipped because the text
51
+ at their position no longer matched.
52
+ """
53
+ skipped: list[Edit] = []
54
+ spans = []
55
+ for e in edits:
56
+ s, t = to_offsets(text, e)
57
+ if text[s:t] != e.original:
58
+ skipped.append(e)
59
+ continue
60
+ spans.append((s, t, e))
61
+ # Apply from the end so earlier offsets stay valid; drop overlaps.
62
+ spans.sort(key=lambda x: (x[0], x[1]), reverse=True)
63
+ out = text
64
+ last_start = len(text) + 1
65
+ for s, t, e in spans:
66
+ if t > last_start:
67
+ skipped.append(e)
68
+ continue
69
+ out = out[:s] + match_case(e.original, e.replacement) + out[t:]
70
+ last_start = s
71
+ return out, skipped
@@ -0,0 +1,139 @@
1
+ """Sentence context, position heuristics, and Markdown prose regions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from dataclasses import dataclass # noqa: F401
8
+
9
+ FENCE = re.compile(r"^(```|~~~)[^\n]*\n.*?^\1[ \t]*$", re.M | re.S)
10
+ _INLINE_CODE = re.compile(r"(`+)[^`\n]*?\1")
11
+ _LINK_DEST = re.compile(r"\]\([^)\s]*(?:\s+\"[^\"]*\")?\)")
12
+ _URL = re.compile(r"<?(?:https?|ftp|mailto):[^\s>)]+>?")
13
+ FRONT_MATTER = re.compile(r"\A---\n.*?\n---\n", re.S)
14
+ _HTML_COMMENT = re.compile(r"<!--.*?-->", re.S)
15
+ _HTML_TAG = re.compile(r"</?[A-Za-z][^<>\n]*>")
16
+ HTML_ENTITY = re.compile(r"&(?:#\d+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]*);")
17
+
18
+ # Pre-0.3 names, kept so an old consumer does not break mid-upgrade; removed in 0.4.
19
+ _FENCE, _FRONT, _HTML_ENTITY = FENCE, FRONT_MATTER, HTML_ENTITY
20
+
21
+
22
+ @dataclass
23
+ class _Unused:
24
+ quotes: int = 0
25
+ apostrophes: int = 0
26
+ dashes: int = 0
27
+ ranges: int = 0
28
+ dates: int = 0
29
+ punctuation_moved: int = 0
30
+
31
+ def as_dict(self) -> dict[str, int]:
32
+ return {k: v for k, v in self.__dict__.items() if v}
33
+
34
+
35
+ def protected_spans(text: str, entities: bool = True) -> list[tuple[int, int]]:
36
+ """Spans no tool should edit: front matter, fenced and inline code, link
37
+ targets, URLs, HTML comments and tags, and (unless entities=False) HTML
38
+ entities."""
39
+ spans = []
40
+ m = FRONT_MATTER.match(text)
41
+ if m:
42
+ spans.append((0, m.end()))
43
+ rxs = [FENCE, _INLINE_CODE, _LINK_DEST, _URL, _HTML_COMMENT, _HTML_TAG]
44
+ if entities:
45
+ rxs.append(HTML_ENTITY)
46
+ for rx in rxs:
47
+ spans.extend(m.span() for m in rx.finditer(text))
48
+ spans.sort()
49
+ merged: list[tuple[int, int]] = []
50
+ for s, e in spans:
51
+ if merged and s <= merged[-1][1]:
52
+ merged[-1] = (merged[-1][0], max(merged[-1][1], e))
53
+ else:
54
+ merged.append((s, e))
55
+ return merged
56
+
57
+
58
+ def prose_regions(text: str) -> list[tuple[int, int]]:
59
+ regions, cursor = [], 0
60
+ for s, e in protected_spans(text):
61
+ if s > cursor:
62
+ regions.append((cursor, s))
63
+ cursor = e
64
+ if cursor < len(text):
65
+ regions.append((cursor, len(text)))
66
+ return regions
67
+
68
+
69
+ def map_prose(text: str, fn) -> str:
70
+ out, cursor = [], 0
71
+ for s, e in prose_regions(text):
72
+ out.append(text[cursor:s])
73
+ out.append(fn(text[s:e]))
74
+ cursor = e
75
+ out.append(text[cursor:])
76
+ return "".join(out)
77
+
78
+
79
+
80
+ _SENT_END = re.compile(r"(?<=[.!?])\s+")
81
+
82
+
83
+ def paragraph_bounds(text: str, pos: int) -> tuple[int, int]:
84
+ start = text.rfind("\n\n", 0, pos)
85
+ start = 0 if start < 0 else start + 2
86
+ end = text.find("\n\n", pos)
87
+ end = len(text) if end < 0 else end
88
+ return start, end
89
+
90
+
91
+ def sentence_context(text: str, start: int, end: int, radius: int = 1,
92
+ max_chars: int = 400) -> str:
93
+ """The sentence containing [start, end) plus `radius` neighbours, with
94
+ the target span marked as [[...]]."""
95
+ ps, pe = paragraph_bounds(text, start)
96
+ para = text[ps:pe]
97
+ rel_s, rel_e = start - ps, end - ps
98
+ pieces, cursor = [], 0
99
+ for m in _SENT_END.finditer(para):
100
+ pieces.append((cursor, m.start()))
101
+ cursor = m.end()
102
+ pieces.append((cursor, len(para)))
103
+ idx = next((i for i, (a, b) in enumerate(pieces) if a <= rel_s < b), len(pieces) - 1)
104
+ lo = pieces[max(0, idx - radius)][0]
105
+ hi = pieces[min(len(pieces) - 1, idx + radius)][1]
106
+ snippet = para[lo:rel_s] + "[[" + para[rel_s:rel_e] + "]]" + para[rel_e:hi]
107
+ snippet = re.sub(r"\s+", " ", snippet).strip()
108
+ if len(snippet) > max_chars:
109
+ mark = snippet.find("[[")
110
+ a = max(0, mark - max_chars // 2)
111
+ snippet = ("…" if a else "") + snippet[a:a + max_chars] + "…"
112
+ return snippet
113
+
114
+
115
+ # Brackets are not sentence starts: "[Center for X](url)" and "(Centre for Y)"
116
+ # keep their capital because of the name, so the word is adjudicated.
117
+ _SENTENCE_START_PUNCT = set('.!?:"“‘\'')
118
+
119
+
120
+ def at_sentence_start(text: str, pos: int) -> bool:
121
+ """True when a capital at `pos` is explained by position, not by being a name."""
122
+ line_start = text.rfind("\n", 0, pos) + 1
123
+ before = text[line_start:pos].rstrip()
124
+ if not before:
125
+ return True
126
+ # Markdown markers: headings, list bullets, numbered items, blockquotes.
127
+ if re.fullmatch(r"(#{1,6}|[-*+]|\d+[.)]|>|\|)", before):
128
+ return True
129
+ return before[-1] in _SENTENCE_START_PUNCT
130
+
131
+
132
+ def inside_quotes(text: str, pos: int) -> bool:
133
+ """Cheap check: an odd number of opening quotes precede `pos` in the paragraph."""
134
+ ps, _ = paragraph_bounds(text, pos)
135
+ seg = text[ps:pos]
136
+ opens = seg.count("“") + seg.count("‘") + len(re.findall(r'(?:^|\s)["\']', seg))
137
+ # `seg` ends where the word starts, so a quote at its very end is an opener.
138
+ closes = seg.count("”") + seg.count("’") + len(re.findall(r'["\'](?=[\s.,;:!?)])', seg))
139
+ return opens > closes
@@ -0,0 +1,206 @@
1
+ """Ask a model to choose between supplied candidates for marked spans.
2
+
3
+ The contract: each item carries a short list of options (the last usually
4
+ KEEP), a rule of thumb, and the sentence with the span marked [[...]].
5
+ The model picks one option per item. Anything else is rejected."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import sys
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass, field, asdict
15
+ from pathlib import Path
16
+
17
+ from .llm import Endpoint, LLMError
18
+
19
+ SYSTEM_TEMPLATE = """{task}
20
+ For each item, choose exactly one option from its numbered list. {keep_rule}
21
+ Answer with the option's number (or the word KEEP); never retype or paraphrase the option text.
22
+ Respond with JSON only, in this shape:
23
+ {{"decisions": [{{"id": 1, "choice": "<option number, or KEEP>", "confidence": 0.0, "reason": "<under 12 words>"}}]}}"""
24
+
25
+ DEFAULT_KEEP_RULE = ("Choose KEEP when the marked text is already correct in context, is part of a "
26
+ "proper name, a title, a quotation, a URL or code, or when no option fits.")
27
+
28
+
29
+ def system_prompt(task: str, keep_rule: str = DEFAULT_KEEP_RULE) -> str:
30
+ return SYSTEM_TEMPLATE.format(task=task, keep_rule=keep_rule)
31
+
32
+
33
+ @dataclass
34
+ class Item:
35
+ id: int
36
+ word: str
37
+ options: tuple[str, ...]
38
+ rule: str
39
+ context: str
40
+ line: int
41
+ start: int
42
+ end: int
43
+ source: str = "ambiguous" # caller-defined tag for reporting
44
+
45
+
46
+ @dataclass
47
+ class Decision:
48
+ item: Item
49
+ choice: str | None # None = could not decide
50
+ confidence: float
51
+ reason: str
52
+ cached: bool = False
53
+
54
+ @property
55
+ def replacement(self) -> str | None:
56
+ """The text to substitute, or None to leave the word alone."""
57
+ if self.choice is None or self.choice == "KEEP":
58
+ return None
59
+ if self.choice.lower() == self.item.word.lower():
60
+ return None
61
+ return self.choice
62
+
63
+
64
+ class Cache:
65
+ def __init__(self, path: Path | None):
66
+ self.path = path
67
+ self.data: dict[str, dict] = {}
68
+ if path and path.exists():
69
+ try:
70
+ self.data = json.loads(path.read_text())
71
+ except json.JSONDecodeError:
72
+ self.data = {}
73
+
74
+ @staticmethod
75
+ def key(scope: str, item: Item) -> str:
76
+ """scope: anything that changes the right answer (target variety, task)."""
77
+ norm = re.sub(r"\s+", " ", item.context).strip().lower()
78
+ h = hashlib.sha256(f"{scope}|{item.word.lower()}|{'|'.join(item.options)}|{norm}".encode()).hexdigest()
79
+ return h[:24]
80
+
81
+ def get(self, k: str) -> dict | None:
82
+ return self.data.get(k)
83
+
84
+ def put(self, k: str, d: Decision) -> None:
85
+ self.data[k] = {"word": d.item.word, "choice": d.choice,
86
+ "confidence": d.confidence, "reason": d.reason,
87
+ "context": d.item.context}
88
+
89
+ def save(self) -> None:
90
+ if self.path:
91
+ self.path.parent.mkdir(parents=True, exist_ok=True)
92
+ self.path.write_text(json.dumps(self.data, indent=1, ensure_ascii=False))
93
+
94
+
95
+ def render_batch(items: list[Item]) -> str:
96
+ parts = []
97
+ for it in items:
98
+ opts = " | ".join(o if o == "KEEP" else f"[{n}] {o}" for n, o in enumerate(it.options, 1))
99
+ parts.append(
100
+ f"Item {it.id} — word: \"{it.word}\" — options: {opts}\n"
101
+ f"Rule: {it.rule}\n"
102
+ f"Context: {it.context}")
103
+ return "\n\n".join(parts)
104
+
105
+
106
+ def match_choice(choice: str, options: tuple[str, ...]) -> str | None:
107
+ """Resolve the model's answer to one option: by number ("2", "[2]"),
108
+ by the word KEEP, or by the option text (trimmed, case-insensitive)."""
109
+ c = str(choice).strip()
110
+ m = re.fullmatch(r"\[?(\d+)\]?", c)
111
+ if m:
112
+ n = int(m.group(1))
113
+ return options[n - 1] if 1 <= n <= len(options) else None
114
+ if c.upper() == "KEEP" and "KEEP" in options:
115
+ return "KEEP"
116
+ c = re.sub(r"^\[\d+\]\s*", "", c)
117
+ return next((o for o in options if o.strip().lower() == c.lower()), None)
118
+
119
+
120
+ def parse_response(text: str) -> dict[int, dict]:
121
+ """Return {id: {choice, confidence, reason}} from the model's reply."""
122
+ text = text.strip()
123
+ text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.S)
124
+ m = re.search(r"[\[{]", text)
125
+ if not m:
126
+ return {}
127
+ try:
128
+ data = json.loads(text[m.start():])
129
+ except json.JSONDecodeError:
130
+ # Try to salvage the largest balanced object.
131
+ end = max(text.rfind("}"), text.rfind("]"))
132
+ try:
133
+ data = json.loads(text[m.start():end + 1])
134
+ except json.JSONDecodeError:
135
+ return {}
136
+ if isinstance(data, dict):
137
+ data = data.get("decisions", data.get("items", []))
138
+ out = {}
139
+ for d in data if isinstance(data, list) else []:
140
+ if isinstance(d, dict) and "id" in d:
141
+ try:
142
+ out[int(d["id"])] = d
143
+ except (TypeError, ValueError):
144
+ continue
145
+ return out
146
+
147
+
148
+ def decide_batch(ep: Endpoint, system: str, items: list[Item]) -> list[Decision]:
149
+ try:
150
+ reply = ep.chat(system, render_batch(items))
151
+ except LLMError as e:
152
+ return [Decision(it, None, 0.0, f"model error: {e}") for it in items]
153
+ answers = parse_response(reply)
154
+ out = []
155
+ for it in items:
156
+ a = answers.get(it.id)
157
+ if not a:
158
+ out.append(Decision(it, None, 0.0, "no answer from model"))
159
+ continue
160
+ choice = str(a.get("choice", "")).strip()
161
+ matched = match_choice(choice, it.options)
162
+ try:
163
+ conf = float(a.get("confidence", 0))
164
+ except (TypeError, ValueError):
165
+ conf = 0.0
166
+ reason = str(a.get("reason", ""))[:200]
167
+ if matched is None:
168
+ out.append(Decision(it, None, 0.0, f"invalid choice {choice!r}"))
169
+ else:
170
+ out.append(Decision(it, matched, conf, reason))
171
+ return out
172
+
173
+
174
+ def adjudicate(ep: Endpoint | None, scope: str, items: list[Item], cache: Cache,
175
+ batch_size: int = 20, parallel: int = 4, verbose: bool = False,
176
+ system: str | None = None, log_name: str = "adjudicate") -> list[Decision]:
177
+ """Decide every item: from cache where possible, otherwise from the model
178
+ in parallel batches. `scope` keys the cache; `system` is the task prompt
179
+ (see system_prompt)."""
180
+ system = system or system_prompt("You are a meticulous copy editor.")
181
+ decisions: dict[int, Decision] = {}
182
+ todo: list[Item] = []
183
+ for it in items:
184
+ k = Cache.key(scope, it)
185
+ hit = cache.get(k)
186
+ if hit and hit.get("choice") in it.options:
187
+ decisions[it.id] = Decision(it, hit["choice"], float(hit.get("confidence", 1.0)),
188
+ hit.get("reason", ""), cached=True)
189
+ else:
190
+ todo.append(it)
191
+ if todo and ep is None:
192
+ for it in todo:
193
+ decisions[it.id] = Decision(it, None, 0.0, "no model configured")
194
+ elif todo:
195
+ batches = [todo[i:i + batch_size] for i in range(0, len(todo), batch_size)]
196
+ if verbose:
197
+ print(f"{log_name}: asking {ep.name} ({ep.model}) about {len(todo)} items "
198
+ f"in {len(batches)} batch(es), {parallel} in parallel", file=sys.stderr)
199
+ with ThreadPoolExecutor(max_workers=max(1, parallel)) as pool:
200
+ for result in pool.map(lambda b: decide_batch(ep, system, b), batches):
201
+ for d in result:
202
+ decisions[d.item.id] = d
203
+ if d.choice is not None:
204
+ cache.put(Cache.key(scope, d.item), d)
205
+ cache.save()
206
+ return [decisions[it.id] for it in items]