"""s24 — 공간 단서 사전 기반 수정 wave (2026-07-07 야간 사용자 지시 + Codex 의논
비판적 수용).

배경: 씬 11개 원문 전수 통독 사전(plans/spatial_evidence_v1.md — 확정 단서
18·발명 5·일관성 체인)이 SOT. 현 자산의 문제는 구체화 부족이 아니라 LLM
구체화가 단서 체인이 아닌 무드 발명(안개·앰버·콘크리트·치수·wet)으로 샌 것.

설계(Codex 3포인트 수용 + 과지정 경고 수용):
  ① graph  = 사전 md+사용자 정정을 **일반 스키마** evidence graph 로 구조화
     (evidence_id/quote/source/claim_type 3종/affected_shots/confidence/
     허용 추정·금지 모드). 코드=일반 스키마 처리만, 구체 내용=데이터 주입.
  ② audit  = 실외 샷별 (shot description + staging + remake 확정 스틸
     프롬프트) + s22 관계 데이터를 graph 와 대조해 LLM 의미 분류 4종:
     supported / plausible_inference(감독식 구체화 — 보존) /
     unsupported_invention / contradiction(정정·확정 단서와 충돌).
     인용(exact quote)+evidence_id+근거 필수. 코드의 글자 단위 판단 0.
  ③ chain  = 사전의 일관성 체인 링크를 LLM 이 s22 요소 id 쌍으로 매핑 →
     **결정론 코드**(s22.solve 좌표 재사용)가 접촉 간극 검증.
  ④ repair = unsupported_invention + contradiction **만** 수정(좋은 감독식
     구체화는 보존): 스틸 프롬프트=최소 치환(edit 마다 before_quote 실재
     assert, 코드=적용·검증만) → nb2 스틸 재생성 3열 비교 / staging·desc =
     정정 팩(적용하지 않고 provenance 와 함께 보존 — override 패턴).
  ⑤ 계약 명문화 = EVIDENCE_BOUND_CLAUSE(원문 단서 우선+구체화 경계, 생성측
     주입은 단서 요약+금지 모드 목록만 — 과지정 방지). production 이식은
     실험 완성 후 사용자 GO 별도.
사용: backend/.venv/bin/python s24_evidence.py \
        [--only all|graph|audit|chain|repair|still|html]
산출: plans/spatial_evidence_graph_v1.json / spatial_audit_v1.json /
      spatial_chaincheck_v1.json / spatial_repair_v1.json,
      out/shots_outdoor/repair/<샷>_repairstill.png, evidence.html (8897)
DB write 0, 커밋 금지(scratchpad). 육안 판정=사용자.
"""
import argparse
import html as _html
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
import forest_lib as F  # noqa: E402
import s14_blockgen as S14  # noqa: E402  (사용자 정정 override 재사용)
import s22_bd_code as S22  # noqa: E402  (솔버 좌표 재사용 — 체인 검증)

ROOT = Path("/Users/manta/Documents/Projects/TheRoad-I1")
EXP = Path(__file__).parent
OUT = F.OUT / "shots_outdoor" / "repair"
PAGE = EXP / "evidence.html"
EVIDENCE_MD = F.PLANS / "spatial_evidence_v1.md"

CLAIM_TYPES = ["confirmed_cue", "allowed_inference", "prohibited_invention"]
CATEGORIES = ["supported", "plausible_inference", "unsupported_invention",
              "contradiction"]
TARGETS = ["shot_description", "staging", "still_prompt", "layout_data"]

# ── ⑤ 계약 명문화 — 추출·staging 저작 계약에 넣을 일반 조항 (goal 3) ──
# 생성측 주입은 이 조항 + 단서 요약 줄 + 금지 발명 모드 목록까지만 (과지정
# 방지 — 모든 공간을 숫자로 고정하는 것이 아니라 "발명 금지"가 핵심).
EVIDENCE_BOUND_CLAUSE = "\n".join([
    "EVIDENCE-BOUND AUTHORING: every concrete claim about the world —",
    "spatial relations, materials, weather, atmosphere, light colour,",
    "surface condition, numeric dimensions — must either (a) restate a",
    "provided evidence entry (cite its evidence_id), or (b) be a staging",
    "concretization DIRECTLY derivable from one (mark it inferred).",
    "Creator corrections override any conflicting downstream text.",
    "NEVER introduce weather effects, colour moods, surface conditions,",
    "materials or numeric dimensions that no evidence entry supports —",
    "when the evidence is silent, stay silent and leave that choice to",
    "the image model.",
])


