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
539 lines
26 KiB
Python
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()
|