K&R database cleanup: handoff bundle

Self-contained bundle to continue the case-summary enrichment pass (70/256 done). sources/ holds the 364 .txt dossiers; scripts use relative paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FJRciSWZc9HftS2edbzBf
This commit is contained in:
2026-09-02 19:49:46 -03:00
co-authored by Claude Opus 4.8
commit ba7529ea50
735 changed files with 90597 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
"""
Propose where a case's article starts and stops in each staged file, using the case
record itself as the anchor.
The earlier version took the longest contiguous run of body-like paragraphs. That fails
on two shapes seen in real pages:
- a page carrying several stories, where the longest run is a DIFFERENT article
(A Gazeta on case 456 opened on an unrelated arrest for threatening a relative)
- an article whose key paragraph sits after a short sentence, so the run stops early
(A Tribuna on case 456 truncated three paragraphs before the only line it was added for)
So: score every paragraph by how many case-specific terms it contains, drawn from the
database record. Seed on the best-scoring paragraph and grow outward through body-like
text, stopping at teaser runs. A page whose paragraphs never mention the case at all is
flagged OFF-CASE, which is the deterministic half of the Phuket trap.
Emits exact anchors so apply_sources.py can guard against paragraph drift without
anyone retyping them.
"""
import io
import json
import re
import sys
import unicodedata
from pathlib import Path
BASE = Path(__file__).resolve().parent
STAGING = BASE / "staging"
JSONF = BASE.parent / "attacks-export-Gart-website.json"
PAYWALL = re.compile(
r"(il vous reste|% à lire|reste \d+% |assinante|already a subscriber|"
r"subscribe to (read|continue)|log ?in to (read|continue)|para continuar lendo|"
r"conteúdo exclusivo|abonnez-vous|déjà abonné)", re.I)
JUNK = re.compile(
r"(cookie|adblock|bloqueador de an|javascript n|termos de uso|terms of use|"
r"all rights reserved|todos os direitos|aviso legal|disclaimer|©|newsletter|"
r"receba as principais|participe da nossa|join the groups|login page|please log in|"
r"follow us|leia também|à lire aussi|is a writer|escritora y editora|"
r"contact us|about us|sabe de alguma coisa)", re.I)
STOP = {"the", "and", "for", "was", "were", "with", "his", "her", "man", "men", "woman",
"unidentified", "undisclosed", "unknown", "multiple", "victim", "repeat", "family",
"crypto", "cryptocurrency", "bitcoin", "old", "year", "y/o", "de", "da", "do", "la",
"le", "el", "en", "un", "una", "and"}
MONTHS = {
1: ["january", "janeiro", "janvier", "enero"], 2: ["february", "fevereiro", "février", "febrero"],
3: ["march", "março", "mars", "marzo"], 4: ["april", "abril", "avril"],
5: ["may", "maio", "mai", "mayo"], 6: ["june", "junho", "juin", "junio"],
7: ["july", "julho", "juillet", "julio"], 8: ["august", "agosto", "août"],
9: ["september", "setembro", "septembre", "septiembre"], 10: ["october", "outubro", "octobre", "octubre"],
11: ["november", "novembro", "novembre", "noviembre"], 12: ["december", "dezembro", "décembre", "diciembre"],
}
def fold(s):
s = unicodedata.normalize("NFKD", str(s or ""))
return "".join(c for c in s if not unicodedata.combining(c)).lower()
def case_terms(rec):
"""Distinctive strings that should appear in an article about THIS case."""
terms = set()
for field in ("victim", "location", "country"):
for tok in re.split(r"[^A-Za-z0-9]+", fold(rec.get(field))):
if len(tok) > 3 and tok not in STOP:
terms.add(tok)
if rec.get("month") in MONTHS:
terms.update(MONTHS[rec["month"]])
m = re.search(r"\b(\d{1,2})\b", str(rec.get("date") or ""))
if m:
terms.add(m.group(1))
for field in ("money_wanted", "description"):
for num in re.findall(r"\d[\d.,]{2,}", str(rec.get(field) or "")):
terms.add(fold(num))
# Distinctive nouns from the description carry most of the signal: a car model, a
# street, a company. Without them a bare "11" matches half of any news page.
for field in ("description", "notes"):
for tok in re.findall(r"\b[A-Z][A-Za-z0-9]{4,}\b", str(rec.get(field) or "")):
t = fold(tok)
if t not in STOP:
terms.add(t)
return {t for t in terms if t}
def score(para, terms):
f = fold(para)
return sum(1 for t in terms if t in f)
def bodyish(p):
p = p.strip()
return len(p) > 110 and re.search(r"[.!?”»\"]$", p) and not JUNK.search(p)
def propose(paras, terms, bridge=2):
"""Runs decide the boundaries; case terms decide which run.
Boundaries come from contiguous body text, tolerating up to `bridge` short lines so
a one-sentence paragraph cannot truncate an article. Selection then picks the run
that actually talks about this case, which is what a multi-story page needs.
"""
scores = [score(p, terms) for p in paras]
total_hits = sum(scores)
runs, cur, gap = [], [], 0
for i, p in enumerate(paras):
if bodyish(p):
cur.append(i)
gap = 0
elif cur and len(p.strip()) <= 200 and not JUNK.search(p) and gap < bridge:
cur.append(i)
gap += 1
else:
if cur:
runs.append(cur)
cur, gap = [], 0
if cur:
runs.append(cur)
if not runs:
return None, None, total_hits
def run_score(r):
# Distinct terms first: a long unrelated run accumulates raw hits just by being
# long, which is how A Gazeta's story about a woman threatening her relative beat
# the actual kidnapping. Density breaks ties; length only decides after that.
text = fold(" ".join(paras[i] for i in r))
distinct = sum(1 for t in terms if t in text)
chars = sum(len(paras[i]) for i in r) or 1
return (distinct, sum(scores[i] for i in r) / chars, chars)
best = max(runs, key=run_score)
start, end = best[0], best[-1]
# Trim bridged non-body lines off either end.
while start < end and not bodyish(paras[start]):
start += 1
while end > start and not bodyish(paras[end]):
end -= 1
return start, end, total_hits
def main():
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
records = {r["id"]: r for r in json.loads(JSONF.read_text(encoding="utf-8"))}
out = {}
for case in sys.argv[1:]:
cid = int(case)
rec = records[cid]
terms = case_terms(rec)
print(f"\n### case {cid} terms: {' '.join(sorted(terms))[:110]}")
for f in sorted((STAGING / f"{cid:03d}").glob("*.txt")):
paras = f.read_text(encoding="utf-8").split("\n\n")
start, end, hits = propose(paras, terms)
flag = ""
if hits == 0:
flag = " !! OFF-CASE: no case term appears anywhere on this page"
elif hits < 3:
flag = f" !! WEAK: only {hits} case-term hits on the whole page"
pay = [p.strip()[:60] for p in paras if PAYWALL.search(p)]
title = paras[0].strip() if paras else ""
use_title = start not in (0, None) and 20 < len(title) < 200 and not JUNK.search(title)
key = f"{cid}:{f.name}"
out[key] = {"start": start, "end": end, "title": 0 if use_title else None,
"paras": len(paras), "case_term_hits": hits,
"starts_with": paras[start][:45] if start is not None else None,
"ends_with": paras[end][-55:] if end is not None else None,
"paywall": pay}
kept = len("\n\n".join(paras[start:end + 1])) if start is not None else 0
print(f" {f.name:34} {len(paras):>4} paras -> "
f"{'[0] + ' if use_title else ''}{start}..{end} {kept:>6,} chars hits={hits}{flag}")
if pay:
print(f" PAYWALL: {pay[0]}")
if start is not None:
print(f" starts: {paras[start][:88]}")
print(f" ends : {paras[end][-88:]}")
(STAGING / "proposed-cuts.json").write_text(json.dumps(out, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"\nwrote {len(out)} proposals with anchors -> staging/proposed-cuts.json")
if __name__ == "__main__":
main()