# ── ① evidence graph — 사전 md → 일반 스키마 (판단=LLM, 내용=데이터) ──

GRAPH_SYSTEM = """You are a continuity supervisor structuring a human-written
SPATIAL EVIDENCE MEMO about one real filming location into a machine-checkable
evidence graph. The memo is the source of truth: it lists (A) confirmed cues
quoted from the original scenario scenes, (B) verdicts on downstream staging
including inventions to fix, and (C) a consistency chain of connected spaces.
You also get CREATOR CORRECTIONS (they override any conflicting text) and the
list of shot keys in scope.

Emit every distinct fact as one claim:
- claim_type "confirmed_cue": directly quoted/derived from the original
  scenario per the memo. quote = the memo's original-text quotation (keep the
  original language), source = the scene/shot reference given in the memo.
- claim_type "allowed_inference": ranges and estimates the memo allows
  (distances, heights, counts). These are VALIDATION bounds, not hard
  geometry — allowed_inference_en states the allowed range/relation.
- claim_type "prohibited_invention": each invention the memo marks for
  correction. quote = the invented wording, source = where it appears,
  prohibited_modes_en = a GENERIC description of the invention mode (e.g.
  "weather/fog invention", "colour-mood propagation", "material invention
  overridden by creator correction", "numeric dimension invention",
  "surface condition without textual support") so the mode generalizes
  beyond this location.
- affected_space_en: the place part the claim constrains (short generic
  words). affected_shots: every given shot key the claim bears on ([] if
  none specifically).
- confidence: high/medium/low per how directly the memo grounds it.
- summary_ko: one short Korean sentence.

chain: one link per arrow of the memo's consistency chain, in order —
from_space_en / to_space_en (short generic words), relation_en (how they
connect physically: adjoins / opens-into / rises-to / sightline etc.),
claim_refs = evidence_ids of the claims grounding the link.

Do not invent facts absent from the memo/corrections. notes_ko: 2-4 Korean
sentences on how this graph should gate downstream authoring."""

_STR = {"type": "string"}
GRAPH_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {
        "claims": {"type": "array", "items": {
            "type": "object", "additionalProperties": False,
            "properties": {
                "evidence_id": _STR,
                "claim_type": {"type": "string", "enum": CLAIM_TYPES},
                "quote": _STR, "source": _STR, "summary_ko": _STR,
                "affected_space_en": _STR,
                "affected_shots": {"type": "array", "items": _STR},
                "confidence": {"type": "string",
                               "enum": ["high", "medium", "low"]},
                "allowed_inference_en": _STR,
                "prohibited_modes_en": _STR},
            "required": ["evidence_id", "claim_type", "quote", "source",
                         "summary_ko", "affected_space_en", "affected_shots",
                         "confidence", "allowed_inference_en",
                         "prohibited_modes_en"]}},
        "chain": {"type": "array", "items": {
            "type": "object", "additionalProperties": False,
            "properties": {
                "link_id": _STR, "from_space_en": _STR, "to_space_en": _STR,
                "relation_en": _STR,
                "claim_refs": {"type": "array", "items": _STR},
                "summary_ko": _STR},
            "required": ["link_id", "from_space_en", "to_space_en",
                         "relation_en", "claim_refs", "summary_ko"]}},
        "notes_ko": _STR,
    },
    "required": ["claims", "chain", "notes_ko"],
}


def run_graph(shot_keys):
    ov = S14.load_overrides()
    user = "\n\n".join([
        "SPATIAL EVIDENCE MEMO (full, source of truth):\n"
        + EVIDENCE_MD.read_text(encoding="utf-8"),
        "CREATOR CORRECTIONS (override any conflicting text):\n"
        + json.dumps(ov, ensure_ascii=False, indent=1),
        "SHOT KEYS IN SCOPE:\n" + "\n".join(f"- {k}" for k in shot_keys),
        "Build the evidence graph now.",
    ])
    graph = F.llm("s24_evidence_graph", GRAPH_SYSTEM, user, GRAPH_SCHEMA,
                  model="gpt")
    F.save_plan("spatial_evidence_graph_v1", graph)
    counts = {t: sum(1 for c in graph["claims"] if c["claim_type"] == t)
              for t in CLAIM_TYPES}
    print(f"graph: claims {len(graph['claims'])} {counts} | chain"
          f" {len(graph['chain'])}")
    return graph


