feat: prose_pass — move article voice to third person, with a gate that refuses fact drift
348 sections carry 819 imperatives ('Obtain a CBC', 'Counsel parents').
Reference prose states what is done. This script exports those sections,
takes rewrites back, and refuses any that lost a cross-reference, changed
a number, or landed outside 0.7-1.4x length — so a voice pass cannot
quietly become a content pass.
Measured while building it: first person is zero in the corpus. An
earlier count of 136 was matching 'US' the country as the pronoun 'us'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
595b619515
commit
9bc202d967
1 changed files with 209 additions and 0 deletions
209
backend/scripts/prose_pass.py
Normal file
209
backend/scripts/prose_pass.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""Move article prose into the third person, impersonal, and check the result.
|
||||
|
||||
The articles were written as instructions to a clinician — "Obtain a CBC",
|
||||
"Do not order a scrotal ultrasound", "Counsel parents". Reference prose states
|
||||
what is done rather than telling the reader to do it: *diagnosis is made by*,
|
||||
*response to treatment is assessed by*. 819 imperatives across 348 sections
|
||||
carry that voice.
|
||||
|
||||
This does the moving of text, not the writing. `export` writes the affected
|
||||
sections to batch files; something that can write English rewrites the
|
||||
`content` of each and saves alongside; `import` checks and applies.
|
||||
|
||||
What `import` refuses, because a rewrite must not quietly change the article:
|
||||
|
||||
* a lost or invented cross-reference — the set of `[[id|…]]` targets must match
|
||||
* a changed number — every numeric token must survive, so doses, ages,
|
||||
thresholds and percentages cannot drift
|
||||
* a length outside 0.7-1.4x, which is how a summary or a padding pass looks
|
||||
* an empty result
|
||||
|
||||
It reports, but allows, imperatives that survive: some are genuinely the right
|
||||
form inside a quoted protocol.
|
||||
|
||||
python -m scripts.prose_pass export --out /tmp/prose --batches 8
|
||||
python -m scripts.prose_pass import --in /tmp/prose
|
||||
python -m scripts.prose_pass check
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.article import Article
|
||||
|
||||
MARKER = re.compile(r"\[\[(\d+)\|([^\]]+)\]\]")
|
||||
NUMBER = re.compile(r"\d+(?:\.\d+)?")
|
||||
#: Verbs that open a sentence or a bullet as an instruction to the reader.
|
||||
IMPERATIVE = re.compile(
|
||||
r"(?:(?<=^)|(?<=[.!?] )|(?<=^- )|(?<=^\* )|(?<=\n- )|(?<=\n\* ))"
|
||||
r"(Obtain|Order|Check|Give|Start|Administer|Consider|Assess|Evaluate|Perform|"
|
||||
r"Measure|Repeat|Refer|Avoid|Ensure|Monitor|Treat|Begin|Initiate|Stop|Use|Send|"
|
||||
r"Screen|Look|Ask|Confirm|Rule out|Do not|Don't|Reserve|Counsel|Admit|Discharge|"
|
||||
r"Suspect|Exclude|Document|Reassess|Titrate|Correct|Repair|Remove|Apply|Prescribe)\b")
|
||||
|
||||
MIN_RATIO, MAX_RATIO = 0.7, 1.4
|
||||
|
||||
|
||||
def affected(db):
|
||||
"""Every section carrying the instructional voice, with its article."""
|
||||
out = []
|
||||
for article in db.query(Article).order_by(Article.id).all():
|
||||
for section in article.sections or []:
|
||||
content = section.get("content") or ""
|
||||
hits = IMPERATIVE.findall(content)
|
||||
if hits:
|
||||
out.append({
|
||||
"article_id": article.id,
|
||||
"article_title": article.title,
|
||||
"section_id": section.get("id"),
|
||||
"section_title": section.get("title"),
|
||||
"variant": section.get("variant") or "long",
|
||||
"imperatives": len(hits),
|
||||
"content": content,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def validate(before: str, after: str) -> list[str]:
|
||||
"""Reasons this rewrite must not be applied. Empty means it is safe."""
|
||||
problems = []
|
||||
if not after or not after.strip():
|
||||
return ["empty"]
|
||||
|
||||
want = sorted(m.group(1) for m in MARKER.finditer(before))
|
||||
got = sorted(m.group(1) for m in MARKER.finditer(after))
|
||||
if want != got:
|
||||
lost = sorted(set(want) - set(got))
|
||||
added = sorted(set(got) - set(want))
|
||||
problems.append(f"cross-references changed (lost {lost or '-'}, added {added or '-'})")
|
||||
|
||||
want_n = sorted(NUMBER.findall(before))
|
||||
got_n = sorted(NUMBER.findall(after))
|
||||
if want_n != got_n:
|
||||
lost = sorted(set(want_n) - set(got_n))
|
||||
added = sorted(set(got_n) - set(want_n))
|
||||
problems.append(f"numbers changed (lost {lost or '-'}, added {added or '-'})")
|
||||
|
||||
ratio = len(after) / max(len(before), 1)
|
||||
if not MIN_RATIO <= ratio <= MAX_RATIO:
|
||||
problems.append(f"length {ratio:.2f}x of the original")
|
||||
return problems
|
||||
|
||||
|
||||
def cmd_export(args) -> int:
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = affected(db)
|
||||
finally:
|
||||
db.close()
|
||||
if not rows:
|
||||
print("Nothing to rewrite.")
|
||||
return 0
|
||||
|
||||
# Whole articles stay together where they can: a rewriter seeing every
|
||||
# section of one topic keeps its terminology consistent.
|
||||
rows.sort(key=lambda r: (r["article_id"], r["section_title"] or ""))
|
||||
per = max(1, -(-len(rows) // args.batches))
|
||||
written = 0
|
||||
for index in range(args.batches):
|
||||
chunk = rows[index * per:(index + 1) * per]
|
||||
if not chunk:
|
||||
break
|
||||
path = out / f"batch-{index + 1:02d}.json"
|
||||
path.write_text(json.dumps(chunk, indent=2, ensure_ascii=False))
|
||||
written += 1
|
||||
print(f" {path} {len(chunk):3d} sections, {sum(c['imperatives'] for c in chunk):4d} imperatives")
|
||||
print(f"\n{len(rows)} sections in {written} batches.")
|
||||
print("Rewrite each entry's `content` and save as batch-NN.done.json alongside.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_import(args) -> int:
|
||||
folder = Path(args.folder)
|
||||
done = sorted(folder.glob("*.done.json"))
|
||||
if not done:
|
||||
print(f"No *.done.json in {folder}")
|
||||
return 1
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
articles = {a.id: a for a in db.query(Article).all()}
|
||||
applied = skipped = 0
|
||||
for path in done:
|
||||
entries = json.loads(path.read_text())
|
||||
for entry in entries:
|
||||
article = articles.get(entry["article_id"])
|
||||
if not article:
|
||||
continue
|
||||
section = next((s for s in (article.sections or [])
|
||||
if s.get("id") == entry["section_id"]), None)
|
||||
if not section:
|
||||
print(f" ! {entry['article_title']} / {entry['section_title']}: section is gone")
|
||||
skipped += 1
|
||||
continue
|
||||
before, after = section.get("content") or "", entry["content"]
|
||||
if before == after:
|
||||
continue
|
||||
problems = validate(before, after)
|
||||
if problems:
|
||||
print(f" ! {entry['article_title']} / {entry['section_title']}: {'; '.join(problems)}")
|
||||
skipped += 1
|
||||
continue
|
||||
section["content"] = after
|
||||
flag_modified(article, "sections")
|
||||
applied += 1
|
||||
print(f"\n applied: {applied} refused: {skipped}")
|
||||
if args.apply:
|
||||
db.commit()
|
||||
print(" committed.")
|
||||
else:
|
||||
print("\n Re-run with --apply to write.")
|
||||
finally:
|
||||
db.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check(args) -> int:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = affected(db)
|
||||
total = sum(r["imperatives"] for r in rows)
|
||||
articles = len({r["article_id"] for r in rows})
|
||||
print(f" {total} imperatives in {len(rows)} sections across {articles} articles")
|
||||
for row in sorted(rows, key=lambda r: -r["imperatives"])[:10]:
|
||||
print(f" {row['imperatives']:3d} {row['article_title']} / {row['section_title']}")
|
||||
finally:
|
||||
db.close()
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
export = sub.add_parser("export")
|
||||
export.add_argument("--out", required=True)
|
||||
export.add_argument("--batches", type=int, default=8)
|
||||
export.set_defaults(func=cmd_export)
|
||||
|
||||
imp = sub.add_parser("import")
|
||||
imp.add_argument("--in", dest="folder", required=True)
|
||||
imp.add_argument("--apply", action="store_true")
|
||||
imp.set_defaults(func=cmd_import)
|
||||
|
||||
check = sub.add_parser("check")
|
||||
check.set_defaults(func=cmd_check)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue