Files
game-library/scripts/repair_extractions.py
Claude Agent bcfb6841eb Faza 1 complete: bilingual+enrichment plumbing, UI/filters, frozen DB
Extraction finished (575/588 chunks; 6 content-filter-blocked, 7 await
re-extraction). DB rebuilt and frozen at 9418 activities — content_keys
are now stable for the enrichment overlay.

Part A (plumbing + UI):
- database.py: name_ro/description_ro/rules_ro/variations_ro, indoor_outdoor,
  space_needed, estimated_fields, source_id/source_ids/chunk_key columns;
  FTS5 indexes the 4 *_ro columns across CREATE + all 3 triggers; new equality
  filters + category counts for both axes.
- activity.py: new fields + bilingual display helpers (get_display_*,
  is_estimated, axis displays).
- config_taxonomy.py: INDOOR_OUTDOOR/SPACE_NEEDED enums + normalizers
  (None on unrecognised, no fabrication).
- search.py / routes.py / config.py / templates / css: new dropdowns,
  RO-primary rendering with "(estimat)" markers and collapsible original
  text, and a /source/<id> download route shipped DARK behind
  SOURCE_DOWNLOAD_ENABLED (copyright opt-in).
- build_database.py: source_id/chunk_key in dict_to_activity; merge_cluster
  unions source_ids without touching enrichment fields.

Part B (enrichment pipeline, built not yet run):
- build_database.py: load_enrichment + apply_enrichment (post-dedup, keyed on
  content_key) + --enrichment CLI + stated-vs-estimated QA.
- run_enrichment.py (resumable, --source/--limit pilot scoping, --collect),
  ENRICHMENT_PROMPT.md.

Repair: scripts/repair_extractions.py fixes the subagents' systematic
unescaped-ASCII-quote bug with a faithful char-scanner (escapes, never
truncates) + schema validation + a strictly-more-text guard. json_repair was
tried first, truncated silently, and is NOT used. build_database has no repair
dependency.

Tests: tests/test_enrichment.py added; 99 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:10:13 +00:00