def graph_pack(graph, full=False):
    """LLM 입력용 graph 직렬화 — full=False 는 생성측 최소 주입(요약+금지)."""
    if full:
        return json.dumps({"claims": graph["claims"],
                           "chain": graph["chain"]}, ensure_ascii=False,
                          indent=1)
    lines = []
    for c in graph["claims"]:
        if c["claim_type"] == "prohibited_invention":
            lines.append(f"[{c['evidence_id']}] PROHIBITED"
                         f" ({c['prohibited_modes_en']}): \"{c['quote']}\""
                         f" — {c['summary_ko']}")
        else:
            extra = (f" | allowed: {c['allowed_inference_en']}"
                     if c["allowed_inference_en"] else "")
            lines.append(f"[{c['evidence_id']}] {c['claim_type']}"
                         f" ({c['affected_space_en']}): \"{c['quote']}\""
                         f" ({c['source']}){extra}")
    return "\n".join(lines)


# ── ② audit — 4분류 의미 검증 (LLM, 인용+evidence_id 필수) ──

AUDIT_SYSTEM = """You are a continuity auditor. You get an EVIDENCE GRAPH
(claims with evidence_ids: confirmed cues, allowed inference bounds,
prohibited inventions; creator corrections override everything else) and one
or more downstream ARTIFACTS for one shot or one layout dataset.

Find every CONCRETE world claim in the artifacts — spatial relation,
material, weather/atmosphere, light colour/mood, surface condition, numeric
dimension — and classify each against the graph:
- "supported": restates a confirmed cue (cite the evidence_ids).
- "plausible_inference": not directly evidenced but a reasonable directorial
  concretization inside the allowed bounds and contradicting nothing. These
  are GOOD — they must be kept.
- "unsupported_invention": adds weather, mood colour, material, surface
  condition or dimension that no claim supports (match a prohibited mode
  where one applies).
- "contradiction": conflicts with a confirmed cue or a creator correction.

Rules: claim_quote = an EXACT substring copied from the artifact (never
paraphrase). location_en = which artifact field/part it sits in. Cite
evidence_ids for the claims you compared against ([] only for pure
inventions with no related claim). Ignore pure framing/lens/composition
language unless it asserts a world fact. Do not flag the same fact twice in
the same artifact unless the wording differs materially. repair_hint_en:
one short sentence on the minimal fix (only for unsupported_invention /
contradiction; "" otherwise). reasoning_ko: 한 줄 한국어 근거.
summary_ko: 2-3 문장 한국어 총평."""

AUDIT_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {
        "findings": {"type": "array", "items": {
            "type": "object", "additionalProperties": False,
            "properties": {
                "target": {"type": "string", "enum": TARGETS},
                "location_en": _STR, "claim_quote": _STR,
                "category": {"type": "string", "enum": CATEGORIES},
                "evidence_ids": {"type": "array", "items": _STR},
                "reasoning_ko": _STR, "repair_hint_en": _STR},
            "required": ["target", "location_en", "claim_quote", "category",
                         "evidence_ids", "reasoning_ko", "repair_hint_en"]}},
        "summary_ko": _STR,
    },
    "required": ["findings", "summary_ko"],
}


def run_audit(graph, recon, collect, remake_jobs, rel, audit=None):
    audit = audit or {"shots": {}, "layout": None}
    gp = graph_pack(graph, full=True)
    for k in sorted(collect["shots"].keys()):
        if audit["shots"].get(k):
            continue
        s = collect["shots"][k]
        staging = (recon["shots"].get(k) or {}).get("staging")
        parts = [
            "EVIDENCE GRAPH:\n" + gp,
            f"ARTIFACT target=shot_description ({k}):\n"
            f"scene_heading: {s.get('scene_heading')}\n"
            f"description: {s.get('description')}",
            f"ARTIFACT target=staging ({k}) — full staging JSON:\n"
            + json.dumps(staging, ensure_ascii=False, indent=1),
        ]
        if k in remake_jobs:
            parts.append(f"ARTIFACT target=still_prompt ({k}) — the full"
                         " image prompt actually used:\n"
                         + remake_jobs[k]["prompt"])
        parts.append("Audit every concrete world claim now.")
        res = F.llm(f"s24_audit_{k}", AUDIT_SYSTEM, "\n\n".join(parts),
                    AUDIT_SCHEMA, model="gpt")
        audit["shots"][k] = res
        F.save_plan("spatial_audit_v1", audit)
        bad = sum(1 for f in res["findings"] if f["category"] in
                  ("unsupported_invention", "contradiction"))
        print(f"audit {k}: findings {len(res['findings'])} (수정대상 {bad})")
    if not audit.get("layout"):
        user = "\n\n".join([
            "EVIDENCE GRAPH:\n" + gp,
            "ARTIFACT target=layout_data — relational block layout of the"
            " same location (elements with desc_ko + evidence quotes,"
            " connections):\n"
            + json.dumps({"elements": [
                {kk: e[kk] for kk in ("id", "name_en", "desc_ko", "kind",
                                      "parent", "evidence")}
                for e in rel["elements"]],
                "connections": rel.get("connections") or []},
                ensure_ascii=False, indent=1),
            "Audit every concrete world claim now (location_en = element id"
            " + field).",
        ])
        res = F.llm("s24_audit_layout", AUDIT_SYSTEM, user, AUDIT_SCHEMA,
                    model="gpt")
        audit["layout"] = res
        F.save_plan("spatial_audit_v1", audit)
        print(f"audit layout: findings {len(res['findings'])}")
    return audit


