"""도면 읽기 — 두 모델이 **얼마나 같게 읽나** (2026-08-27, #92 6단계).

Codex 판정: 「geometry 이중화의 합의는 좌표 평균 금지, 구조화 ID 합의다.
`marker ID 집합·kind·missing/extra` 가 같아야 하고 `row/col` 은 평균 대신
`exact / adjacent / conflict` 로 기록한다. 인접 셀 허용은 **양성 표본으로
경계를 잰 뒤에만**.」

★그래서 **구현 전에 잰다.** `exact` 만 통과시키는 계약을 두 모델 일치율도
 모르고 넣으면, 격자를 다르게 읽을 때 도면 읽기가 통째로 막혀 파이프라인이
 선다. 앞선 무료 도구(`audit_floor_readback_shape.py`)는 「문제의 크기」만
 셌다 — 마커 평균 11개·격자 10×10. 여기서 재는 것은 **일치율**이다.

## 무엇을 어떻게 재는가

★**프로덕션 조립을 그대로 쓴다.** `_build_dossier_facts` / `_build_messages`
 / `_build_response_format` 을 직접 부르고 **모델만 바꾼다**. 문안을 새로
 지으면 「나가는 것을 쟀다」고 말할 수 없다(08-24 지적).

입력은 **완주한 체크포인트**에서 온다 — 도면 PNG·dossier·격자 다.
Sol 이 실제로 읽은 값(`readback`)도 같이 있어서 **3자 비교**가 된다.

    ID 집합     number 들이 같은가                → 합의의 뼈대
    kind        같은 number 에 같은 kind 인가      → 합의 대상 ②
    row/col     exact / adjacent(체비쇼프 1) / conflict
    missing     dossier 에 있는데 아무도 못 본 것

★**「인접」은 여기서 정의만 하고 판정에 안 쓴다.** 얼마나 흔한지를 세서
 계약을 정하는 재료로 삼는다 — 먼저 문턱을 박고 재는 것은 거꾸로다.

    .venv/bin/python tools/prompt_measure/probe_floor_readback_agreement.py [N]
"""
from __future__ import annotations

import json
import pathlib
import sys
from collections import Counter

sys.path.insert(0, "/Users/manta/Documents/Projects/TheRoad-I1/scratchpad")
import _opik_env  # noqa: F401,E402

ROOT = pathlib.Path("/Users/manta/Documents/Projects/TheRoad-I1")
OUT = ROOT / "artifact/20260827_floor_readback_agreement"
MODELS = ("gemini-pro", "grok")
DEFAULT_N = 8


def _samples(limit: int):
    """완주 CP 에서 (도면 PNG · dossier · 격자 · Sol 이 읽은 값) 을 모은다."""
    out = []
    cps = sorted(ROOT.glob(
        "projects/*/checkpoints/episodes/*/floor_plan_geometry_readback/"
        "manifest*.json"))
    for cp in reversed(cps):
        epi = cp.parent.parent
        proj = epi.parent.parent.parent
        try:
            rb = json.loads(cp.read_text(encoding="utf-8"))
            dos_ms = sorted((epi / "base_location_dossier").glob(
                "manifest*.json"))
            ren_ms = sorted((epi / "floor_plan_render").glob("manifest*.json"))
            if not dos_ms or not ren_ms:
                continue
            dossiers = json.loads(dos_ms[-1].read_text(
                encoding="utf-8")).get("data", {}).get("dossiers") or {}
            plans = json.loads(ren_ms[-1].read_text(
                encoding="utf-8")).get("data", {}).get("floor_plans") or {}
        except Exception:
            continue
        for fp_id, entry in (rb.get("data", {}).get("per_fp") or {}).items():
            dos = dossiers.get(fp_id)
            plan = plans.get(fp_id)
            if not isinstance(dos, dict) or not isinstance(plan, dict):
                continue
            if not dos.get("base_marker_inventory"):
                continue
            png = _find_png(plan, proj)
            if not png:
                continue
            grid = (entry.get("geometry") or {}).get("grid_size") or [10, 10]
            out.append({
                "fp_id": fp_id, "png": png, "dossier": dos,
                "grid": (int(grid[0]), int(grid[1])),
                "sol": entry.get("readback") or {},
                "project": proj.name[:8],
            })
            if len(out) >= limit:
                return out
    return out


