#!/usr/bin/env python3
"""저작이 「대본이 부른 글자」만 내는가 — 팩 v1 vs v2 (2026-08-25).

무엇이 문제였나 — 완주 판(da049582/fb7a883f)의 대본은 900자 전문에 읽을
글자를 부르는 대목이 **하나도 없다**. 그런데 저작 기록 7건 중 **6건이 글자를
만들었다**(86%). 팩 본문은 "Most shots show none — then return an empty
list" 라고 명시했는데 정반대다. 모델이 적은 이유가 그대로 말한다 —
"정비소임을 명확히 보여주는", "현실적인 분위기를 살리기 위해". 장면이 부른
것이 아니라 **장소 유형에서 끌어낸 것**이다.

「이 샷이 읽을 면을 보여주는가」는 언제나 「예」가 나오는 물음이다. 그래서
v2 는 금지를 더 쌓는 대신 **출처를 값으로 받는다**(scene_text / world_facts
/ inferred) — 추론은 코드가 버린다.

★프로덕션 경로를 그대로 쓴다 — `author_inscriptions` · `build_author_schema`
 · `call_structured`. 팩 selector 만 갈아 끼운다. 다시 짜지 않는다.

★입력이 프로덕션이 실제로 보낸 것인지를 **도구가 스스로 증명한다** —
 복원한 (샷 텍스트 · 장소 · 세계 사실) 로 `_sg_fp` 를 그대로 계산해
 records 의 `fp` 와 대조한다. 어긋나면 그 샷은 「복원 실패」로 표시하고
 수치에서 뺀다. 맞는 것만 세지 않으면 「0」이 「없다」로 읽힌다.

★그림은 사지 않는다. 저작(보조 모델 1콜)만 다시 돌려 **저작률과 출처
 분포**를 잰다 — 방향이 틀렸으면 그림값은 한 푼도 안 나간다.

usage:
  ab_inscription_source.py                 # v1·v2 × 7샷 × 1회
  ab_inscription_source.py --rounds 3      # 흔들림까지
  ab_inscription_source.py --arms v2       # 한쪽만
  ab_inscription_source.py --json OUT.json
"""
import argparse
import hashlib
import json
import sys
from collections import Counter
from pathlib import Path

sys.path.insert(0, "/Users/manta/Documents/Projects/TheRoad-I1/scratchpad")
import _opik_env  # noqa: E402,F401  ★cwd 를 backend 로 고정

ROOT = Path("/Users/manta/Documents/Projects/TheRoad-I1")
PROJECT_ID = "da049582-2c6d-492c-979d-f468d61bab6e"
EPISODE_ID = "fb7a883f-baac-4145-9131-732ce628d474"
CP = ROOT / f"projects/{PROJECT_ID}/checkpoints/episodes/{EPISODE_ID}"
RECORDS = (ROOT / f"projects/{PROJECT_ID}/images/{EPISODE_ID}"
           / "scene/recipe/records.json")


# ── 양성 대조 ────────────────────────────────────────────────────────
# ★「v2 가 0 을 냈다」와 「v2 는 언제나 0 을 낸다」는 다르다. 이 대본은
#  글자를 부르지 않으므로 0 이 맞지만, 그것만 재면 **꺼진 장치와 구분이
#  안 된다**(era 조사에서 판별 모델을 바꿨더니 17/17 이 「비대상」으로
#  나와 조사가 통째로 꺼졌던 자리와 같은 함정).
#
# 그래서 **글자를 부르는 입력**을 지어 같이 태운다. 여기서 v2 가
# `scene_text` 로 저작하면 과잉 억제가 아니라는 증거가 된다.
# 특정 작품에 기대지 않는 일반 문장만 쓴다.
# ── 기준선 판 식별자 ────────────────────────────────────────────────
# 완주 판 records 는 팩 v1 · 정책 v1 로 만들어졌다. 입력 복원을 확인할 때는
# **그 판의 값**으로 지문을 계산해야 한다 — 코드의 현재 상수를 쓰면 팩을
# 올릴 때마다 전건이 「복원 실패」로 뒤집힌다.
BASELINE_POLICY = "signage_author_v1_flash"
BASELINE_PACK = "1"

POSITIVE_CONTROLS = [
    {
        "tag": "PC-quoted",
        "shot": ("A closed door in a narrow corridor. A sheet of paper is "
                 "taped at eye level, and it reads: 금일 휴업."),
        "place": "A narrow indoor corridor at night, lit by one lamp.",
        "why": "샷 텍스트가 글자를 **그대로 인용**한다",
    },
    {
        "tag": "PC-named",
        "shot": ("A man stops in front of a public notice board and reads "
                 "the notice pinned to it."),
        "place": "A quiet street corner beside a low brick wall, daytime.",
        "why": "샷 텍스트가 **읽는 행위와 그 대상**을 지목한다",
    },
]