245 lines
8.7 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
repair_extractions.py — one-shot repair of malformed extraction JSON.
Subagents systematically emit unescaped ASCII double-quotes inside string
values (Romanian text like „Unu" uses a closing " that terminates the JSON
string early). Re-extraction reproduces the bug, so we repair instead.
IMPORTANT — why NOT json_repair: json_repair "recovers" an unescaped quote by
ending the string at the stray quote and reinterpreting the trailing text as a
new key, which (a) TRUNCATES the value and (b) injects garbage keys. The
truncation is silent (the field is still non-empty) and slips past a naive
presence check. So we use a faithful char-scanner that ESCAPES stray quotes
(\\") instead of splitting on them, then validate the result against the real
activity schema (additionalProperties:false also catches any residual split).
This is an OFFLINE maintenance tool. build_database.py must NOT depend on it —
the "DB regenerable from data/extracted/" invariant requires plain valid JSON on
disk. We write clean JSON back to data/extracted/ and the build reads vanilla
json.
Source selection (faithful recovery needs the ORIGINAL malformed text):
* a chunk is a candidate when a MALFORMED original exists — either the
top-level data/extracted/<key>.json is itself invalid, or a malformed
original sits in data/extracted/_rejected/<key>.json.
* the malformed original is preferred as the repair source.
* chunks whose only artifact is already-valid JSON (e.g. a prior json_repair
output that lost the original) are NOT silently "repaired" — if such a chunk
has no valid top-level file it is reported as needing RE-EXTRACTION.
Usage:
python scripts/repair_extractions.py # report only (dry run)
python scripts/repair_extractions.py --apply # write repaired JSON
"""
from __future__ import annotations
import argparse
import glob
import json
from pathlib import Path
from typing import Optional
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
EXTRACTED = REPO_ROOT / "data" / "extracted"
REJECTED = EXTRACTED / "_rejected"
if str(SCRIPT_DIR) not in __import__("sys").path:
__import__("sys").path.insert(0, str(SCRIPT_DIR))
from import_common import DEFAULT_SCHEMA_PATH, load_schema, validate_extraction # noqa: E402
def escape_stray_quotes(s: str) -> str:
"""Escape ASCII double-quotes that occur INSIDE a JSON string value.
A `"` inside a string is treated as a real string-close only when the next
non-whitespace char is structural (`,` `}` `]` `:`) or EOF; otherwise it is
content and is escaped to `\\"`. This preserves the full value instead of
truncating it (the json_repair failure mode).
"""
out: list[str] = []
in_str = False
esc = False
n = len(s)
i = 0
while i < n:
c = s[i]
if esc:
out.append(c)
esc = False
i += 1
continue
if c == "\\":
out.append(c)
esc = True
i += 1
continue
if c == '"':
if not in_str:
in_str = True
out.append(c)
else:
j = i + 1
while j < n and s[j] in " \t\r\n":
j += 1
nxt = s[j] if j < n else ""
if nxt in ",}]:" or nxt == "":
in_str = False
out.append(c)
else:
out.append('\\"') # content quote → escape, keep value whole
i += 1
continue
out.append(c)
i += 1
return "".join(out)
def _is_valid_json(path: Path) -> bool:
try:
json.loads(path.read_text(encoding="utf-8"))
return True
except (json.JSONDecodeError, OSError):
return False
def _malformed_source(key: str) -> Optional[Path]:
"""Return the malformed-original file for a chunk, preferring top-level."""
live = EXTRACTED / f"{key}.json"
if live.exists() and not _is_valid_json(live):
return live
rej = REJECTED / f"{key}.json"
if rej.exists() and not _is_valid_json(rej):
return rej
return None
def _candidate_keys() -> tuple[dict[str, Path], list[str]]:
"""
(repair_candidates, needs_reextraction).
repair_candidates: key -> malformed source file (faithfully repairable).
needs_reextraction: chunks with no malformed original AND no valid
top-level file (their original was lost) — must be re-extracted.
"""
keys = set()
for fn in glob.glob(str(EXTRACTED / "*.json")):
keys.add(Path(fn).stem)
for fn in glob.glob(str(REJECTED / "*.json")):
keys.add(Path(fn).stem)
candidates: dict[str, Path] = {}
needs_reextraction: list[str] = []
for key in sorted(keys):
# A malformed original anywhere is faithfully repairable, and is the
# source of truth even if a (json_repair-produced, possibly truncated)
# valid top-level file exists — escaping the original never truncates,
# so re-repairing from it is always >= the json_repair output.
src = _malformed_source(key)
if src is not None:
candidates[key] = src
continue
live = EXTRACTED / f"{key}.json"
if live.exists() and _is_valid_json(live):
continue # genuinely-valid extraction, nothing to do
# no valid top-level and no malformed original to repair from
needs_reextraction.append(key)
return candidates, needs_reextraction
def repair(apply: bool) -> int:
schema = load_schema(DEFAULT_SCHEMA_PATH)
candidates, needs_reextraction = _candidate_keys()
print("=" * 64)
print(f"REPAIR EXTRACTIONS ({'APPLY' if apply else 'dry run'})")
print("=" * 64)
print(f"repair candidates: {len(candidates)}")
def _textlen(data: dict) -> int:
total = 0
for a in data.get("activities", []):
if isinstance(a, dict):
for v in a.values():
if isinstance(v, str):
total += len(v)
return total
ok = 0
kept_toplevel = 0
still_bad: list[str] = []
schema_fail: list[tuple[str, str]] = []
for key, src in candidates.items():
live = EXTRACTED / f"{key}.json"
live_valid = live.exists() and _is_valid_json(live)
raw = src.read_text(encoding="utf-8")
fixed = escape_stray_quotes(raw)
try:
data = json.loads(fixed)
except json.JSONDecodeError as exc:
if live_valid:
kept_toplevel += 1 # genuine top-level is fine; stale _rejected
else:
still_bad.append(f"{key}: still invalid after escape ({exc})")
continue
errors = validate_extraction(data, schema)
if errors:
if live_valid:
kept_toplevel += 1
else:
schema_fail.append((key, errors[0]))
print(f" {key[:50]:<50} SCHEMA-FAIL: {errors[0][:40]}")
continue
# Faithfulness guard: only replace a valid top-level when the escaped
# repair carries STRICTLY more text (i.e. the top-level was a truncated
# json_repair output). Genuine extractions are kept untouched.
if live_valid:
try:
live_data = json.loads(live.read_text(encoding="utf-8"))
except json.JSONDecodeError:
live_data = {}
if _textlen(data) <= _textlen(live_data):
kept_toplevel += 1
continue
n = len(data.get("activities", []))
print(f" {key[:50]:<50} {n:>3} acts REPAIR")
if apply:
live.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
ok += 1
print("-" * 64)
print(f"repaired: {ok} | kept genuine top-level: {kept_toplevel} | "
f"schema-fail: {len(schema_fail)} | still-bad: {len(still_bad)} | "
f"needs re-extraction: {len(needs_reextraction)}")
for key, err in schema_fail:
print(f" ⚠ schema {key}: {err[:60]}")
for msg in still_bad:
print(f"{msg}")
for key in needs_reextraction:
print(f" ↻ re-extract: {key}")
if not apply:
print("\nDry run — re-run with --apply to write repaired JSON.")
print("=" * 64)
return 0
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Repair malformed extraction JSON.")
parser.add_argument("--apply", action="store_true",
help="write repaired JSON (default: dry run)")
args = parser.parse_args(argv)
return repair(args.apply)
if __name__ == "__main__":
raise SystemExit(main())