def _find_png(plan: dict, proj: pathlib.Path):
    """플랜 딕셔너리 어디에 있든 실재하는 PNG 하나를 찾는다."""
    for v in _strings(plan):
        if not v.endswith(".png"):
            continue
        for cand in (pathlib.Path(v), proj / v, ROOT / v):
            if cand.exists() and cand.is_file():
                return cand
    return None


def _strings(o):
    if isinstance(o, str):
        yield o
    elif isinstance(o, dict):
        for v in o.values():
            yield from _strings(v)
    elif isinstance(o, list):
        for v in o:
            yield from _strings(v)


def _read_one(alias: str, sample: dict, tag: str) -> dict:
    """★프로덕션 조립을 그대로 쓰고 **모델만 바꾼다.**"""
    from app.modules.pipeline.floor_plan_vlm_provider import (
        _build_dossier_facts, _build_messages, _build_response_format,
    )
    from app.modules.llm.llm_client import call_structured
    import base64

    raw = sample["png"].read_bytes()
    url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
    facts = _build_dossier_facts(dossier=sample["dossier"],
                                 grid_size=sample["grid"])
    msgs = _build_messages(dossier_facts=facts, image_data_url=url)
    schema = _build_response_format()["json_schema"]["schema"]

    sys_txt = "\n".join(str(m.get("content") or "") for m in msgs
                        if m.get("role") == "system")
    user = [m for m in msgs if m.get("role") == "user"]
    parts = user[-1].get("content") if user else []
    if isinstance(parts, str):
        parts = [{"type": "text", "text": parts}]

    sink: dict = {}
    try:
        got = call_structured(
            tag, sys_txt, parts, schema,
            project_config={tag: {"model": alias}},
            schema_name="floor_readback", enable_fallback=False,
            num_retries=0, usage_sink=sink)
        return {"ok": True, "payload": got, "usage": sink}
    except Exception as exc:  # noqa: BLE001 — 실패도 자료다
        return {"ok": False, "error": f"{type(exc).__name__}: {exc}"[:180],
                "usage": sink}


def _markers(payload) -> dict:
    out = {}
    for m in (payload or {}).get("observed_markers") or []:
        if isinstance(m, dict) and isinstance(m.get("number"), int):
            out[m["number"]] = (m.get("kind"), m.get("row"), m.get("col"))
    return out


def _compare(a: dict, b: dict) -> dict:
    """두 읽기의 합의 — ID·kind·셀. ★좌표를 평균하지 않는다."""
    ids_a, ids_b = set(a), set(b)
    both = ids_a & ids_b
    kind_ok = cell_exact = cell_adj = cell_conflict = 0
    for n in both:
        ka, ra, ca = a[n]
        kb, rb, cb = b[n]
        if ka == kb:
            kind_ok += 1
        if None in (ra, ca, rb, cb):
            cell_conflict += 1
        elif (ra, ca) == (rb, cb):
            cell_exact += 1
        elif max(abs(ra - rb), abs(ca - cb)) <= 1:
            cell_adj += 1
        else:
            cell_conflict += 1
    return {
        "ids_a": len(ids_a), "ids_b": len(ids_b), "ids_both": len(both),
        "only_a": sorted(ids_a - ids_b), "only_b": sorted(ids_b - ids_a),
        "kind_ok": kind_ok, "cell_exact": cell_exact,
        "cell_adjacent": cell_adj, "cell_conflict": cell_conflict,
    }