def _load_records() -> dict:
    d = json.loads(RECORDS.read_text(encoding="utf-8"))
    data = d.get("data", d)
    return {k[:-len("::signage")]: v for k, v in data.items()
            if k.endswith("::signage") and isinstance(v, dict)}


def _load_classify() -> dict:
    d = json.loads((CP / "shot_ref_classify" / "manifest.json")
                   .read_text(encoding="utf-8"))
    return d.get("data", d)


def _shot_desc_by_tag() -> dict:
    """프로덕션의 `shot_desc_by_id` 와 같은 값 — DB 가 SOT 다.

    태그는 코드가 조립한다(`S{scene}sh{shot}`). 여기서도 같은 규칙으로
    맞춘다 — 어긋나면 fp 대조가 잡아 준다.
    """
    from app.core.database import SessionLocal
    from app.models.project import SceneStill

    out = {}
    with SessionLocal() as db:
        rows = (db.query(SceneStill)
                .filter(SceneStill.episode_id == EPISODE_ID).all())
        for r in rows:
            if r.scene_index is None or r.shot_index is None:
                continue
            out[f"S{r.scene_index}sh{r.shot_index}"] = (
                r.shot_description or "")
    return out


def build_inputs() -> list:
    """저작 입력을 복원하고 fp 로 검증한다."""
    import app.modules.pipeline.signage_author as sa

    recs = _load_records()
    classify = _load_classify()
    shots_cls = classify.get("shots") or {}
    scenes_cls = classify.get("scenes") or {}
    _wa = (classify.get("world_anchor_en") or "").strip()
    world_anchor = f" — {_wa}" if _wa else ""
    descs = _shot_desc_by_tag()

    rows = []
    for tag in sorted(recs):
        si = tag.split("sh")[0].lstrip("S")
        place = ((shots_cls.get(tag) or {}).get("place_en")
                 or (scenes_cls.get(si) or {}).get("place_en") or "")
        shot = descs.get(tag, "")
        # 프로덕션과 **같은 식**으로 계산한다 (still_recipe_service:3420).
        # ★정책·팩 값은 **기록이 만들어진 판**의 것을 쓴다. 지금 상수를
        #  쓰면 selector 를 올린 순간 7/7 이 「복원 실패」가 된다 — 입력은
        #  그대로인데 판 식별자만 달라진 것이라, 그것으로 「같은 입력인가」를
        #  재면 도구가 제 꼬리를 문다(2026-08-27 실측: v3 승격 뒤 0/7).
        fp = hashlib.sha256("\n".join([
            shot, place, world_anchor,
            BASELINE_POLICY, sa.resolve_signage_pack(BASELINE_PACK),
            sa.signage_pack_content_hash(BASELINE_PACK),
        ]).encode("utf-8")).hexdigest()[:16]
        rows.append({
            "tag": tag, "shot": shot, "place": place,
            "world_anchor": world_anchor,
            "fp_recomputed": fp, "fp_recorded": recs[tag].get("fp"),
            "fp_ok": fp == recs[tag].get("fp"),
            "recorded": recs[tag].get("inscriptions") or [],
        })
    return rows


class _Tap:
    """모델이 **낸 그대로**를 옆에서 받아 적는다.

    ★「0건」에는 두 가지가 있다 — 모델이 빈 목록을 냈거나, 냈는데 코드가
     근거 없다고 버렸거나. 둘은 뜻이 아주 다른데 반환값만 보면 같아 보인다.
     그래서 프로덕션 함수를 **그대로 호출하되** 응답을 옆에서 관찰한다
     (우회가 아니라 관찰이다 — 거르기도 프로덕션 코드가 한다).
    """

    def __init__(self):
        import app.modules.llm.llm_client as llm_client

        self._mod = llm_client
        self._orig = llm_client.call_structured
        self.last = None

    def __enter__(self):
        def wrapped(*a, **kw):
            data = self._orig(*a, **kw)
            self.last = data
            return data

        self._mod.call_structured = wrapped
        return self

    def __exit__(self, *exc):
        self._mod.call_structured = self._orig
        return False