# ── ③ chain — 사전 체인 → s22 요소 매핑(LLM) + 접촉 검증(결정론) ──

CHAINMAP_SYSTEM = """You get the consistency CHAIN LINKS of a location's
evidence graph and the ELEMENT LIST of a relational block layout of the same
location. For each link pick the layout element ids that embody its two
spaces (a_id = from side, b_id = to side). If a side has no matching element
set mappable=false with the ids "" and explain why in reason_ko. Pure
sightline links map the two elements whose line of sight the link asserts.
If a space corresponds to SEVERAL elements, do not mark it unmappable —
pick the single element that the link's grounding claims name (note the
choice in reason_ko). Never invent element ids — use only the given list."""

CHAINMAP_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {"links": {"type": "array", "items": {
        "type": "object", "additionalProperties": False,
        "properties": {"link_id": _STR, "a_id": _STR, "b_id": _STR,
                       "mappable": {"type": "boolean"},
                       "sightline": {"type": "boolean"},
                       "reason_ko": _STR},
        "required": ["link_id", "a_id", "b_id", "mappable", "sightline",
                     "reason_ko"]}}},
    "required": ["links"],
}


def _gap(ra, rb):
    ax, ay, aw, ah = ra
    bx, by, bw, bh = rb
    gx = max(0.0, max(bx - (ax + aw), ax - (bx + bw)))
    gy = max(0.0, max(by - (ay + ah), ay - (by + bh)))
    return max(gx, gy)


def run_chain(graph, rel):
    els = [{k: e[k] for k in ("id", "name_en", "desc_ko", "kind", "parent")}
           for e in rel["elements"]]
    user = "\n\n".join([
        "CHAIN LINKS:\n" + json.dumps(graph["chain"], ensure_ascii=False,
                                      indent=1),
        "ELEMENT LIST:\n" + json.dumps(els, ensure_ascii=False, indent=1),
        "Map every link now.",
    ])
    mapping = F.llm("s24_chain_map", CHAINMAP_SYSTEM, user, CHAINMAP_SCHEMA,
                    model="gpt")
    _, rects, structural = S22.solve(rel)  # 결정론 좌표 재사용
    by_link = {l["link_id"]: l for l in graph["chain"]}
    rows = []
    for m in mapping["links"]:
        row = {**m, "summary_ko": (by_link.get(m["link_id"]) or {}).get(
            "summary_ko", ""), "gap": None, "touching": None}
        if m["mappable"] and m["a_id"] in rects and m["b_id"] in rects:
            g = _gap(rects[m["a_id"]], rects[m["b_id"]])
            # 사전 체인=물리 연결 검증(간극<=1), 시선 링크=존재만 확인
            row["gap"] = round(g, 2)
            row["touching"] = bool(m["sightline"]) or g <= 1.0
        rows.append(row)
    check = {"links": rows, "structural": structural}
    F.save_plan("spatial_chaincheck_v1", check)
    ok = sum(1 for r in rows if r["touching"])
    print(f"chain: {ok}/{len(rows)} 링크 통과 (미매핑/실패 "
          f"{[r['link_id'] for r in rows if not r['touching']]})")
    return check


# ── ④ repair — invention/contradiction 만 최소 수정 (좋은 구체화 보존) ──

