Files
DB-cleanup/merge-output/auto_trim.py
T
StellarCrowandClaude Fable 5.1 439e83a4cb feat(sources): Wayback pass, automatic trimming, detention rule, ten cases re-sourced
Second and third rounds on the dossier pipeline, reviewed locally before commit.

Retrieval
- wayback_pass.py: retries every BLOCKED/DEAD/THIN/ERROR linked source through
  the Wayback Machine; blocks carry the snapshot date, archive URL and the live
  verdict. 143 slots retried: 93 fetched, 37 no snapshot, 12 script shells, 1 PDF.
- fetch_sources.py: fetch_wayback() helper.
- add_extra_sources.py: files staged extra_* finds (web search, not on the DB
  record) into the store with a provenance note.
- Cases re-sourced by web search: 248 (Oslo: Document.no, Avisa Oslo, NRK; the
  linked Le Parisien piece is case 242 and is marked OFF-CASE), 258 Kharkiv,
  260 Singapore, 358 Bangkok, 365 Las Vegas, 388 Verneuil-sur-Seine, 390 Zoersel,
  430 Homestead. 341 of 364 cases now hold a fetched article; 11, 66 and 399
  have no public text source (podcast, police video, direct victim report).

Trimming
- auto_trim.py: case-anchored furniture cut for UNTRIMMED blocks. Finds the body
  run that mentions the case, merges across subheadings and short furniture
  gaps, drops teasers, share bars, date/URL/caption lines and subscription
  pitches; refuses pages with no record term or almost no body. 302 blocks
  trimmed; 16 left on auto-trim-review.md (7 OFF-CASE suspects). Every cut is
  labelled AUTO-TRIMMED, UNREVIEWED in the dossier; the full extract stays in
  staging/. Decisions with anchors in trim-decisions-auto.json.
- build_cases.py / verify_all.py: banners for auto-trimmed and archived blocks,
  staged-file check extended to Wayback blocks, flag legend under the record.

Detention rule
- README "Definitions": the DB field `kidnappings` is the DETENTION violence
  type (victim, guard, staff or relative held to force submission or execute
  the theft), distinct from the Kidnapping scenario (taken away and held).
- detention-flag-review.md / detention-flag-corrections.json: 40 records
  reviewed with evidence; 30 set-to-1 proposals accepted by the owner on
  2026-09-06 (listed in corrections-approved.md), 10 still open.
- apply_detention_wording.py: "Violence Used" in the 70 reviewed summaries now
  names detention explicitly (62 of 70 labelled), supported by the summary's
  own text; idempotent; supersedes the batch scripts' wording.

Worklist and docs
- sofia-worklist.md: 248 decisions, detention rule item replacing the old
  "no abduction" item, fresh-search section for the textless cases.
- README-START-HERE.md: progress notes, run commands, next steps.
- Bug fixed in passing: Wayback blocks stored in-memory text with carriage
  returns; now stored as read back from disk.

verify_all.py PASSES (2321 checks).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZZENdTLzNGsbNy4DyF1yt
2026-09-06 19:36:45 +02:00

295 lines
14 KiB
Python