def run_arm(rows, selector, rounds, verified_only=True):
    import app.modules.pipeline.signage_author as sa

    out = []
    with _Tap() as tap:
        for r in rows:
            if verified_only and not r["fp_ok"]:
                continue
            for i in range(rounds):
                tap.last = None
                try:
                    got = sa.author_inscriptions(
                        step_tag="signage_author",
                        shot_text=r["shot"], place_text=r["place"],
                        world_facts_block=r["world_anchor"],
                        pack_selector=selector,
                    )
                    # 2026-08-27: 반환이 dict 로 바뀌었다(버린 것을 값으로).
                    items = got.get("inscriptions") or []
                    drops = got.get("dropped") or []
                    cues = got.get("cues") or []
                    err = None
                except Exception as exc:  # noqa: BLE001 — 측정은 계속한다
                    items, drops, cues = [], [], []
                    err = f"{type(exc).__name__}: {exc}"[:200]
                raw = [x for x in ((tap.last or {}).get("inscriptions") or [])
                       if isinstance(x, dict)]
                out.append({"tag": r["tag"], "round": i, "selector": selector,
                            "items": items, "raw": raw, "drops": drops,
                            "cues": cues,
                            "dropped": len(drops), "error": err})
    return out


def mark_for(item, *, kept, cues) -> str:
    """모델이 낸 항목 하나가 **어느 갈래로 갔는지** 표시를 고른다.

        ✓  읽을 글자로 나간다
        ~  읽을 것이 있다는 신호 — 물건은 두되 문안은 안 나간다
        ✗  코드가 버렸다

    ★신원을 **(출처, 인용) 두 칸으로** 본다 (2026-08-27 Codex 재리뷰
     BLOCK). 인용 하나로만 보면, 같은 인용을 `scene_text_implied`(신호)와
     인용 검증에 실패한 항목이 함께 쓸 때 **버린 것이 신호로 찍힌다** —
     정확히 이 도구가 갈라 보이겠다고 만든 구분이 무너진다.

    두 칸이면 모호함이 남지 않는다: 신호 갈래는 출처가 `CUE_SOURCES` 일
    때만 들어가고, 그 갈래의 통과·탈락은 (출처, 인용)만으로 정해진다
    (`_filter_grounded` 의 신호 갈래는 문안을 안 본다). 그래서 같은 두
    칸을 가진 두 항목이 한쪽은 신호, 한쪽은 버림이 될 수 없다.

    ★표시를 고르는 자리를 **하나로** 둔다. 본 표본과 양성 대조에 같은
     코드를 두 벌 두었더니 한쪽만 고쳐지는 결함이 실제로 났다.
    """
    def _key(x):
        return (str(x.get("source") or ""), str(x.get("source_quote") or ""))

    if any(k is item for k in kept):
        return "✓ "
    return "~ " if _key(item) in {_key(c) for c in (cues or [])} else "✗ "