REPAIR_SYSTEM = """You are a continuity repair editor. You get the location's
evidence summary (with prohibited invention modes), creator corrections, one
shot's downstream artifacts, and the audit FINDINGS for that shot restricted
to categories unsupported_invention / contradiction. Fix ONLY those findings
— every supported claim and every plausible directorial concretization must
survive verbatim.

""" + EVIDENCE_BOUND_CLAUSE + """

Output two things:
1. prompt_edits — minimal text substitutions for the STILL PROMPT (if one is
   given): each edit has before_quote = an EXACT substring of the current
   prompt (long enough to be unambiguous, usually the full clause) and
   after_en = its replacement ("" to delete the clause; rewrite the clause
   so the sentence still reads naturally). Touch nothing outside the flagged
   claims; never add new specifics the evidence does not support.
2. staging_corrections — for findings in shot_description/staging: DO NOT
   rewrite those artifacts; emit a correction entry per finding with
   field_en (which field/part), before_quote (exact), after_en (the minimal
   corrected wording), evidence_ids, reason_ko. These are stored as a
   correction pack applied when a lane next consumes that data.
notes_ko: 2-3 문장 한국어 — 무엇을 왜 고쳤고 무엇을 일부러 남겼는지."""

REPAIR_SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {
        "prompt_edits": {"type": "array", "items": {
            "type": "object", "additionalProperties": False,
            "properties": {"before_quote": _STR, "after_en": _STR,
                           "evidence_ids": {"type": "array", "items": _STR},
                           "reason_ko": _STR},
            "required": ["before_quote", "after_en", "evidence_ids",
                         "reason_ko"]}},
        "staging_corrections": {"type": "array", "items": {
            "type": "object", "additionalProperties": False,
            "properties": {"field_en": _STR, "before_quote": _STR,
                           "after_en": _STR,
                           "evidence_ids": {"type": "array", "items": _STR},
                           "reason_ko": _STR},
            "required": ["field_en", "before_quote", "after_en",
                         "evidence_ids", "reason_ko"]}},
        "notes_ko": _STR,
    },
    "required": ["prompt_edits", "staging_corrections", "notes_ko"],
}


def run_repair(graph, audit, recon, collect, remake_jobs, repair=None):
    repair = repair or {"provenance": "s24 evidence repair v1 — 사전 SOT"
                        " audit 의 unsupported_invention/contradiction 만"
                        " 정정, plausible_inference 보존", "shots": {}}
    gp_min = graph_pack(graph, full=False)
    ov = S14.load_overrides()
    corrections = "\n".join(ov.get("facts_extra") or [])
    for k in sorted(audit["shots"].keys()):
        if repair["shots"].get(k):
            continue
        flagged = [f for f in audit["shots"][k]["findings"]
                   if f["category"] in ("unsupported_invention",
                                        "contradiction")]
        if not flagged:
            continue
        s = collect["shots"][k]
        staging = (recon["shots"].get(k) or {}).get("staging")
        parts = [
            "EVIDENCE SUMMARY:\n" + gp_min,
            "CREATOR CORRECTIONS (override everything):\n" + corrections,
            f"SHOT ARTIFACTS ({k}):\n"
            f"shot_description: {s.get('description')}\n\n"
            "staging JSON:\n"
            + json.dumps(staging, ensure_ascii=False, indent=1),
        ]
        if k in remake_jobs:
            parts.append("STILL PROMPT (current, full):\n"
                         + remake_jobs[k]["prompt"])
        parts.append("AUDIT FINDINGS TO FIX (only these):\n"
                     + json.dumps(flagged, ensure_ascii=False, indent=1))
        parts.append("Produce the minimal repairs now.")
        res = F.llm(f"s24_repair_{k}", REPAIR_SYSTEM, "\n\n".join(parts),
                    REPAIR_SCHEMA, model="gpt")
        entry = {"findings_fixed": flagged, **res}
        # 프롬프트 치환 적용 — 코드=적용·검증만 (before_quote 실재 assert)
        if k in remake_jobs and res["prompt_edits"]:
            prompt = remake_jobs[k]["prompt"]
            for e in res["prompt_edits"]:
                assert e["before_quote"] in prompt, (
                    f"{k}: before_quote 미실재 — {e['before_quote'][:80]!r}")
                prompt = prompt.replace(e["before_quote"], e["after_en"])
            assert prompt != remake_jobs[k]["prompt"], f"{k}: 프롬프트 무변경"
            entry["repaired_prompt"] = prompt
        repair["shots"][k] = entry
        F.save_plan("spatial_repair_v1", repair)
        print(f"repair {k}: prompt_edits {len(res['prompt_edits'])} |"
              f" staging_corrections {len(res['staging_corrections'])}")
    return repair


