proofix 0.5.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.
proofix/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """proofix: typos and punctuation, fixed under audit."""
2
+
3
+ __version__ = "0.5.0"
proofix/cli.py ADDED
@@ -0,0 +1,328 @@
1
+ """Command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import difflib
7
+ import math
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from adjudicate import Cache, ConfigError, Edit, adjudicate, apply_edits, check_endpoint, resolve_endpoint, run_setup, system_prompt
15
+
16
+ from . import __version__, report
17
+ from .commas import CommaCache, correct_commas
18
+ from .lt import LTError, find_languagetool, run_languagetool
19
+ from . import consistency, hygiene
20
+ from .tiers import partition, typos_edits
21
+
22
+ DESCRIPTION = """\
23
+ Fix typos and punctuation in a Markdown file, under audit.
24
+
25
+ Five tiers, each bounded so every change is one logged, reasoned edit:
26
+
27
+ 0. Hygiene: HTML entities Markdown can carry plainly, zero-width spaces,
28
+ byte-order marks, soft hyphens, control characters, Windows line endings
29
+ and decomposed accents are fixed deterministically.
30
+ 1. Mechanical faults with one right answer are fixed outright: a space before a
31
+ comma or period, a missing space after one, doubled punctuation, doubled
32
+ words. Detected by LanguageTool (and, if installed, the `typos` tool for
33
+ known misspellings).
34
+ 2. Adjudicated: LanguageTool proposes spellings or grammar fixes, and a
35
+ language model picks one or KEEP from the sentence and the rule's message.
36
+ 3. Compounds and consistency: the model chooses closed, hyphenated or open
37
+ for compounds the dictionaries question ("cyber attack", "non-profit"),
38
+ and one form for terms the document capitalises two ways.
39
+ 4. Commas: comma placement, which no rule engine detects, is corrected
40
+ sentence by sentence by the model under a diff guard: only insertions and
41
+ deletions of commas are accepted; anything else the model returns is
42
+ rejected and reported.
43
+
44
+ Without a model, tiers 0 and 1 still apply; tier 2's fixes are listed for
45
+ review, tier 3 is skipped and sentences are not checked for commas.
46
+
47
+ Code spans, fenced code, link targets, URLs, HTML and front matter are never
48
+ touched. Run it after stylefix so LanguageTool sees the target variety.
49
+ """
50
+
51
+ EPILOG = """\
52
+ examples:
53
+ proofix --setup # choose, test and save a model endpoint
54
+ proofix draft.md # -> draft.proofed.md, US English
55
+ proofix draft.md --target uk -o out.md
56
+ proofix draft.md --changes # list every change as it is made
57
+ proofix draft.md --no-commas --llm none
58
+ LanguageTool's mechanical fixes only; nothing needs a model
59
+
60
+ Requires languagetool on PATH for tier 1's LanguageTool fixes and for tier 2
61
+ (brew install languagetool); tiers 3 and 4 need only a model. Accept lists:
62
+ --accept, ~/.config/proofix/accept.txt, ~/.config/stylefix/accept.txt,
63
+ ./.stylefix/accept.txt and ./.proofix/accept.txt are all honoured.
64
+
65
+ environment:
66
+ PROOFIX_LLM_URL, PROOFIX_LLM_MODEL, PROOFIX_LLM_KEY
67
+ the model endpoint for proofix (same as --url, --model, and the API key)
68
+ ADJUDICATE_LLM_URL, ADJUDICATE_LLM_MODEL, ADJUDICATE_LLM_KEY
69
+ the same, shared with stylefix; below the proofix variables and config
70
+ OLLAMA_HOST Ollama base URL (default http://localhost:11434)
71
+
72
+ files:
73
+ ~/.config/proofix/config.toml [llm] url, model, api_key or api_key_env,
74
+ timeout, thinking_switch (proofix only)
75
+ ~/.config/adjudicate/config.toml the same table, shared with stylefix
76
+
77
+ Full documentation: https://github.com/AndrewBroz/proofix (man page:
78
+ man/proofix.1 in the source)
79
+ """
80
+
81
+ TASK = "You are a meticulous copy editor fixing typos and punctuation in a document written in {variety}."
82
+ VARIETY = {"us": "American English", "uk": "British English"}
83
+
84
+
85
+ def positive_seconds(text: str) -> float:
86
+ """argparse type for --timeout: a positive, finite number of seconds."""
87
+ try:
88
+ value = float(text)
89
+ except ValueError:
90
+ raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
91
+ if not (math.isfinite(value) and value > 0):
92
+ raise argparse.ArgumentTypeError(f"must be a positive number of seconds, not {text}")
93
+ return value
94
+
95
+
96
+ def build_parser() -> argparse.ArgumentParser:
97
+ p = argparse.ArgumentParser(prog="proofix", description=DESCRIPTION, epilog=EPILOG,
98
+ formatter_class=argparse.RawDescriptionHelpFormatter)
99
+ p.add_argument("input", type=Path, nargs="?", help="Markdown file to proofread")
100
+ p.add_argument("-o", "--output", type=Path, metavar="FILE",
101
+ help="default: <input>.proofed.md")
102
+ p.add_argument("-i", "--in-place", action="store_true", help="overwrite the input file")
103
+ p.add_argument("--target", choices=["us", "uk"], default="us",
104
+ help="English variety the document is written in (default: %(default)s)")
105
+ p.add_argument("--accept", type=Path, metavar="FILE",
106
+ help="extra words to never flag as misspelled, one per line")
107
+ p.add_argument("--no-lt", action="store_true",
108
+ help="skip LanguageTool: its tier 1 fixes (typos still runs) and all of tier 2")
109
+ p.add_argument("--lt-level", choices=["default", "picky"], default="default",
110
+ help="LanguageTool rule level (default: %(default)s)")
111
+ p.add_argument("--no-typos", action="store_true",
112
+ help="skip the `typos` pre-pass even if installed")
113
+ p.add_argument("--no-hygiene", action="store_true",
114
+ help="skip text hygiene (HTML entities, invisible characters, line endings, "
115
+ "decomposed accents)")
116
+ p.add_argument("--no-consistency", action="store_true",
117
+ help="skip the compounds and consistency tier (closed, hyphenated or open "
118
+ "compounds; terms capitalised two ways)")
119
+ p.add_argument("--style", default="none", metavar="GUIDE",
120
+ help="style guide, only for its hyphenation habit (ap hyphenates less); "
121
+ "chicago, gpo, ap, apa, oxford, guardian or none")
122
+ p.add_argument("--commas", choices=["gated", "all", "none"], default="gated",
123
+ help="comma tier: gated sends only sentences with a comma, an introductory "
124
+ "opener, a clause trigger or a conjunction joining clauses; all sends every "
125
+ "sentence; none skips it (default: %(default)s)")
126
+ p.add_argument("--no-commas", action="store_true", help="same as --commas none")
127
+ p.add_argument("--max-sentences", type=int, metavar="N",
128
+ help="cap on sentences sent to the comma tier (the rest are reported as unchecked)")
129
+ p.add_argument("--optional-commas", choices=["leave", "add"], default="leave",
130
+ help="what the comma tier does with commas that are optional by convention "
131
+ "(default: %(default)s)")
132
+ p.add_argument("--llm", choices=["auto", "ollama", "none"], default="auto", help="auto: the endpoint from --url, PROOFIX_LLM_*, ~/.config/proofix/config.toml, ADJUDICATE_LLM_* or ~/.config/adjudicate/config.toml, else local Ollama; ollama: local Ollama only; none: no model, so tier 2's fixes are listed for review, tier 3 is skipped and commas are not checked (default: %(default)s)")
133
+ p.add_argument("--url", metavar="URL",
134
+ help="OpenAI-compatible base URL ending in /v1; overrides every configured "
135
+ "url. A --url or PROOFIX_LLM_URL drops keys set in lower layers; set "
136
+ "PROOFIX_LLM_KEY (or api_key_env in the same config file) alongside it")
137
+ p.add_argument("--model", metavar="NAME")
138
+ p.add_argument("--min-confidence", type=float, default=0.8, metavar="X")
139
+ p.add_argument("--parallel", type=int, default=4, metavar="N")
140
+ p.add_argument("--timeout", type=positive_seconds, default=None, metavar="SEC", help="seconds per model request (default: 120, or timeout in config.toml)")
141
+ c = p.add_argument_group("configuration", "set up or check the model endpoint, then exit")
142
+ c.add_argument("--setup", action="store_true",
143
+ help="choose a model endpoint interactively, test it, and save it to "
144
+ "~/.config/adjudicate/config.toml or ~/.config/proofix/config.toml")
145
+ c.add_argument("--check-endpoint", action="store_true",
146
+ help="show the endpoint proofix would use and where each setting comes "
147
+ "from, then test it; exit 0 if it answers, 1 if not, 2 on a config error")
148
+ p.add_argument("--cache", type=Path, default=Path(".proofix/cache.json"), metavar="FILE")
149
+ p.add_argument("--no-cache", action="store_true")
150
+ p.add_argument("--report", type=Path, metavar="FILE", help="default: <output>.report.md")
151
+ p.add_argument("--no-report", action="store_true")
152
+ p.add_argument("--dry-run", action="store_true", help="print a diff, write nothing")
153
+ p.add_argument("--changes", action="store_true", help="print every change to stderr")
154
+ p.add_argument("-v", "--verbose", action="store_true")
155
+ p.add_argument("--version", action="version", version=f"proofix {__version__}")
156
+ return p
157
+
158
+
159
+ def load_accept(explicit: Path | None) -> set[str]:
160
+ cfg = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
161
+ paths = [cfg / "proofix" / "accept.txt", cfg / "stylefix" / "accept.txt",
162
+ Path(".stylefix") / "accept.txt", Path(".proofix") / "accept.txt"]
163
+ if explicit:
164
+ paths.append(explicit)
165
+ words: set[str] = set()
166
+ for path in paths:
167
+ if path.exists():
168
+ for line in path.read_text(encoding="utf-8").splitlines():
169
+ line = line.strip()
170
+ if line and not line.startswith("#"):
171
+ words.add(line.lower())
172
+ return words
173
+
174
+
175
+ def run_typos(text: str) -> list[Edit]:
176
+ """Known-misspelling fixes from the `typos` tool, as edits, if installed."""
177
+ exe = shutil.which("typos")
178
+ if not exe:
179
+ return []
180
+ proc = subprocess.run([exe, "--format", "json", "-"], input=text, capture_output=True, text=True)
181
+ return typos_edits(text, proc.stdout)
182
+
183
+
184
+ def main(argv: list[str] | None = None) -> int:
185
+ parser = build_parser()
186
+ args = parser.parse_args(argv)
187
+ if args.setup:
188
+ return run_setup("proofix")
189
+ if args.check_endpoint:
190
+ return check_endpoint("proofix", preset=args.llm, url=args.url, model=args.model,
191
+ timeout=args.timeout)
192
+ if args.input is None:
193
+ parser.error("the following arguments are required: input")
194
+ if not args.input.exists():
195
+ print(f"proofix: no such file: {args.input}", file=sys.stderr)
196
+ return 2
197
+ output = args.input if args.in_place else (
198
+ args.output or args.input.with_name(args.input.stem + ".proofed" + (args.input.suffix or ".md")))
199
+ report_path = args.report or output.with_suffix(output.suffix + ".report.md")
200
+ text = args.input.read_text(encoding="utf-8")
201
+ accept = load_accept(args.accept)
202
+ try:
203
+ ep = resolve_endpoint("proofix", args.llm, url=args.url, model=args.model,
204
+ timeout=args.timeout)
205
+ except ConfigError as e:
206
+ print(f"proofix: {e}", file=sys.stderr)
207
+ return 2
208
+ if ep is None and args.llm == "auto":
209
+ print("proofix: no model endpoint found; adjudicated fixes (tier 2) will be listed for "
210
+ "review, not applied, compounds (tier 3) skipped and commas (tier 4) not checked. "
211
+ "Run `proofix --setup` to configure one.", file=sys.stderr)
212
+
213
+ original = text
214
+ hygiene_notes: list[str] = []
215
+ edits: list[Edit] = []
216
+ if not args.no_hygiene:
217
+ text, hygiene_notes = hygiene.normalize_text(text)
218
+ h_edits, h_notes = hygiene.plan(text)
219
+ edits += h_edits
220
+ hygiene_notes += [f"L{n.line} {n.message}" for n in h_notes]
221
+ edits += [] if args.no_typos else run_typos(text)
222
+ typos_fixed = sum(1 for e in edits if e.source == "typos")
223
+ decisions = []
224
+ review = []
225
+ lt = find_languagetool() if not args.no_lt else None
226
+ if not args.no_lt and not lt:
227
+ print("proofix: languagetool not found; skipping its tier 1 fixes and tier 2 "
228
+ "(brew install languagetool)", file=sys.stderr)
229
+ # Pass 1: mechanical faults (typos, whitespace, doubled punctuation), applied.
230
+ if lt:
231
+ try:
232
+ matches = run_languagetool(text, args.target, args.lt_level)
233
+ except LTError as e:
234
+ print(f"proofix: {e}", file=sys.stderr)
235
+ return 2
236
+ edits += partition(text, matches, accept, adjudicate_available=False).edits
237
+ text, skipped = apply_edits(text, edits)
238
+ applied = [e for e in edits if e not in skipped]
239
+ # Pass 2: judgement calls on the cleaned text, so spans no longer collide.
240
+ if lt:
241
+ try:
242
+ matches = run_languagetool(text, args.target, args.lt_level)
243
+ except LTError as e:
244
+ print(f"proofix: {e}", file=sys.stderr)
245
+ return 2
246
+ part = partition(text, matches, accept, adjudicate_available=ep is not None)
247
+ edits = [e for e in part.edits if e.source == "MISSING_SPACE"] # anything pass 1 exposed
248
+ review = part.review
249
+ else:
250
+ from .tiers import Partition
251
+ part = Partition([], [], [])
252
+ edits = []
253
+ if True:
254
+ if True:
255
+ if part.items:
256
+ cache = Cache(None if args.no_cache else args.cache)
257
+ decisions = adjudicate(ep, f"proofix|{args.target}", part.items, cache,
258
+ parallel=args.parallel, verbose=args.verbose,
259
+ system=system_prompt(TASK.format(variety=VARIETY[args.target])),
260
+ log_name="proofix")
261
+ for d in decisions:
262
+ if d.choice is not None and d.confidence < args.min_confidence:
263
+ d.reason, d.choice = f"low confidence ({d.confidence:.2f}): {d.reason}", None
264
+ if d.replacement:
265
+ edits.append(Edit(d.item.line, d.item.start, d.item.end, d.item.word,
266
+ d.replacement, "adjudicated"))
267
+ new_text, skipped = apply_edits(text, edits)
268
+ applied += [e for e in edits if e not in skipped]
269
+ text = new_text
270
+ # Pass 3: compounds and consistency, on the text as it now stands.
271
+ if not args.no_consistency and ep is not None:
272
+ c_items, groups = consistency.items(text, args.target, args.style)
273
+ if c_items:
274
+ cache = Cache(None if args.no_cache else args.cache)
275
+ c_decisions = adjudicate(ep, f"proofix|{args.target}", c_items, cache,
276
+ parallel=args.parallel, verbose=args.verbose,
277
+ system=system_prompt(TASK.format(variety=VARIETY[args.target])),
278
+ log_name="proofix")
279
+ c_edits = []
280
+ for d in c_decisions:
281
+ if d.choice is not None and d.confidence < args.min_confidence:
282
+ d.reason, d.choice = f"low confidence ({d.confidence:.2f}): {d.reason}", None
283
+ if not d.replacement:
284
+ continue
285
+ for o in groups[d.item.id]:
286
+ line = text.count("\n", 0, o.start) + 1
287
+ col = o.start - (text.rfind("\n", 0, o.start) + 1) + 1
288
+ c_edits.append(Edit(line, col, col + (o.end - o.start) - 1, o.form,
289
+ consistency._recase(o.form, d.replacement.lower()), "adjudicated"))
290
+ decisions += c_decisions
291
+ new_text, skipped = apply_edits(text, c_edits)
292
+ applied += [e for e in c_edits if e not in skipped]
293
+ text = new_text
294
+ new_text = text
295
+
296
+ comma_results = []
297
+ if not args.no_commas and args.commas != "none":
298
+ ccache = CommaCache(None if args.no_cache else args.cache.with_name("commas.json"))
299
+ cedits, comma_results = correct_commas(new_text, args.target, ep, ccache,
300
+ args.optional_commas, args.min_confidence,
301
+ parallel=args.parallel, verbose=args.verbose,
302
+ gate=args.commas == "gated",
303
+ max_sentences=args.max_sentences)
304
+ final, cskipped = apply_edits(new_text, cedits)
305
+ applied += [e for e in cedits if e not in cskipped]
306
+ new_text = final
307
+
308
+ if args.dry_run:
309
+ sys.stdout.writelines(difflib.unified_diff(
310
+ original.splitlines(keepends=True), new_text.splitlines(keepends=True),
311
+ str(args.input), str(output)))
312
+ else:
313
+ output.write_text(new_text, encoding="utf-8")
314
+ log = report.changelog(original, applied, decisions, comma_results, review, typos_fixed,
315
+ hygiene_notes)
316
+ if args.changes or args.verbose:
317
+ sys.stderr.write(log)
318
+ if not args.no_report and not args.dry_run:
319
+ report_path.write_text("# proofix report\n\n```\n" + log + "```\n", encoding="utf-8")
320
+ n_commas = sum(1 for r in comma_results if r.status == "edit")
321
+ print(f"proofix: {len(applied)} edit(s) ({n_commas} comma sentence(s)), "
322
+ f"{sum(1 for d in decisions if d.choice is None)} undecided, {len(review)} for review"
323
+ + ("" if args.dry_run else f" -> {output}"), file=sys.stderr)
324
+ return 0
325
+
326
+
327
+ if __name__ == "__main__":
328
+ sys.exit(main())
proofix/commas.py ADDED
@@ -0,0 +1,325 @@
1
+ """Constrained rewrite tier: the model corrects comma placement sentence by
2
+ sentence, and a diff guard admits only insertions and deletions of commas.
3
+ Anything else the model returns is rejected and reported."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import difflib
8
+ import hashlib
9
+ import json
10
+ import re
11
+ import sys
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ from adjudicate import FENCE, FRONT_MATTER, Edit, Endpoint, LLMError, protected_spans, strip_thinking
17
+
18
+ VARIETY = {"us": "American English", "uk": "British English"}
19
+
20
+ SYSTEM = """You are a copy editor checking comma placement only, in {variety}.
21
+ For each numbered sentence, decide whether a comma is missing or misplaced. You may ONLY insert or delete commas.
22
+ Never change, add, or remove any other character, including other punctuation and spacing.
23
+ {optional}
24
+ Never add or remove a serial (Oxford) comma before the last item of a list; that is a style choice made elsewhere.
25
+ Do not "fix" a comma splice by changing punctuation; instead list it under "notes".
26
+ Return ONLY the sentences you changed. Respond with JSON only:
27
+ {{"changed": [{{"id": 1, "text": "<corrected sentence>", "confidence": 0.0, "reason": "<under 12 words>"}}],
28
+ "notes": [{{"id": 2, "reason": "<e.g. comma splice; needs a semicolon or period>"}}]}}"""
29
+
30
+ OPTIONAL = {
31
+ "leave": "Where a comma is optional by convention (after a short introductory phrase, before "
32
+ "a short final clause), leave the sentence unchanged.",
33
+ "add": "Where a comma is optional by convention, add it: this style guide prefers commas "
34
+ "after introductory phrases and before coordinating conjunctions joining clauses.",
35
+ }
36
+
37
+ _SENT_END = re.compile(r"(?<=[.!?])[\"”’)]*\s+(?=[A-Z“\"(])")
38
+ _MD_LINE = re.compile(r"^(\s*(?:#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s?|\|))")
39
+
40
+ # The gate: a sentence goes to the model only if a comma question can arise.
41
+ _OPENER = re.compile(r"^(?:After|Although|As|Because|Before|By|Despite|During|For|From|If|In|On|Once|"
42
+ r"Since|Though|Through|Unless|Until|When|Whenever|Where|Whereas|While|With|Without|"
43
+ r"Given|Having|Following|Under|Over|To|At|Like|Unlike|Beyond|Within|Throughout|"
44
+ r"According|Instead|Rather|First|Second|Third|Finally|Meanwhile|Today|Yesterday|"
45
+ r"Tomorrow|Now|Then|Thus|Hence|So|Yet|But|And|Or|Nor|However|Therefore|Moreover|"
46
+ r"Furthermore|Nevertheless|Nonetheless|Consequently|Otherwise|Indeed|Still|Also|"
47
+ r"Additionally|Importantly|Notably|Ultimately|Overall)\b")
48
+ _TRIGGER = re.compile(r"\b(?:which|however|therefore|moreover|for example|for instance|such as|including|"
49
+ r"e\.g\.|i\.e\.|said|says|asked|replied|added|noted|argued|wrote)\b|[\"“”]", re.I)
50
+ _CLAUSE_JOIN = re.compile(r"\b(?:and|but|so|yet|or|nor)\b", re.I)
51
+
52
+
53
+ def needs_check(sentence: str) -> bool:
54
+ """True if the sentence contains a comma, an introductory opener, a
55
+ clause-level trigger, or two clauses joined by a conjunction."""
56
+ if "," in sentence:
57
+ return True
58
+ if _OPENER.match(sentence):
59
+ return True
60
+ if _TRIGGER.search(sentence):
61
+ return True
62
+ words = len(sentence.split())
63
+ return words >= 12 and bool(_CLAUSE_JOIN.search(sentence))
64
+
65
+
66
+ PLACEHOLDER = "⟨…⟩"
67
+
68
+
69
+ @dataclass
70
+ class Sentence:
71
+ start: int # absolute offset of the first character
72
+ end: int
73
+ text: str # what the model sees: protected spans as PLACEHOLDER
74
+ segments: list[tuple[int, int, int]] = field(default_factory=list)
75
+ # (display_start, display_end, absolute_start) for each real-text run
76
+
77
+ def to_absolute(self, display_pos: int) -> int | None:
78
+ """Absolute offset for a position in `text`; None inside a placeholder."""
79
+ for ds, de, a in self.segments:
80
+ if ds <= display_pos <= de:
81
+ return a + (display_pos - ds)
82
+ return None
83
+
84
+
85
+ @dataclass
86
+ class CommaResult:
87
+ sentence: Sentence
88
+ new_text: str | None # None = unchanged / no answer
89
+ confidence: float
90
+ reason: str
91
+ status: str # unchanged | edit | rejected | low | note | error
92
+
93
+
94
+ def _paragraphs(text: str):
95
+ """(start, end) of paragraphs outside front matter and fenced code."""
96
+ blocked = []
97
+ m = FRONT_MATTER.match(text)
98
+ if m:
99
+ blocked.append((0, m.end()))
100
+ blocked += [m.span() for m in FENCE.finditer(text)]
101
+ cursor = 0
102
+ for bs, be in sorted(blocked):
103
+ if bs > cursor:
104
+ yield from _split_paras(text, cursor, bs)
105
+ cursor = be
106
+ if cursor < len(text):
107
+ yield from _split_paras(text, cursor, len(text))
108
+
109
+
110
+ def _split_paras(text: str, lo: int, hi: int):
111
+ for pm in re.finditer(r"[^\n]+(?:\n[^\n]+)*", text[lo:hi]):
112
+ yield lo + pm.start(), lo + pm.end()
113
+
114
+
115
+ def _display(text: str, a: int, b: int, spans: list[tuple[int, int]]) -> tuple[str, list[tuple[int, int, int]]]:
116
+ """Text of [a, b) with protected spans replaced by PLACEHOLDER, plus the
117
+ segment map back to absolute offsets."""
118
+ out, segs, cursor = [], [], a
119
+ dpos = 0
120
+ for s_, e_ in spans:
121
+ if e_ <= a or s_ >= b:
122
+ continue
123
+ s_, e_ = max(s_, a), min(e_, b)
124
+ if s_ > cursor:
125
+ piece = text[cursor:s_]
126
+ segs.append((dpos, dpos + len(piece), cursor))
127
+ out.append(piece); dpos += len(piece)
128
+ out.append(PLACEHOLDER); dpos += len(PLACEHOLDER)
129
+ cursor = e_
130
+ if cursor < b:
131
+ piece = text[cursor:b]
132
+ segs.append((dpos, dpos + len(piece), cursor))
133
+ out.append(piece)
134
+ return "".join(out), segs
135
+
136
+
137
+ def sentences(text: str, min_words: int = 4, gate: bool = True) -> list[Sentence]:
138
+ """Sentences in prose, excluding table rows and headings, with protected
139
+ spans (code, link targets, URLs, HTML) shown as a placeholder so a link
140
+ does not split its sentence. With gate, only sentences where a comma
141
+ question can arise."""
142
+ spans = protected_spans(text)
143
+ out: list[Sentence] = []
144
+ for ps, pe in _paragraphs(text):
145
+ first = text[ps:pe].split("\n", 1)[0]
146
+ if first.lstrip().startswith(("#", "|")):
147
+ continue
148
+ display, segs = _display(text, ps, pe, spans)
149
+ cursor = 0
150
+ pieces = []
151
+ for m in _SENT_END.finditer(display):
152
+ pieces.append((cursor, m.start() + len(m.group(0).rstrip())))
153
+ cursor = m.end()
154
+ pieces.append((cursor, len(display)))
155
+ for a, b in pieces:
156
+ raw = display[a:b]
157
+ lead = len(raw) - len(raw.lstrip())
158
+ body = raw.strip()
159
+ if not body:
160
+ continue
161
+ words = len(body.replace(PLACEHOLDER, "").split())
162
+ if words < min_words or (gate and not needs_check(body)):
163
+ continue
164
+ da, db = a + lead, a + lead + len(body)
165
+ # segments clipped to this sentence, re-based to its display text
166
+ fixed = []
167
+ for ds, de, ab in [(ds - da, de - da, ab) for ds, de, ab in segs if de > da and ds < db]:
168
+ clip = max(ds, 0)
169
+ fixed.append((clip, min(de, len(body)), ab + (clip - ds)))
170
+ abs_start = fixed[0][2] if fixed else ps
171
+ abs_end = fixed[-1][2] + (fixed[-1][1] - fixed[-1][0]) if fixed else ps
172
+ out.append(Sentence(abs_start, abs_end, body, fixed))
173
+ return out
174
+
175
+
176
+ def only_commas(a: str, b: str) -> bool:
177
+ for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
178
+ if op == "equal":
179
+ continue
180
+ if set(a[i1:i2] + b[j1:j2]) - {","}:
181
+ return False
182
+ return True
183
+
184
+
185
+ def comma_edits(text: str, s: Sentence, new: str) -> list[Edit]:
186
+ """Edits (line, col based) turning s.text into new; only comma changes.
187
+ Returns [] if any change falls inside a placeholder."""
188
+ edits = []
189
+ for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, s.text, new, autojunk=False).get_opcodes():
190
+ if op == "equal":
191
+ continue
192
+ pos = s.to_absolute(i1)
193
+ if pos is None or (i2 > i1 and s.to_absolute(i2) is None):
194
+ return []
195
+ original, repl = s.text[i1:i2], new[j1:j2]
196
+ if original and text[pos:pos + len(original)] != original:
197
+ return []
198
+ line = text.count("\n", 0, pos) + 1
199
+ col = pos - (text.rfind("\n", 0, pos) + 1) + 1
200
+ edits.append(Edit(line, col, col + len(original) - 1, original, repl, "commas"))
201
+ return edits
202
+
203
+
204
+ class CommaCache:
205
+ def __init__(self, path: Path | None):
206
+ self.path, self.data = path, {}
207
+ if path and path.exists():
208
+ try:
209
+ self.data = json.loads(path.read_text())
210
+ except json.JSONDecodeError:
211
+ self.data = {}
212
+
213
+ @staticmethod
214
+ def key(scope: str, sentence: str) -> str:
215
+ return hashlib.sha256(f"commas|{scope}|{sentence.strip()}".encode()).hexdigest()[:24]
216
+
217
+ def save(self):
218
+ if self.path:
219
+ self.path.parent.mkdir(parents=True, exist_ok=True)
220
+ self.path.write_text(json.dumps(self.data, indent=1, ensure_ascii=False))
221
+
222
+
223
+ def _ask(ep: Endpoint, system: str, batch: list[Sentence]) -> dict[int, dict] | None:
224
+ """{id: {"text", "confidence", "reason"} or {"note": reason}}; None on a bad reply."""
225
+ user = "\n".join(f"{i + 1}. {s.text}" for i, s in enumerate(batch))
226
+ reply = strip_thinking(ep.chat(system, user))
227
+ m = re.search(r"\{.*\}", reply, re.S)
228
+ if not m:
229
+ return None
230
+ try:
231
+ data = json.loads(m.group(0))
232
+ except json.JSONDecodeError:
233
+ return None
234
+ if not isinstance(data, dict):
235
+ return None
236
+ out: dict[int, dict] = {}
237
+ for d in data.get("changed") or data.get("sentences") or []:
238
+ try:
239
+ out[int(d["id"])] = d
240
+ except (KeyError, TypeError, ValueError):
241
+ continue
242
+ for d in data.get("notes") or []:
243
+ try:
244
+ out.setdefault(int(d["id"]), {"note": str(d.get("reason", ""))})
245
+ except (KeyError, TypeError, ValueError):
246
+ continue
247
+ return out
248
+
249
+
250
+ def correct_commas(text: str, target: str, ep: Endpoint | None, cache: CommaCache,
251
+ optional: str = "leave", min_confidence: float = 0.8, batch_size: int = 15,
252
+ parallel: int = 4, verbose: bool = False, gate: bool = True,
253
+ max_sentences: int | None = None) -> tuple[list[Edit], list[CommaResult]]:
254
+ sents = sentences(text, gate=gate)
255
+ if max_sentences is not None:
256
+ sents = sents[:max_sentences]
257
+ scope = f"{target}|{optional}"
258
+ results: dict[int, CommaResult] = {}
259
+ todo: list[tuple[int, Sentence]] = []
260
+ for i, s in enumerate(sents):
261
+ hit = cache.data.get(CommaCache.key(scope, s.text))
262
+ if hit is not None:
263
+ new = hit.get("text")
264
+ results[i] = CommaResult(s, new if new and new != s.text else None,
265
+ float(hit.get("confidence", 1.0)), hit.get("reason", "") + " (cached)",
266
+ "edit" if new and new != s.text else "unchanged")
267
+ else:
268
+ todo.append((i, s))
269
+ if todo and ep is not None:
270
+ system = SYSTEM.format(variety=VARIETY[target], optional=OPTIONAL[optional])
271
+ batches = [todo[k:k + batch_size] for k in range(0, len(todo), batch_size)]
272
+ if verbose:
273
+ print(f"proofix: checking commas in {len(todo)} sentence(s), {len(batches)} batch(es)",
274
+ file=sys.stderr)
275
+
276
+ def run(batch):
277
+ try:
278
+ return batch, _ask(ep, system, [s for _, s in batch])
279
+ except LLMError as e:
280
+ return batch, {"error": str(e)}
281
+
282
+ with ThreadPoolExecutor(max_workers=max(1, parallel)) as pool:
283
+ for batch, answers in pool.map(run, batches):
284
+ for n, (i, s) in enumerate(batch, 1):
285
+ if answers is None or "error" in answers:
286
+ why = answers["error"] if answers else "unparseable reply"
287
+ results[i] = CommaResult(s, None, 0.0, f"model error: {why}", "error")
288
+ continue
289
+ a = answers.get(n)
290
+ if a is None: # not listed: unchanged
291
+ results[i] = CommaResult(s, None, 1.0, "", "unchanged")
292
+ cache.data[CommaCache.key(scope, s.text)] = {"text": s.text, "confidence": 1.0, "reason": ""}
293
+ continue
294
+ if "note" in a:
295
+ results[i] = CommaResult(s, None, 0.0, a["note"], "note")
296
+ continue
297
+ if not isinstance(a.get("text"), str):
298
+ results[i] = CommaResult(s, None, 0.0, "malformed answer", "error")
299
+ continue
300
+ new = a["text"].strip()
301
+ try:
302
+ conf = float(a.get("confidence", 0))
303
+ except (TypeError, ValueError):
304
+ conf = 0.0
305
+ reason = str(a.get("reason", ""))[:160]
306
+ if new == s.text:
307
+ results[i] = CommaResult(s, None, conf, reason, "unchanged")
308
+ cache.data[CommaCache.key(scope, s.text)] = {"text": s.text, "confidence": conf, "reason": reason}
309
+ elif not only_commas(s.text, new):
310
+ results[i] = CommaResult(s, new, conf, reason, "rejected")
311
+ elif conf < min_confidence:
312
+ results[i] = CommaResult(s, new, conf, reason, "low")
313
+ else:
314
+ results[i] = CommaResult(s, new, conf, reason, "edit")
315
+ cache.data[CommaCache.key(scope, s.text)] = {"text": new, "confidence": conf, "reason": reason}
316
+ cache.save()
317
+ elif todo:
318
+ for i, s in todo:
319
+ results[i] = CommaResult(s, None, 0.0, "no model configured", "error")
320
+ ordered = [results[i] for i in range(len(sents)) if i in results]
321
+ edits = []
322
+ for r in ordered:
323
+ if r.status == "edit" and r.new_text:
324
+ edits.extend(comma_edits(text, r.sentence, r.new_text))
325
+ return edits, ordered