def _fmt(results, rows):
    n_shots = len({x["tag"] for x in results})
    rounds = max((x["round"] for x in results), default=0) + 1
    authored = sum(1 for x in results if x["items"])
    model_said = sum(1 for x in results if x.get("raw"))
    total = len(results)
    srcs = Counter()
    for x in results:
        for it in x.get("raw") or []:
            srcs[str(it.get("source") or "-")] += 1
    whys = Counter()
    for x in results:
        for d in x.get("drops") or []:
            whys[str(d.get("why") or "-")] += 1
    errs = sum(1 for x in results if x["error"])
    pct = (100.0 * authored / total) if total else 0.0
    line = (f"  최종 저작 {authored}/{total} ({pct:.0f}%)"
            f" · {n_shots}샷 × {rounds}회\n"
            f"  모델이 낸 것 {model_said}/{total}"
            f" · 코드가 버린 항목 {sum(x.get('dropped', 0) for x in results)}건")
    if srcs:
        line += "\n  출처(모델이 스스로 붙인 값) " + ", ".join(
            f"{k}={v}" for k, v in sorted(srcs.items()))
    if whys:
        line += "\n  버린 이유(코드가 붙인 값) " + ", ".join(
            f"{k}={v}" for k, v in sorted(whys.items()))
    n_cues = sum(len(x.get("cues") or []) for x in results)
    if n_cues:
        line += (f"\n  읽을 것이 있다는 신호 {n_cues}건 "
                 f"— 문안은 안 나간다(물체는 두되 안 읽히게)")
    if errs:
        line += f"\n  ★실패 {errs}"
    return line


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--rounds", type=int, default=1)
    ap.add_argument("--arms", default="v1,v2,v3")
    ap.add_argument("--json", dest="json_out")
    ap.add_argument("--include-unverified", action="store_true",
                    help="fp 대조에 실패한 샷도 넣는다(기본은 뺀다)")
    a = ap.parse_args()

    rows = build_inputs()
    ok = [r for r in rows if r["fp_ok"]]
    print(f"■ 입력 복원 — {len(ok)}/{len(rows)}샷이 프로덕션 지문과 일치")
    for r in rows:
        mark = "✓" if r["fp_ok"] else "✗"
        rec_n = len(r["recorded"])
        print(f"  {mark} {r['tag']}  기록 저작 {rec_n}건"
              f"  shot={len(r['shot'])}자 place={len(r['place'])}자")
        if not r["fp_ok"]:
            print(f"      기록 {r['fp_recorded']} ≠ 복원 "
                  f"{r['fp_recomputed']} — 수치에서 뺀다")
    if not ok and not a.include_unverified:
        print("\n★복원이 하나도 안 맞는다 — 측정을 멈춘다"
              "(맞지 않는 입력으로 잰 값은 프로덕션 이야기가 아니다)")
        return 1

    sel_map = {"v1": "1", "v2": "2", "v3": "3"}
    all_res = {}
    print("\n■ 기준선 — 완주 판에 실제로 기록된 저작")
    base = sum(1 for r in rows if r["recorded"])
    print(f"  저작 {base}/{len(rows)}샷"
          f" ({100.0*base/len(rows):.0f}%)  ← 대본이 부른 글자는 0개다")

    for arm in a.arms.split(","):
        arm = arm.strip()
        if arm not in sel_map:
            continue
        res = run_arm(rows, sel_map[arm], a.rounds,
                      verified_only=not a.include_unverified)
        all_res[arm] = res
        print(f"\n■ {arm} (팩 selector {sel_map[arm]})")
        print(_fmt(res, rows))
        for x in res:
            if x["error"]:
                print(f"    ★{x['tag']} r{x['round']}: {x['error']}")
                continue
            raw = x.get("raw") or []
            if not raw:
                print(f"    {x['tag']} r{x['round']}: (모델이 빈 목록을 냈다)")
                continue
            for it in raw:
                # ★세 상태를 가른다 (2026-08-27) — 신호를 버린 것과 같은
                #  표시로 찍으면 「읽을 글자로 안 나갔다」와 「근거가 없어
                #  버렸다」가 한 덩이로 읽힌다.
                mark = mark_for(it, kept=x["items"], cues=x.get("cues"))
                # ★인용을 **함께 찍는다** — 통과했다는 것만 보이면 무엇으로
                #  통과했는지 모른다(2026-08-27). v3 는 문안이 그 장소
                #  언어이고 인용은 주어진 글 그대로라 서로 다를 수 있다.
                q = str(it.get("source_quote") or "")
                qs = f' ←"{q[:40]}"' if q else ""
                print(f"    {mark}{x['tag']} r{x['round']}: "
                      f"\"{it.get('text_native')}\""
                      f" [{it.get('source', '-')}]{qs} "
                      f"{it.get('reason_ko', '')[:40]}")

    # ── 양성 대조 ────────────────────────────────────────────────────
    _wa = (_load_classify().get("world_anchor_en") or "").strip()
    pc_rows = [{**p, "world_anchor": f" — {_wa}" if _wa else "",
                "fp_ok": True, "recorded": []} for p in POSITIVE_CONTROLS]
    print("\n■ 양성 대조 — 글자를 부르는 입력에서도 0 이면 꺼진 장치다")
    for arm in a.arms.split(","):
        arm = arm.strip()
        if arm not in sel_map:
            continue
        res = run_arm(pc_rows, sel_map[arm], 1)
        all_res[f"{arm}:positive_control"] = res
        got = sum(1 for x in res if x["items"])
        print(f"  {arm}: 저작 {got}/{len(res)}")
        for x, p in zip(res, pc_rows):
            if x["error"]:
                print(f"    ★{p['tag']}: {x['error']}")
                continue
            raw = x.get("raw") or []
            if not raw:
                print(f"    {p['tag']} ({p['why']}): (빈 목록)")
            for it in raw:
                q = str(it.get("source_quote") or "")
                mark = mark_for(it, kept=x["items"], cues=x.get("cues"))
                qs = f' ←"{q[:40]}"' if q else ""
                print(f"    {mark}{p['tag']}"
                      f" ({p['why']}): \"{it.get('text_native')}\""
                      f" [{it.get('source', '-')}]{qs}")

    if a.json_out:
        Path(a.json_out).write_text(json.dumps(
            {"inputs": rows, "results": all_res}, ensure_ascii=False,
            indent=1), encoding="utf-8")
        print(f"\n→ {a.json_out}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