def run_still(remake, repair):
    """수리 프롬프트로 nb2 스틸 재생성 — 참조 구성=remake 확정본 그대로."""
    bg = Path(remake["new_bg"])
    jobs = {j["shot_key"]: j for j in remake["jobs"]}
    for k, entry in sorted(repair["shots"].items()):
        if not entry.get("repaired_prompt") or k not in jobs:
            continue
        labeled = []
        for r in jobs[k]["refs"]:
            if r.get("dropped"):
                continue
            path = bg if r.get("swapped") else ROOT / r["file_path"]
            labeled.append((r["label"], path))
        F.img_nb2(f"s24_repairstill_{k}", entry["repaired_prompt"], labeled,
                  out_path=OUT / f"{k}_repairstill.png")


# ── 갤러리 ──

CAT_STYLE = {"supported": "sup", "plausible_inference": "inf",
             "unsupported_invention": "inv", "contradiction": "con"}
CAT_KO = {"supported": "원문 지지", "plausible_inference": "허용 구체화(보존)",
          "unsupported_invention": "무근거 발명", "contradiction": "모순"}


def _esc(t):
    return _html.escape(str(t or ""))


def _findings_table(findings):
    rows = "".join(
        f"<tr class='{CAT_STYLE[f['category']]}'>"
        f"<td>{_esc(f['target'])}<br><span class='mini'>"
        f"{_esc(f['location_en'])}</span></td>"
        f"<td>{_esc(f['claim_quote'])}</td>"
        f"<td><b>{_esc(CAT_KO[f['category']])}</b></td>"
        f"<td class='mini'>{_esc(', '.join(f['evidence_ids']))}</td>"
        f"<td class='mini'>{_esc(f['reasoning_ko'])}"
        f"{('<br>fix: ' + _esc(f['repair_hint_en'])) if f['repair_hint_en'] else ''}"
        f"</td></tr>"
        for f in findings)
    return ("<table><tr><th>대상</th><th>인용 (artifact 원문)</th>"
            f"<th>분류</th><th>evidence</th><th>근거</th></tr>{rows}</table>")


