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 +3 -0
- proofix/cli.py +328 -0
- proofix/commas.py +325 -0
- proofix/consistency.py +234 -0
- proofix/hygiene.py +106 -0
- proofix/lt.py +71 -0
- proofix/report.py +73 -0
- proofix/tiers.py +133 -0
- proofix-0.5.0.dist-info/METADATA +137 -0
- proofix-0.5.0.dist-info/RECORD +13 -0
- proofix-0.5.0.dist-info/WHEEL +4 -0
- proofix-0.5.0.dist-info/entry_points.txt +2 -0
- proofix-0.5.0.dist-info/licenses/LICENSE +21 -0
proofix/__init__.py
ADDED
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
|