def main() -> None:
    n = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_N
    samples = _samples(n)
    if not samples:
        print("  재료를 못 찾았다 — dossier + PNG 가 다 있는 CP 가 없다")
        return
    print(f"■ 도면 {len(samples)}장 × 모델 {len(MODELS)}개\n")

    rows = []
    for i, s in enumerate(samples):
        reads = {}
        for alias in MODELS:
            r = _read_one(alias, s, f"fpagree_{i}_{alias}")
            reads[alias] = r
            u = r.get("usage") or {}
            mark = "○" if r["ok"] else "✕"
            print(f"  [{s['fp_id'][:26]:26s} {alias:10s}] {mark} "
                  f"마커={len(_markers(r.get('payload')))} "
                  f"완성={u.get('completion_tokens')} "
                  f"${u.get('estimated_cost_usd')}"
                  + (f"  {r.get('error','')}" if not r["ok"] else ""))
        a, b = (_markers(reads[MODELS[0]].get("payload")),
                _markers(reads[MODELS[1]].get("payload")))
        cmp_ab = _compare(a, b) if (reads[MODELS[0]]["ok"]
                                    and reads[MODELS[1]]["ok"]) else None
        sol = _markers(s["sol"])
        rows.append({
            "fp_id": s["fp_id"], "project": s["project"],
            "grid": list(s["grid"]),
            "dossier_markers": len(s["dossier"].get(
                "base_marker_inventory") or []),
            "sol_markers": len(sol),
            "reads": {k: {"ok": v["ok"], "error": v.get("error"),
                          "markers": len(_markers(v.get("payload"))),
                          "usage": v.get("usage"),
                          "payload": v.get("payload")}
                      for k, v in reads.items()},
            "pair": cmp_ab,
            "vs_sol": {k: _compare(sol, _markers(v.get("payload")))
                       for k, v in reads.items() if v["ok"]} if sol else {},
        })
        if cmp_ab:
            print(f"     └ 합의: ID {cmp_ab['ids_both']}공통 "
                  f"(단독 {len(cmp_ab['only_a'])}/{len(cmp_ab['only_b'])}) · "
                  f"kind {cmp_ab['kind_ok']} · "
                  f"셀 정확 {cmp_ab['cell_exact']} / "
                  f"인접 {cmp_ab['cell_adjacent']} / "
                  f"어긋남 {cmp_ab['cell_conflict']}")

    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "agreement.json").write_text(
        json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")

    print("\n" + "─" * 62)
    print("■ 합산 — 이 값으로 계약을 정한다\n")
    pairs = [r["pair"] for r in rows if r["pair"]]
    if not pairs:
        print("  두 모델이 다 성공한 도면이 없다 — 계약을 못 정한다")
    else:
        tot_both = sum(p["ids_both"] for p in pairs)
        tot_a = sum(p["ids_a"] for p in pairs)
        tot_b = sum(p["ids_b"] for p in pairs)
        solo = sum(len(p["only_a"]) + len(p["only_b"]) for p in pairs)
        ex = sum(p["cell_exact"] for p in pairs)
        ad = sum(p["cell_adjacent"] for p in pairs)
        cf = sum(p["cell_conflict"] for p in pairs)
        kd = sum(p["kind_ok"] for p in pairs)
        print(f"  도면 {len(pairs)}장 · 본 마커 "
              f"{MODELS[0]}={tot_a} {MODELS[1]}={tot_b} · 공통 {tot_both}")
        print(f"  ID 한쪽만 본 것   {solo}건")
        if tot_both:
            print(f"  kind 일치        {kd}/{tot_both} "
                  f"({kd/tot_both*100:.0f}%)")
            print(f"  셀 정확 일치     {ex}/{tot_both} "
                  f"({ex/tot_both*100:.0f}%)")
            print(f"  셀 인접(±1)      {ad}/{tot_both} "
                  f"({ad/tot_both*100:.0f}%)")
            print(f"  셀 어긋남        {cf}/{tot_both} "
                  f"({cf/tot_both*100:.0f}%)")
    fails = Counter(f"{a}:{r['reads'][a]['error']}" for r in rows
                    for a in MODELS if not r["reads"][a]["ok"])
    if fails:
        print("\n  ★실패")
        for k, v in fails.most_common(6):
            print(f"    {v}× {k[:100]}")
    cost = sum(float((r["reads"][a]["usage"] or {}).get(
        "estimated_cost_usd") or 0) for r in rows for a in MODELS)
    print(f"\n  이번 실측 비용 ${cost:.4f}")
    print(f"  기록 → {OUT / 'agreement.json'}")
    print("\n★이 숫자를 보기 전에는 `exact` 계약을 못 넣는다 — 정확 일치가")
    print(" 낮으면 도면 읽기가 통째로 막혀 파이프라인이 선다.")


if __name__ == "__main__":
    main()