def build_page(graph, audit, check, repair, remake):
    jobs = {j["shot_key"]: j for j in (remake or {}).get("jobs") or []}
    secs = []
    if graph:
        claim_rows = "".join(
            f"<tr class='{ {'confirmed_cue': 'sup', 'allowed_inference': 'inf', 'prohibited_invention': 'inv'}[c['claim_type']] }'>"
            f"<td><b>{_esc(c['evidence_id'])}</b></td>"
            f"<td>{_esc(c['claim_type'])}<br><span class='mini'>"
            f"{_esc(c['confidence'])}</span></td>"
            f"<td>{_esc(c['quote'])}<br><span class='mini'>{_esc(c['source'])}"
            f"</span></td>"
            f"<td>{_esc(c['summary_ko'])}"
            f"{('<br><span class=mini>allowed: ' + _esc(c['allowed_inference_en']) + '</span>') if c['allowed_inference_en'] else ''}"
            f"{('<br><span class=mini>mode: ' + _esc(c['prohibited_modes_en']) + '</span>') if c['prohibited_modes_en'] else ''}"
            f"</td>"
            f"<td class='mini'>{_esc(c['affected_space_en'])}<br>"
            f"{_esc(', '.join(c['affected_shots']))}</td></tr>"
            for c in graph["claims"])
        chain_rows = "".join(
            f"<tr><td>{_esc(l['link_id'])}</td>"
            f"<td>{_esc(l['from_space_en'])} → {_esc(l['to_space_en'])}"
            f" <span class='mini'>({_esc(l['relation_en'])})</span></td>"
            f"<td>{_esc(l['summary_ko'])}</td>"
            f"<td class='mini'>{_esc(', '.join(l['claim_refs']))}</td></tr>"
            for l in graph["chain"])
        secs.append(f"""
<h2>A. evidence graph — 사전 SOT 구조화 (claims {len(graph['claims'])} ·
chain {len(graph['chain'])})</h2>
<p class='note'>{_esc(graph['notes_ko'])}</p>
<table><tr><th>id</th><th>type</th><th>인용/출처</th><th>요약</th>
<th>공간/샷</th></tr>{claim_rows}</table>
<h3>일관성 체인</h3>
<table><tr><th>link</th><th>연결</th><th>요약</th><th>근거 claim</th></tr>
{chain_rows}</table>""")
    if audit:
        for k in sorted(audit["shots"].keys()):
            a = audit["shots"][k]
            secs.append(f"<h2>B. audit — {_esc(k)}</h2>"
                        f"<p class='note'>{_esc(a['summary_ko'])}</p>"
                        + _findings_table(a["findings"]))
        if audit.get("layout"):
            a = audit["layout"]
            secs.append("<h2>B. audit — s22 관계 데이터(layout)</h2>"
                        f"<p class='note'>{_esc(a['summary_ko'])}</p>"
                        + _findings_table(a["findings"]))
    if check:
        rows = "".join(
            f"<tr class='{'sup' if r['touching'] else 'con'}'>"
            f"<td>{_esc(r['link_id'])}</td><td>{_esc(r['summary_ko'])}</td>"
            f"<td>{_esc(r['a_id'])} ↔ {_esc(r['b_id'])}"
            f"{' <i>(시선)</i>' if r.get('sightline') else ''}</td>"
            f"<td>{'—' if r['gap'] is None else r['gap']}</td>"
            f"<td><b>{'PASS' if r['touching'] else 'FAIL/미매핑'}</b></td>"
            f"<td class='mini'>{_esc(r['reason_ko'])}</td></tr>"
            for r in check["links"])
        ok = sum(1 for r in check["links"] if r["touching"])
        secs.append(f"""
<h2>C. 체인 접촉 검증 — 사전 체인 × s22 솔버 좌표 (결정론) —
{ok}/{len(check['links'])} PASS</h2>
<table><tr><th>link</th><th>사전 체인</th><th>s22 요소 매핑(LLM)</th>
<th>간극</th><th>판정(코드)</th><th>비고</th></tr>{rows}</table>""")
    if repair:
        for k in sorted(repair["shots"].keys()):
            e = repair["shots"][k]
            edit_rows = "".join(
                f"<tr><td>{_esc(x['before_quote'])}</td>"
                f"<td>{_esc(x['after_en']) or '<i>(삭제)</i>'}</td>"
                f"<td class='mini'>{_esc(', '.join(x['evidence_ids']))}<br>"
                f"{_esc(x['reason_ko'])}</td></tr>"
                for x in e.get("prompt_edits") or [])
            corr_rows = "".join(
                f"<tr><td>{_esc(x['field_en'])}</td>"
                f"<td>{_esc(x['before_quote'])}</td>"
                f"<td>{_esc(x['after_en'])}</td>"
                f"<td class='mini'>{_esc(', '.join(x['evidence_ids']))}<br>"
                f"{_esc(x['reason_ko'])}</td></tr>"
                for x in e.get("staging_corrections") or [])
            trio = ""
            if k in jobs:
                orig = jobs[k]["orig_file"]
                imgs = [
                    (f"out/shots_outdoor/{orig}", "원본 최신 스틸(기존 배경)"),
                    (f"out/shots_outdoor/remake/{k}_bgswap.png",
                     "현행 remake(배경 교체, 프롬프트=발명 잔존)"),
                    (f"out/shots_outdoor/repair/{k}_repairstill.png",
                     "수리본(발명 정정 프롬프트)"),
                ]
                cells = []
                for rel_p, cap in imgs:
                    if (EXP / rel_p).exists():
                        cells.append(f"<div><img src='{_esc(rel_p)}'>"
                                     f"<p class='cap'>{_esc(cap)}</p></div>")
                    else:
                        cells.append(f"<div><div class='pending'>생성 대기…"
                                     f"</div><p class='cap'>{_esc(cap)}</p>"
                                     f"</div>")
                trio = f"<div class='trio'>{''.join(cells)}</div>"
            secs.append(f"""
<h2>D. repair — {_esc(k)}</h2>
<p class='note'>{_esc(e['notes_ko'])}</p>
{trio}
{('<h3>프롬프트 최소 치환 (' + str(len(e.get('prompt_edits') or []))
  + '건)</h3><table><tr><th>before (원문 그대로)</th><th>after</th>'
  '<th>근거</th></tr>' + edit_rows + '</table>') if edit_rows else ''}
{('<h3>staging/desc 정정 팩 (적용 대기 — provenance 보존)</h3>'
  '<table><tr><th>field</th><th>before</th><th>after</th><th>근거</th></tr>'
  + corr_rows + '</table>') if corr_rows else ''}
{('<details><summary>수리 프롬프트 전문</summary><pre>'
  + _esc(e['repaired_prompt']) + '</pre></details>')
 if e.get('repaired_prompt') else ''}""")
    PAGE.write_text(f"""<!doctype html><meta charset='utf-8'>
<title>s24 — 공간 단서 사전 wave (graph/audit/chain/repair)</title>
<style>
body{{font-family:system-ui,'Apple SD Gothic Neo',sans-serif;margin:24px;
background:#fafafa;color:#222;max-width:1560px}}
h1{{font-size:20px}} h2{{font-size:16px;margin-top:34px;border-bottom:2px
solid #ddd;padding-bottom:4px}} h3{{font-size:13.5px;margin:14px 0 4px}}
img{{width:100%;display:block;background:#fff;border:1px solid #ddd;
padding:3px;box-sizing:border-box}}
.trio{{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin:8px 0}}
.cap{{font-size:12px;color:#333;margin:4px 0 10px;font-weight:600}}
table{{border-collapse:collapse;font-size:12px;margin:8px 0;width:100%;
background:#fff}}
td,th{{border:1px solid #ddd;padding:4px 8px;text-align:left;
vertical-align:top}}
.mini{{font-size:11px;color:#667}}
.note{{font-size:13px;color:#345;background:#eef3f8;padding:6px 10px;
border-radius:6px}}
tr.sup td{{background:#f2f9f2}} tr.inf td{{background:#f4f6fb}}
tr.inv td{{background:#fdf3ec}} tr.con td{{background:#fdecea}}
.pending{{padding:40px 10px;text-align:center;color:#999;background:#fff;
border:1px dashed #ccc;font-size:12px}}
pre{{white-space:pre-wrap;font-size:11px;background:#fff;border:1px solid
#e5e5e5;padding:10px}}
details{{margin:8px 0}} summary{{font-size:12px;color:#557;cursor:pointer}}
.clause{{background:#fff8e6;border:1px solid #eeddaa;padding:10px 14px;
font-size:12px;white-space:pre-wrap;font-family:ui-monospace,monospace}}
</style>
<h1>s24 — 공간 단서 사전 기반 수정 wave</h1>
<p style='font-size:12.5px;color:#556'>사전 SOT=plans/spatial_evidence_v1.md
→ ① evidence graph(일반 스키마, 내용=데이터 주입) ② 4분류 audit(LLM 의미
검증: 원문 지지/허용 구체화 보존/무근거 발명/모순) ③ 사전 체인×s22 솔버
좌표 접촉 검증(결정론) ④ 발명·모순만 최소 수정(프롬프트 치환→nb2 재생성,
staging=정정 팩). 색: <span style='background:#f2f9f2'>지지</span> ·
<span style='background:#f4f6fb'>허용 구체화</span> ·
<span style='background:#fdf3ec'>무근거 발명</span> ·
<span style='background:#fdecea'>모순</span>. 육안 판정=사용자.</p>
<h2>⑤ 계약 명문화 — EVIDENCE_BOUND_CLAUSE (추출·staging 저작 계약용,
production 이식은 사용자 GO 별도)</h2>
<div class='clause'>{_esc(EVIDENCE_BOUND_CLAUSE)}</div>
{''.join(secs)}
""", encoding="utf-8")
    print(f"page -> {PAGE}")


