Files
DB-cleanup/merge-output/build_cases.py
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

539 lines
26 KiB
Python

"""
Build one .txt dossier per case from the exported DB plus the saved article text.
Matching is settled before this runs (see final-mapping below). Article bodies are
copied verbatim. Where one article documents several cases, each file also carries a
RELEVANT EXCERPT made only of verbatim paragraphs from the article beneath it, and
the build asserts that every excerpt line really is a substring of the full text.
Run with --samples to write two example files and stop.
Run with --all to write every case file.
Inputs are never modified.
"""
import argparse
import hashlib
import json
import re
import unicodedata
from collections import defaultdict
from pathlib import Path
import openpyxl
BASE = Path(__file__).resolve().parent
SRC = BASE.parent
XLSX = SRC / "KR-reports_analysis_Sofi.xlsx"
JSONF = SRC / "attacks-export-Gart-website.json"
OUT = BASE / "sources"
SAMPLES = BASE / "samples"
MIN_LEN = 200
ART_COLS = [("Articles", 1), ("Article 2", 2), ("Article 3", 3)]
# Matches settled during review. Loaded from the approval artefacts rather than
# retyped, so this file cannot drift from what was actually signed off.
URL_RESOLVED = {59: 58, 74: 73, 193: 318} # byte-identical URL + corroborating text
BY_ELIMINATION = {164: 307} # Salford incident 4
def load_approved():
approved, rules = {}, {}
for fname, rule in (("proposed-auto-matches.json", "exact date + country, unique candidate"),
("review-16.json", "reviewed and approved individually")):
for m in json.loads((BASE / fname).read_text(encoding="utf-8")):
approved[m["sheet_row"]] = m["j_id"]
rules[m["sheet_row"]] = rule
for row, jid in URL_RESOLVED.items():
approved[row] = jid
rules[row] = "identical source URL plus corroborating article text, approved"
for row, jid in BY_ELIMINATION.items():
approved[row] = jid
rules[row] = "confirmed by elimination across the 5-incident Salford cluster, approved"
return approved, rules
APPROVED, APPROVED_RULES = load_approved()
# Furniture cuts for phase-1 spreadsheet articles, decided on review. Keyed "case:column".
# Each entry drops furniture line indices (or keeps only listed ones). Every retained line is
# still a verbatim line of the original cell; the full cell is hashed for provenance so the
# archive link survives even though the embedded text is now trimmed.
SHEET_TRIM_PATH = BASE / "sheet-trim-decisions.json"
SHEET_TRIM = (json.loads(SHEET_TRIM_PATH.read_text(encoding="utf-8"))
if SHEET_TRIM_PATH.exists() else {})
# Improved case summaries, rewritten on review against the case's own sources and record.
# Keyed by DB id (string). The live DB summary field is never touched; this file is the
# deliverable Sofia applies on the website. Every claim in a rewrite must trace to the
# record or a source already in the case file (no fabrication) — the source-checker verifies.
SUMMARY_OVERRIDES_PATH = BASE / "summary-overrides.json"
SUMMARY_OVERRIDES = (json.loads(SUMMARY_OVERRIDES_PATH.read_text(encoding="utf-8"))
if SUMMARY_OVERRIDES_PATH.exists() else {})
def apply_sheet_trim(cell, dec):
lines = cell.split("\n")
if "keep" in dec:
kept = [i for i in dec["keep"] if 0 <= i < len(lines)]
else:
drop = set(dec.get("drop", []))
kept = [i for i in range(len(lines)) if i not in drop]
text = "\n".join(lines[i] for i in kept)
for i in kept: # every kept piece must be a real line of the cell (no fabrication)
if lines[i] not in cell:
raise SystemExit(f"ABORT: sheet-trim produced a line not in the cell ({dec})")
return text
# One article, five cases. Excerpts are SLICED from the article body between two
# anchors rather than transcribed, so "verbatim" is structural instead of asserted.
# Anchors deliberately avoid apostrophes and quotes, which vary in the source text.
SALFORD_SOURCE_ROW = 164
SALFORD = {
354: ("The abuse first began", "transfer cryptocurrency to him."),
355: ("Later that month, another associate", "demanding more money."),
162: ("Months later, in October", "he was freed."),
307: ("Days later, the gang turned up", "placed a bag over his head."),
169: ("The final incident took place", "head covered once again."),
}
def slice_between(text, start_anchor, end_anchor, label):
i = text.find(start_anchor)
if i < 0:
raise SystemExit(f"ABORT: start anchor for {label} not found in article")
j = text.find(end_anchor, i)
if j < 0:
raise SystemExit(f"ABORT: end anchor for {label} not found after start")
return text[i:j + len(end_anchor)]
# Data problems found while matching. These are for the website database to fix;
# nothing here is applied automatically.
CORRECTIONS = [
{"target": "DB id 321", "field": "victim", "current": "Crypto manager",
"issue": "The person kidnapped was the crypto manager's mother, not the manager.",
"action": "Rename the victim to identify the mother.", "confirmed_by": "Sofia, this session"},
{"target": "DB id 307", "field": "description + notes",
"current": "description duplicates id 354 (incident 1); notes describe incident 2",
"issue": "This record is incident 4 of the Salford series: the gang returned to the "
"victim's house, took him to Egret Drive and put a bag over his head.",
"action": "Rewrite description and notes to incident 4. Its date, location, scenario "
"and flags are already correct.", "confirmed_by": "elimination across 5 sheet rows and 5 DB records"},
{"target": "DB id 258", "field": "url", "current": "https://archive.is/El9EI",
"issue": "The same archive link sits on sheet row 259, whose saved article is the "
"Val-de-Marne case (DB id 362, Europe 1). Id 258 is a Kharkiv kidnapping.",
"action": "Verify which case that snapshot belongs to and clear the wrong one.",
"confirmed_by": "URL audit"},
{"target": "sheet row 18", "field": "Scenario", "current": "Armed Robbery in Public Space",
"issue": "The article says the attack happened inside the victims' own apartment in the "
"Busca neighbourhood of Toulouse.",
"action": "DB id 266 already says Home Invasion, which is closer. Fix the sheet.",
"confirmed_by": "article text, 20 Minutes"},
{"target": "DB id 460", "field": "summary",
"current": "names the victim 'Artem Ivanov' and calls him owner of Hedonist Restaurant",
"issue": "Official sources (ANTARA carrying the Bali Police statement, Bali Times, Social "
"Expat) refer to the victim only by the initials A.I. and say he was RETURNING FROM "
"the restaurant, not that he owned it. Both the full name and the ownership claim "
"trace to a single Indonesian-language Threads.com post.",
"action": "Replace the name with 'Russian national, initials A.I., 41 years old'. Replace "
"'owner of Hedonist Restaurant' with 'was returning from Hedonist Restaurant'. "
"Note that name and ownership are unconfirmed and sourced only from social media.",
"confirmed_by": "Sofia, against ANTARA News official police statement"},
{"target": "DB id 460", "field": "url_2..url_5",
"current": "five links, none of them ANTARA",
"issue": "ANTARA News carries the official Bali Police statement and confirms details the "
"linked sources do not, including the villa key taken from the motorcycle dashboard "
"and used to enter Villa Ukulele. The record's 'Villa Ukulele' note is correct; it "
"is simply not evidenced by anything currently linked.",
"action": "Add the ANTARA article as a linked source.",
"confirmed_by": "Sofia"},
{"target": "DB id 460", "field": "money_wanted",
"current": "$4.9M to $5M in Crypto",
"issue": "No source gives $4.9M. Only Bali Discovery gives a figure at all, US$5 million "
"or about Rp 90 billion, and it attributes that to an Instagram interview. The "
"same article states: 'Police have yet to share the amount of money lost from "
"Crypto Asset Accounts owned by AI.'",
"action": "Drop the $4.9M lower bound. Record the figure as US$5M reported via an "
"Instagram interview, with no police confirmation.",
"confirmed_by": "Bali Discovery, Bali Times, BeInCrypto, all read directly"},
{"target": "DB id 460", "field": "summary",
"current": "'Four crime scenes processed: Hedonist Restaurant, abduction point in Pecatu, "
"Villa Ukulele, hospital drop-off'",
"issue": "FABRICATED LIST. Sources say police investigated four locations but none of them "
"says which four. The bulk import invented the enumeration.",
"action": "Replace with 'police processed four crime scenes; the locations were not "
"specified in reporting'.",
"confirmed_by": "Bali Discovery names the count only; no source enumerates the sites"},
{"target": "DB id 460", "field": "weapons",
"current": "null",
"issue": "No source describes a weapon; the restraints were plastic handcuffs. But police "
"are pursuing the case under the Indonesian criminal code as kidnapping, physical "
"assault AND armed robbery, which implies one. The flag and the legal "
"classification disagree.",
"action": "Decide whether the flag follows what reporting describes or what the charge "
"implies, and apply that rule consistently across cases.",
"confirmed_by": "Bali Discovery"},
{"target": "DB id 462", "field": "url_2",
"current": "YouTube video titled around an 'empresário' (businessman)",
"issue": "Not a mismatch. Brazilian outlets describe this victim variously as an influencer "
"and as a businessman. Recorded so it is not re-investigated on every future pass.",
"action": "None. Leave as linked.", "confirmed_by": "Sofia"},
{"target": "DB id 462", "field": "weapons / notes",
"current": "weapons = 1, with no qualifier",
"issue": "The flag is correct, the attackers used the weapons to intimidate. But the weapons "
"carried during the kidnapping were imitation police props; a real firearm appears "
"only later, during the police chase.",
"action": "Keep weapons = 1 and add a note recording that the weapons used in the abduction "
"were imitation.", "confirmed_by": "Sofia"},
{"target": "sheet row 252", "field": "URL",
"current": "cryptonews.com/news/french-crypto-trader-kidnapped-near-paris-...",
"issue": "Points to a French case near Paris. The article saved in that row is a Phuket "
"robbery in Thailand. Trusting this URL would have filed the article under DB id 350.",
"action": "Re-find the correct source for the Phuket case.", "confirmed_by": "URL audit + article text"},
]
FIELD_ORDER = [
"id", "date", "original_date", "year", "month", "quarter", "victim", "location",
"country", "scenario", "description", "kidnappings", "violence_torture",
"drugs_alcohol", "weapons", "theft", "life_taken", "money_wanted", "coin_type",
"reports", "notes", "has_processed_date", "created_at",
]
def norm(s):
if s is None:
return ""
s = unicodedata.normalize("NFKD", str(s))
s = "".join(c for c in s if not unicodedata.combining(c))
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", s.lower())).strip()
def slug(s):
s = unicodedata.normalize("NFKD", str(s or "Unknown"))
s = "".join(c for c in s if not unicodedata.combining(c))
s = re.sub(r"[^A-Za-z0-9]+", "-", s).strip("-")
return (s[:40].rstrip("-")) or "Unknown"
def year_of(v):
try:
return int(float(v))
except (TypeError, ValueError):
return None
def load_sheet():
wb = openpyxl.load_workbook(XLSX, data_only=True, read_only=True)
ws = wb["reported_K&R"]
rows = list(ws.iter_rows(values_only=True))
idx = {h: i for i, h in enumerate(rows[0]) if h is not None}
out = []
for n, r in enumerate(rows[1:], start=2):
if not (r[idx["Victim"]] or r[idx["Date"]]):
continue
rec = {"_row": n}
for h, i in idx.items():
rec[h] = r[i]
out.append(rec)
wb.close()
return out
def articles_of(row):
out = []
for col, n in ART_COLS:
v = row.get(col)
if isinstance(v, str) and len(v.strip()) >= MIN_LEN:
out.append({"col": col, "n": n, "text": v})
return out
def build_mapping(sheet, records):
"""Unambiguous (victim, year) pairs, plus the reviewed approvals."""
s_key = defaultdict(list)
for r in sheet:
s_key[(norm(r.get("Victim")), year_of(r.get("Year")))].append(r)
j_key = defaultdict(list)
for r in records:
j_key[(norm(r.get("victim")), r.get("year"))].append(r)
mapping = {}
for k in set(s_key) & set(j_key):
if len(s_key[k]) == 1 and len(j_key[k]) == 1:
mapping[s_key[k][0]["_row"]] = j_key[k][0]["id"]
mapping.update(APPROVED)
# A DB record must not receive articles from two different sheet rows.
seen = defaultdict(list)
for row, jid in mapping.items():
seen[jid].append(row)
clashes = {j: rs for j, rs in seen.items() if len(rs) > 1}
if clashes:
raise SystemExit(f"ABORT: DB ids claimed by multiple sheet rows: {clashes}")
return mapping
def rule_for(row, jid):
return APPROVED_RULES.get(row, "exact match on victim + year, unique on both sides")
def render_fetched(entries):
"""Phase-2 blocks: articles retrieved from live sources rather than the spreadsheet."""
L = []
WITH_TEXT = ("KEEP", "PARTIAL", "UNTRIMMED")
keeps = [e for e in entries if e["verdict"] in WITH_TEXT]
others = [e for e in entries if e["verdict"] not in WITH_TEXT]
if others:
L += ["", "OTHER LINKED SOURCES"]
for e in others:
tail = f" — {e['note']}" if e.get("note") else ""
if e.get("of"):
tail = f" — syndication of {e['of']}" + (f". {e['note']}" if e.get("note") else "")
L.append(f" [{e['verdict']}] {e.get('field','')} {e.get('source')}{tail}")
L.append(f" {e.get('url')}")
for i, e in enumerate(keeps, 1):
flag = {"PARTIAL": " [PARTIAL — PAYWALLED]",
"UNTRIMMED": " [RAW EXTRACT — NOT YET TRIMMED]"}.get(e["verdict"], "")
auto = e["verdict"] == "KEEP" and str(e.get("trimmed_by", "")).startswith("auto")
if auto:
flag = " [AUTO-TRIMMED — UNREVIEWED]"
L += ["", "-" * 96, f"FETCHED ARTICLE {i}{flag}"]
if auto:
L += [" !! Furniture was cut by a script keyed on this case's record, not by a person.",
" !! It may still carry passages about OTHER cases from a round-up article.",
" !! The full page extract is kept in staging/ if a cut needs revisiting."]
if e["verdict"] == "UNTRIMMED":
L += [" !! This is the whole page as extracted. It still contains site furniture,",
" !! and may contain teaser headlines for UNRELATED cases. Trim on read-through.",
" !! Do not quote from it without checking the passage belongs to this case."]
L += [f" Source {e['source']}",
f" URL {e['url']}",
f" Field {e['field']}",
f" Retrieved {e.get('fetched_at')} via {e['method']} (HTTP {e.get('http')})",
f" Language {e['language']}",
f" Kept paragraphs {e['paragraphs_kept']}",
f" Length {e['chars']:,} chars",
f" Integrity sha256 {e['sha256']}"]
if e.get("cut_note"):
L.append(f" Trimmed {e['cut_note']}")
if e.get("note"):
L.append(f" Note {e['note']}")
if e.get("archive_url"):
L += [f" Archive {e['archive_url']}"]
L += [" Caveat retrieved from a Wayback Machine snapshot, not the live page. The",
" snapshot date is in the method line; the live URL was unreadable."]
else:
L += [" Caveat retrieved from a live page on the date above. Unlike text copied",
" from the spreadsheet, there is no second copy to hash it against."]
L += [
"-" * 96, "",
f"ORIGINAL ({e['language']}, verbatim as retrieved)", "", e["text"]]
if e.get("translation"):
L += ["", "ENGLISH TRANSLATION (derived by a model, not source text)", "",
e["translation"]]
elif e.get("translation_status") == "PENDING":
L += ["", "ENGLISH TRANSLATION — PENDING"]
return L
def render(rec, arts, notes, fetched=()):
L = []
hdr = f"CASE {rec['id']:03d} | {rec.get('victim')} | {rec.get('date')} | {rec.get('country')}"
L += ["=" * 96, hdr, "=" * 96, "", "DATABASE RECORD"]
for f in FIELD_ORDER:
L.append(f" {f:<20}{rec.get(f) if rec.get(f) is not None else '(null)'}")
L += [" (flag legend: 'kidnappings' is the DETENTION flag — 1 when the victim was held against",
" their will at any point, tied, locked in, held at gunpoint or taken away; it is NOT the",
" same as the 'Kidnapping' scenario, which means taken away and held. See README, Definitions.)"]
ov = SUMMARY_OVERRIDES.get(str(rec["id"]))
if ov:
L += ["", "AI SUMMARY [revised on review, verified against the sources in this file. "
"Live DB still holds the shorter bulk-import summary — apply this to it.]"]
for ln in str(ov).split("\n"):
L.append(f" {ln}")
elif rec.get("summary"):
L += ["", "AI SUMMARY [generated at bulk import, NOT verified against sources]"]
for ln in str(rec["summary"]).split("\n"):
L.append(f" {ln}")
L += ["", "LINKED SOURCES"]
any_url = False
for f in ("url", "url_2", "url_3", "url_4", "url_5"):
if rec.get(f):
L.append(f" [{f}] {rec[f]}")
any_url = True
if not any_url:
L.append(" (none recorded)")
if notes:
L += ["", "NOTES ON THIS FILE"]
for n in notes:
L.append(f" * {n}")
if not arts and not fetched:
L += ["", "-" * 96, "NO ARTICLE TEXT SAVED YET", "-" * 96]
for i, a in enumerate(arts, 1):
L += ["", "-" * 96, f"ARTICLE {i}"]
L += [f" Source {a['source']}", f" URL {a['url']}",
f" Origin {a['origin']}", f" Length {len(a['text']):,} chars"]
if a.get("trimmed"):
L.append(f" Trimmed site furniture removed on review; kept text is verbatim. "
f"Full original ({len(a['full_text']):,} chars) archived, sha256 "
f"{hashlib.sha256(a['full_text'].encode()).hexdigest()}")
if a.get("excerpt"):
L += [" Excerpt verbatim paragraphs relevant to THIS case, quoted from the full text below"]
L.append("-" * 96)
# Article and excerpt are written unaltered. No rewrapping, no indenting:
# the bytes here must match the spreadsheet cell so the manifest hash verifies.
if a.get("excerpt"):
L += ["", "RELEVANT EXCERPT", "", a["excerpt"], "", "FULL ARTICLE", ""]
L.append(a["text"])
L += render_fetched(fetched)
L += ["", "=" * 96,
f"generated from attacks-export-Gart-website.json + reported_K&R | case {rec['id']:03d}",
"=" * 96, ""]
return "\n".join(L)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--all", action="store_true")
ap.add_argument("--samples", action="store_true")
args = ap.parse_args()
sheet = load_sheet()
records = json.loads(JSONF.read_text(encoding="utf-8"))
store_path = BASE / "sourced-articles.json"
sourced = json.loads(store_path.read_text(encoding="utf-8")) if store_path.exists() else {}
by_row = {r["_row"]: r for r in sheet}
mapping = build_mapping(sheet, records)
salford_text = next(a["text"] for a in articles_of(by_row[SALFORD_SOURCE_ROW]))
salford_excerpt = {
jid: slice_between(salford_text, a, b, f"id {jid}")
for jid, (a, b) in SALFORD.items()
}
for jid, exc in salford_excerpt.items():
assert exc in salford_text, jid # true by construction; kept as a tripwire
articles_for = defaultdict(list)
notes_for = defaultdict(list)
for row, jid in mapping.items():
srow = by_row[row]
for a in articles_of(srow):
entry = {
"text": a["text"],
"source": srow.get("REPORTS") or "(source not recorded)",
"url": srow.get("URL") if a["n"] == 1 else "(no URL recorded for this column)",
"origin": f'reported_K&R sheet, row {row}, column "{a["col"]}"',
}
dec = SHEET_TRIM.get(f"{jid}:{a['col']}")
if dec:
entry["full_text"] = a["text"]
entry["text"] = apply_sheet_trim(a["text"], dec)
entry["trimmed"] = True
articles_for[jid].append(entry)
notes_for[jid].append(f"matched to sheet row {row}: {rule_for(row, jid)}")
for jid, exc in salford_excerpt.items():
if jid == 307:
articles_for[jid][0]["excerpt"] = exc
notes_for[jid].append("DB description and notes are WRONG on this record: they duplicate "
"incidents 1 and 2. This record is incident 4. Flagged for correction.")
else:
articles_for[jid].append({
"text": salford_text, "excerpt": exc,
"source": by_row[SALFORD_SOURCE_ROW].get("REPORTS") or "Manchester Evening News",
"url": by_row[SALFORD_SOURCE_ROW].get("URL"),
"origin": f"reported_K&R sheet, row {SALFORD_SOURCE_ROW}, column \"Articles\" "
f"(one article documenting 5 linked incidents)",
})
notes_for[jid].append("part of a 5-incident series against one victim (ids 354, 355, 162, 307, 169)")
OUT.mkdir(exist_ok=True)
SAMPLES.mkdir(exist_ok=True)
target = SAMPLES if args.samples else OUT
wanted = {32, 307} if args.samples else None
manifest, written = [], 0
for rec in records:
if wanted and rec["id"] not in wanted:
continue
arts = articles_for.get(rec["id"], [])
fetched = sourced.get(str(rec["id"]), [])
body = render(rec, arts, notes_for.get(rec["id"], []), fetched)
name = f"{rec['id']:03d}_{slug(rec.get('victim'))}.txt"
path = target / name
path.write_text(body, encoding="utf-8")
written += 1
# Read the file back off disk and prove every article survived unaltered.
on_disk = path.read_text(encoding="utf-8")
for e in fetched:
if e["verdict"] not in ("KEEP", "PARTIAL", "UNTRIMMED"):
continue
if e["text"] not in on_disk:
raise SystemExit(f"ABORT: fetched article altered in {name} ({e['url']})")
if e.get("translation") and e["translation"] not in on_disk:
raise SystemExit(f"ABORT: translation altered in {name} ({e['url']})")
manifest.append({"case": rec["id"], "file": name, "chars": e["chars"],
"sha256": e["sha256"], "origin": f"fetched {e['url']}"})
for a in arts:
if a["text"] not in on_disk:
raise SystemExit(f"ABORT: article text altered in {name} ({a['origin']})")
if a.get("excerpt") and a["excerpt"] not in on_disk:
raise SystemExit(f"ABORT: excerpt altered in {name}")
# sha256 stays the FULL-cell hash so the phase-1 provenance check keeps verifying
# against the spreadsheet, even when the embedded copy has furniture trimmed off.
provenance = a.get("full_text", a["text"])
m = {"case": rec["id"], "file": name, "chars": len(provenance),
"sha256": hashlib.sha256(provenance.encode()).hexdigest(),
"origin": a["origin"]}
if a.get("trimmed"):
m["trimmed"] = True
m["infile_sha256"] = hashlib.sha256(a["text"].encode()).hexdigest()
m["infile_chars"] = len(a["text"])
manifest.append(m)
if not args.samples:
(BASE / "case-files-manifest.json").write_text(
json.dumps({"files": written, "articles": len(manifest), "entries": manifest},
ensure_ascii=False, indent=2), encoding="utf-8")
(BASE / "corrections-for-database.json").write_text(
json.dumps(CORRECTIONS, ensure_ascii=False, indent=2), encoding="utf-8")
r252 = by_row[252]
missing = [{
"sheet_row": 252,
"victim": r252.get("Victim"),
"date": str(r252.get("Date")),
"location": r252.get("Location"),
"country": r252.get("Country"),
"scenario": r252.get("Scenario"),
"description": r252.get("Description"),
"recorded_url": r252.get("URL"),
"url_warning": "This URL is WRONG. It points to a French case near Paris "
"(DB id 350, Alexandre). The saved article is a Phuket robbery. "
"The correct source needs to be re-found.",
"why_missing": "No record in the 364-row export matches this event. Nearest "
"Thailand case is id 204 (January 5 2025, Phuket), a different incident.",
"article_text": next((a["text"] for a in articles_of(r252)), None),
}]
(BASE / "cases-missing-from-database.json").write_text(
json.dumps(missing, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"corrections logged : {len(CORRECTIONS)}")
print(f"cases absent from the database : {len(missing)}")
print(f"sheet rows matched to a DB record : {len(mapping)}")
print(f"DB records receiving article text : {len(articles_for)}")
print(f"files written : {written} -> {target}")
print(f"article bodies embedded : {len(manifest)}")
if __name__ == "__main__":
main()