"""
Automatic first-pass trim of UNTRIMMED fetched blocks.
Uses propose_cuts.py (the case-anchored proposer) to find the paragraph run that talks
about this case, then widens it backwards across short non-junk gaps so a lede is not
lost to a one-line subhead, and drops interior lines that are plainly site furniture
(ALSO READ teasers, share prompts, advertisements). Every kept piece is verified as a
verbatim substring of the staged file before it is written. The full extract stays on
disk in staging/, so any cut can be revisited.
This is a machine cut, not a review. Blocks trimmed here are marked
trimmed_by = "auto (propose_cuts, unreviewed)" and the dossier prints a banner saying
so. Pages where the proposer cannot find the case (fewer than MIN_HITS case-term hits)
are left UNTRIMMED and listed in auto-trim-review.md; they are candidates for a
wrong-source verdict and need a human.
Run build_cases.py --all and verify_all.py afterwards.
"""
import argparse
import hashlib
import io
import json
import re
import sys
from pathlib import Path
from propose_cuts import JUNK, PAYWALL, case_terms, score
from trim import slice_paras
BASE = Path(__file__).resolve().parent
STORE = BASE / "sourced-articles.json"
STAGING = BASE / "staging"
JSONF = BASE.parent / "attacks-export-Gart-website.json"
DECISIONS_OUT = BASE / "trim-decisions-auto.json"
REVIEW_OUT = BASE / "auto-trim-review.md"
MIN_HITS = 3 # at or above this the page is confidently about the case
# 1-2 hits: trimmed, but listed as WEAK on the review list (thin records such as
# "30 y/o man, Sweden" cannot score higher). 0 hits: left UNTRIMMED as possible OFF-CASE.
BACK_GAP = 4 # merge an earlier run if separated by at most this many short lines
# Interior furniture: short lines that sit inside an article body on many news sites.
# Anchored and short-only so a real sentence that happens to contain "share" survives.
INTERIOR = re.compile(
r"^(also read|read (more|also|next)|related( (articles?|stories|news|coverage))?|"
r"see also|more (on|from) |advertisement|advertising|publicidade|publicit\u00e9|"
r"a lire aussi|\u00e0 lire aussi|lire aussi|leia tamb\u00e9m|lea tambi\u00e9n|siehe auch|"
r"sign up|subscribe|share( this| via)?|loading|image[: ]|photo[: ]|picture[: ]|"
r"illustration( of|:)|file photo|getty images|\(image|credit[: ]|source[: ]|"
r"watch[: ]|listen[: ]|video[: ]|"
r"recommended|trending|most read|top stories|editor'?s picks|popular|"
r"follow us|join us|download the app|click here|tap here|"
r"this article (is|was)|this story (is|was)|updated at|published (on|at)|"
r"(mon|tue|wed|thu|fri|sat|sun)[a-z]*, [a-z]+\.? \d{1,2}, \d{4}|"
r"editorial process|trusted editorial|ad disclosure|"
r"let us know|got an opinion|email your thoughts|story tips|"
r"poster sur|tweeter|envoyer via|partager|accueil \u00bb|compartilh|compartir|teilen|"
r"by registering|for signing up|thank you for (signing|subscribing)|"
r"copyright|all rights reserved|\u00a9)", re.I)
# Teaser headline with a date suffix: "Some headline - 4. september 2025" / "- May 3, 2024"
DATED_TEASER = re.compile(r" [-\u2013] (\d{1,2}\.? [A-Za-z\u00e6\u00f8\u00e5]+ \d{4}|[A-Z][a-z]+ \d{1,2}, \d{4})$")
TAIL_WORDS = re.compile(r"(inbox|newsletter|briefing|unsubscribe|privacy policy|terms of (use|service)|"
r"retningslinjer|debattskikk|^vi i document|^kj\u00f8p .* fra document|"
r"cookie|comments? below|in the comments|support local news|pay what you want|"
r"starting at us\$|join a community|become a member|donate|"
r"^every (mon|tues|wednes|thurs|fri|satur|sun)day)", re.I)
def listy(p):
"""A newline-packed paragraph of short fragments is a nav bar or a tag list."""
segs = [x for x in p.split("\n") if x.strip()]
return len(segs) >= 4 and sum(len(x) for x in segs) / len(segs) < 30
def is_furniture(p):
p = p.strip()
if not p or listy(p) or p.startswith(("http://", "https://", "www.")) or DATED_TEASER.search(p):
return True
return len(p) <= 400 and bool(INTERIOR.match(p) or JUNK.search(p) or TAIL_WORDS.search(p))
MIN_KEEP = 600 # a cut that keeps less than this from a page 3x larger is refused
GAP = 4 # furniture lines tolerated between two body runs
def bodyish(p):
"""Body text, relaxed from propose_cuts: a short sentence is still a sentence.
A 70-char line ending in a full stop is a closing sentence far more often than
furniture (teasers are headlines and headlines do not end in full stops); the
furniture that does end in a full stop is caught by JUNK / INTERIOR.
"""
p = p.strip()
if not p or is_furniture(p):
return False
if len(p) >= 140:
return True
return len(p) >= 40 and bool(re.search(r"[.!?;:\u201d\u00bb\"')]$", p))
def gap_ok(p):
p = p.strip()
return len(p) <= 200 and not JUNK.search(p)
def find_range(paras, terms):
"""Choose the body run that talks about this case and widen it across short gaps.
Runs are maximal stretches of body paragraphs. The best run is the one with the
most distinct case terms (density, then length, break ties), as in propose_cuts.
It is then merged with neighbouring runs when the gap between them is at most
GAP short non-junk lines: backwards unconditionally (a lede sits before a subhead),
forwards only when the next run mentions the case or is short (a closing sentence
or two), so a trailing block of sentence-shaped teasers is not absorbed.
"""
body = [bodyish(p) for p in paras]
runs, cur = [], []
for i, b in enumerate(body):
if b:
cur.append(i)
elif cur:
runs.append(cur)
cur = []
if cur:
runs.append(cur)
if not runs:
return None, None
def run_score(r):
text = " ".join(paras[i] for i in r).lower()
distinct = sum(1 for t in terms if t in text)
chars = sum(len(paras[i]) for i in r) or 1
# Distinct terms first, then LENGTH. propose_cuts used density as the tie-break,
# which let a six-line teaser list carrying one "Oslo" beat the 18-paragraph
# article carrying the same one "Oslo" (Document.no, case 248).
return (distinct, chars)
k = max(range(len(runs)), key=lambda i: run_score(runs[i]))
start, end = runs[k][0], runs[k][-1]
def gap_between(a_end, b_start):
between = list(range(a_end + 1, b_start))
return between if len(between) <= GAP and all(gap_ok(paras[i]) for i in between) else None
j = k
while j - 1 >= 0 and gap_between(runs[j - 1][-1], runs[j][0]) is not None:
j -= 1
start = runs[j][0]
j = k
while j + 1 < len(runs) and gap_between(runs[j][-1], runs[j + 1][0]) is not None:
nxt = runs[j + 1]
gap = gap_between(runs[j][-1], nxt[0])
hits = sum(score(paras[i], terms) for i in nxt)
# A one- or two-line gap is a subheading or a pull quote inside the article;
# merge regardless of hits (later sections often carry no record term). A
# longer gap looks like a teaser block, so the next run must earn its place.
if len(gap) > 2 and hits == 0 and len(nxt) > 3:
break
j += 1
end = nxt[-1]
while end > start and not bodyish(paras[end]):
end -= 1
while start < end and not bodyish(paras[start]):
start += 1
return start, end
def reset_auto(store, only=()):
"""Put every auto-trimmed block back to UNTRIMMED from its staged file."""
n = 0
for cid, blocks in store.items():
if only and int(cid) not in only:
continue
for b in blocks:
if b.get("verdict") == "KEEP" and str(b.get("trimmed_by", "")).startswith("auto"):
path = STAGING / f"{int(cid):03d}" / b["staged_file"]
text = path.read_text(encoding="utf-8")
b.update(verdict="UNTRIMMED", text=text, chars=len(text),
sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
paragraphs_kept=f"ALL {len(text.split(chr(10) * 2))} paragraphs, untrimmed",
cut_note="NOT TRIMMED. Full page extract; trim on read-through.")
b.pop("trimmed_by", None)
n += 1
return n
def main():
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser()
ap.add_argument("cases", nargs="*", type=int)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--reset", action="store_true",
help="first restore every auto-trimmed block to UNTRIMMED from staging/")
args = ap.parse_args()
records = {r["id"]: r for r in json.loads(JSONF.read_text(encoding="utf-8"))}
store = json.loads(STORE.read_text(encoding="utf-8"))
decisions, review, applied, skipped = {}, [], 0, 0
if args.reset:
print(f"reset {reset_auto(store, args.cases)} auto-trimmed block(s) to UNTRIMMED")
for cid_s, blocks in store.items():
cid = int(cid_s)
if args.cases and cid not in args.cases:
continue
terms = case_terms(records[cid])
for b in blocks:
if b.get("verdict") != "UNTRIMMED" or not b.get("staged_file"):
continue
path = STAGING / f"{cid:03d}" / b["staged_file"]
full = path.read_text(encoding="utf-8")
paras = full.split("\n\n")
hits = sum(score(p, terms) for p in paras)
start, end = find_range(paras, terms)
if start is None or hits == 0:
reason = ("no body run found" if start is None else
"OFF-CASE? no case term appears anywhere on the page")
review.append((cid, b["field"], b.get("source"), reason, b["url"]))
skipped += 1
continue
weak = hits < MIN_HITS
if weak:
review.append((cid, b["field"], b.get("source"),
f"WEAK anchor: trimmed on only {hits} case-term hit(s); confirm "
"the page is about this case", b["url"]))
exclude = [i for i in range(start, end + 1) if is_furniture(paras[i])]
title = None
t0 = paras[0].strip()
if start > 0 and 20 < len(t0) < 200 and not JUNK.search(t0):
title = 0
text, pieces = slice_paras(paras, start, end, title, exclude)
if any(p not in full for p in pieces):
review.append((cid, b["field"], b.get("source"), "slice not a substring (bug)", b["url"]))
skipped += 1
continue
if len(text) < MIN_KEEP and len(full) > 3 * len(text):
review.append((cid, b["field"], b.get("source"),
f"TOO SMALL: the only body run found is {len(text)} chars of a "
f"{len(full):,}-char page; page may be a script shell or list-form "
"article. Left UNTRIMMED.", b["url"]))
skipped += 1
continue
dropped_head = start - (1 if title is not None else 0)
dropped_tail = len(paras) - 1 - end
pay = [p.strip()[:60] for p in paras if PAYWALL.search(p)]
cut_note = (f"AUTO-TRIMMED, unreviewed: kept paragraphs {start}-{end}"
+ (f" plus headline [0]" if title is not None else "")
+ f"; dropped {dropped_head} leading and {dropped_tail} trailing paragraphs"
+ (f" and {len(exclude)} interior furniture line(s)" if exclude else "")
+ f". Case-term hits on page: {hits}"
+ (" (WEAK anchor, confirm the page is about this case)." if weak else ".")
+ (" PAYWALL marker seen on page." if pay else ""))
decisions.setdefault(cid_s, []).append({
"field": b["field"], "staged_file": b["staged_file"], "start": start, "end": end,
"title": title, "exclude": exclude, "hits": hits,
"starts_with": paras[start][:45], "ends_with": paras[end][-55:],
"chars_before": len(full), "chars_after": len(text), "paywall": bool(pay)})
if not args.dry_run:
b.update(verdict="KEEP", text=text, chars=len(text),
sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
paragraphs_kept=(f"headline [0] + " if title is not None else "")
+ f"{start}-{end}"
+ (f" excl {exclude}" if exclude else "")
+ f" of {len(paras)}",
cut_note=cut_note, trimmed_by="auto (propose_cuts, unreviewed)")
b.pop("carried_over", None)
applied += 1
if not args.dry_run:
STORE.write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8")
DECISIONS_OUT.write_text(json.dumps(decisions, ensure_ascii=False, indent=2), encoding="utf-8")
lines = ["# Auto-trim review list", "",
"Three kinds of entry. TOO SMALL blocks were left UNTRIMMED because the trimmer found",
"almost no body text on a large page. OFF-CASE? blocks were left UNTRIMMED because no term from the",
"record appears on the page; the linked URL may be the wrong source. WEAK blocks were",
"trimmed but on one or two hits only, usually because the record is thin; confirm the",
"page is about this case. The full extract stays in staging/ either way.", ""]
for cid, field, src, reason, url in sorted(review):
lines.append(f"- **case {cid}** `{field}` {src}: {reason}\n <{url}>")
REVIEW_OUT.write_text("\n".join(lines) + "\n", encoding="utf-8")
before = sum(d["chars_before"] for ds in decisions.values() for d in ds)
after = sum(d["chars_after"] for ds in decisions.values() for d in ds)
print(f"{'would trim' if args.dry_run else 'trimmed'} {applied} block(s): "
f"{before:,} -> {after:,} chars; left UNTRIMMED for review: {skipped}")
remaining = sum(1 for c in store.values() for b in c if b.get("verdict") == "UNTRIMMED")
print(f"UNTRIMMED blocks in store now: {remaining}")
if __name__ == "__main__":
main()