def _load(name):
    try:
        return F.load_plan(name)
    except Exception:
        return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--only", default="all",
                    choices=["all", "graph", "audit", "chain", "repair",
                             "still", "html"])
    args = ap.parse_args()
    recon = F.load_recon()
    collect = F.load_plan("outdoor_shots_collect")
    remake = F.load_plan("outdoor_remake_bg")
    remake_jobs = {j["shot_key"]: j for j in remake["jobs"]}
    rel = F.load_plan("bd_code_rel_v1")["rounds"][-1]["rel"]
    shot_keys = sorted(collect["shots"].keys())
    OUT.mkdir(parents=True, exist_ok=True)

    graph = _load("spatial_evidence_graph_v1")
    audit = _load("spatial_audit_v1")
    check = _load("spatial_chaincheck_v1")
    repair = _load("spatial_repair_v1")

    if args.only in ("all", "graph") and not (args.only == "all" and graph):
        graph = run_graph(shot_keys)
        build_page(graph, audit, check, repair, remake)
    assert graph, "graph 먼저: --only graph"
    if args.only in ("all", "audit"):
        audit = run_audit(graph, recon, collect, remake_jobs, rel, audit)
        build_page(graph, audit, check, repair, remake)
    if args.only in ("all", "chain"):
        check = run_chain(graph, rel)
        build_page(graph, audit, check, repair, remake)
    if args.only in ("all", "repair"):
        assert audit, "audit 먼저"
        repair = run_repair(graph, audit, recon, collect, remake_jobs,
                            repair)
        build_page(graph, audit, check, repair, remake)
    if args.only in ("all", "still"):
        assert repair, "repair 먼저"
        run_still(remake, repair)
    build_page(graph, audit, check, repair, remake)
    F.runlog({"kind": "stage", "stage": "s24_evidence", "done": args.only})


if __name__ == "__main__":
    main()
