#!/usr/bin/env python3
"""4개 회차 분석 모델 비교 갤러리 빌더 v2 (실험 전용, 커밋 금지) — 같은 URL 재배포"""
import json, html, os

BASE = os.path.dirname(os.path.abspath(__file__))
D = json.load(open(os.path.join(BASE, "compare4_data.json")))
OUT = os.path.join(BASE, "..", "model_compare_gpt_vs_gemini.html")

KEYS = ["v16", "r1", "gpt", "gemini"]
LABEL = {
    "v16": "v16 정정검증 (07-08)<br><span class=note>GPT-5.5 혼합</span>",
    "r1": "W22 1회차 (07-10)<br><span class=note>GPT-5.5 혼합</span>",
    "gpt": "3회차 (07-10)<br><span class=note>GPT-5.6 전면</span>",
    "gemini": "4회차 (07-11)<br><span class=note>Gemini 원복</span>",
}
SHORT = {"v16": "v16", "r1": "1회차", "gpt": "3회차(5.6)", "gemini": "4회차(원복)"}

def alias_label(key, alias):
    if alias is None: return "—"
    m = {
        "gemini-pro": "Gemini 3.1 Pro", "gemini-flash": "Gemini 3.1 Flash",
        "gemini-lite": "Gemini Flash-Lite", "gemini-image": "Gemini Image",
        "gpt-terra": "GPT-5.6 Terra", "-": "코드", "mixed": "혼합",
    }
    if alias == "gpt":
        return "GPT-5.5" if key in ("v16", "r1") else "GPT-5.6 Sol"
    if alias == "gpt-mini":
        return "GPT-5.6 Sol <span class=note>(Luna폐지)</span>" if key == "gpt" else "Gemini 3.1 Flash <span class=note>(gpt-mini)</span>"
    return m.get(alias, alias)

STEPS = [
    ("planning_doc_analysis", "기획안 PDF 멀티모달 분석 (Gemini 전용 경로)", 0),
    ("text_cleanup", "시나리오 원문 정리 — PDF 추출 노이즈 제거", 0),
    ("scene_segmentation", "씬 경계 세그먼테이션", 0),
    ("episode_summary", "에피소드 전체 요약", 0),
    ("visual_world_rules", "시각적 세계관 규칙 추출 → director_notes 원천", 0),
    ("scene_save", "씬 원본 저장 (LLM 미사용)", 0),
    ("entity_character_list", "등장 인물 목록 1차 추출", 0),
    ("scene_summary", "씬별 요약 (병렬)", 0),
    ("beat_extract", "씬 → beat(상태변화 단위) 추출", 0),
    ("shot_extract", "beat → shot(스틸컷 후보) 추출", 0),
    ("shot_validator", "shot 검증·정합", 0),
    ("entity_all_character", "인물 캐논 확정 (shot 기반 1회)", 0),
    ("entity_extract_character", "인물 상세 추출", 0),
    ("entity_all_location", "배경(장소) 캐논 확정 (체이닝)", 0),
    ("entity_extract_location", "배경(장소) 추출", 0),
    ("entity_all_prop", "소품 캐논 확정 (체이닝)", 0),
    ("entity_extract_prop", "소품 추출", 0),
    ("entity_merge", "요소 병합 — 중복·별칭 통합", 0),
    ("entity_relation", "요소 간 관계(RelationFact) 연결", 0),
    ("entity_filter", "저빈도 요소 LLM 필터링", 0),
    ("entity_detail", "요소 상세 (stable traits)", 0),
    ("entity_t2i", "요소 참조 이미지용 T2I 프롬프트", 0),
    ("shot_selection", "씬별 최종 스틸 shot 선택 (최대 3)", 0),
    ("scene_director", "씬 감독 — 인물 배정 확정 = VE 원천", 0),
    ("shot_director", "shot별 촬영 기법 2종 배정", 0),
    ("scene_camera_flow", "씬 카메라 흐름 설계", 0),
    ("shot_dependency", "앞쪽 연관 shot 분석 (배경·인물 참조)", 0),
    ("outlook_phase1", "아웃룩 Phase1 — 의상 목록", 0),
    ("outlook_phase2", "아웃룩 Phase2 — 씬별 매핑", 0),
    ("outlook_phase3", "아웃룩 Phase3 — 정리·병합", 0),
    ("shot_staging", "shot 스테이징(블로킹) 산출", 0),
    ("background_classify", "장소 실내/외 분류 (FP/직행 분기 원천)", 0),
    ("background_master_plan", "배경 렌더 마스터플랜", 0),
    ("floor_plan_prompt", "실내 FP 프롬프트 (Phase 7 체인)", 0),
    ("scene_consistency", "씬 교차 검증", 0),
    ("floor_plan_geometry_readback", "FP 기하 판독 (LVM)", 0),
    ("base_location_dossier", "장소 도시에(dossier) 저작", 0),
    ("shot_aware_bg_render_plan", "샷 인지 배경 렌더 플랜", 0),
    ("background_prompt", "배경 T2I 프롬프트 (bg_prompt)", 0),
    ("episode_reference_policy", "참조 정책 산출", 0),
    ("visual_continuity_anchor", "시각 연속성 앵커", 0),
    ("scene_detail", "shot별 상세 — 스틸 T2I 프롬프트 저작", 0),
    ("shot_dependency_t2i", "의존 shot T2I 반영", 0),
    ("t2i_review", "T2I 일괄 리뷰", 0),
    ("zoom_continuity_anchor", "줌 연속성 앵커", 0),
    ("outdoor_site_layout", "야외 사이트 레이아웃 (구 야외 스택)", 0),
    ("outdoor_place_spec", "W22 직행 — 야외 장소 마커 스펙", 0),
    ("outdoor_place_canon", "W22 직행 — 장소 캐논 판정·재투영", 0),
    ("outdoor_shot_grounding", "W22 직행 — 샷 접지", 0),
    ("world_guide", "월드 가이드 문서", 0),
    ("floor_plan_render", "실내 FP 렌더", 1),
    ("ref_image_gen", "요소 참조 이미지 생성", 1),
    ("composite_image_gen", "합성 참조 이미지 생성", 1),
    ("character_state_variant", "인물 상태 변형 이미지", 1),
    ("background_render", "배경 렌더", 1),
    ("scene_image_pipeline", "씬 스틸 이미지 생성", 1),
]

def esc(s): return html.escape(str(s if s is not None else ""))
def badge(v):
    cls = {"GPT-5.6 우세": "bg", "Gemini 우세": "bm", "동급/노이즈 내": "bt", "트레이드오프": "bt", "경향 관찰(약)": "bw"}.get(v, "bt")
    return f'<span class="badge {cls}">{esc(v)}</span>'

# ---- 표1: 단계별 모델 ----
rows = []
for sid, purpose, is_img in STEPS:
    st = D["steps"].get(sid, {})
    cells, models = [], set()
    for k in KEYS:
        e = st.get(k)
        if not e:
            cells.append("<td class=na>—</td>"); continue
        ml = alias_label(k, e["model"])
        n = e.get("completed")
        dur = D["durations"].get(sid, {}).get(k)
        sub = f"n={n}" + (f" · {dur}s" if dur is not None else "")
        cells.append(f"<td>{ml}<br><span class=note>{sub}</span></td>")
        models.add(str(alias_label(k, e['model'])))
    diff = len(models) > 1 and not is_img
    cls = "imgrow" if is_img else ("diff" if diff else "")
    rows.append(f"<tr class='{cls}'><td class=sid>{esc(sid)}</td><td class=purpose>{esc(purpose)}</td>{''.join(cells)}</tr>")
step_table = "\n".join(rows)

ents = D["entities"]
def cnt(k, t): return sum(1 for e in ents[k] if e["type"] == t)
shots_total = {k: sum(v["total"] for v in D["shots"][k].values()) for k in KEYS}
sel_total = {k: len(D["selected"][k]) for k in KEYS}
ve_rate = {k: f"{len(D['ve_empty'][k])}/{sel_total[k]} ({100*len(D['ve_empty'][k])//sel_total[k]}%)" for k in KEYS}

DEFECTS = {"v16": set(), "r1": {"S18sh14", "S21sh5"}, "gpt": {"S3sh6", "S12sh22", "S12sh39", "S18sh48", "S25sh17", "S28sh15"}, "gemini": set()}

# (영역, [v16,1회차,3회차,4회차], Claude 판정, Codex 판정, 취합 결론)
SUMMARY = [
    ("캐릭터 엔티티 수·명명",
     [f"{cnt(k,'character')}종" for k in KEYS],
     "GPT-5.6 우세(구체 명명·승격)",
     "C 회수율 우세 / A 정밀도 우세",
     "회수율=C(단역·동물·상태변형까지 유일 회수), 캐논 정밀도=A(상태 파편 2개 수준). C의 20종엔 C09/C10↔C11/C12 중복 패밀리 등 상태 파편 ~5개 — 변형 분리 자체는 프로젝트 계약(by-design)이나 중복 패밀리는 dedupe 결함. D=11종·7소품 최빈약(고양이·마트 직원·보안직원 캐논 부재 → VE 커버리지 상한 제약). 취합: C 출력을 SOT 직승격 금지, 후보 발굴기 + evidence-bound merge/dedupe 후 사용."),
    ("장소 엔티티 — 시간·상태 소착",
     ["19종 · 소착 1건", "18종 · 소착 0건", "20종 · 소착 0건", "21종 · 소착 5건(옥탑방 3분열)"],
     "경향 관찰(약) — 팩 교정 정석",
     "D 상태 구조화 유용하나 캐논 분열 — state layer 적절 / 사실 보수성 B",
     "양측 일치: 4회차의 상태 분열은 정보로서는 유용(정돈/시신/난장판 시간 상태 보존)하나 장소 캐논 SOT 파편화. 취합: 상태는 별도 state layer(기존 state bg 계열)로 담고, 장소 명명의 시간 소착은 추출 팩에 시간 불변 조항 명문화. 원문 밖 구체화(C '현대식 경찰서', D '낡은 경찰서'·'흑백 사진', A '콘크리트 부두')는 전 회차 산발 — 모델 공통 리스크."),
    ("소품 엔티티 + t2i 토큰 보존",
     ["9종 · 보존 93.8%", "11종 · 보존 100%", "10종 · 보존 84.6%", "7종 · 보존 100%"],
     "동급/노이즈 내",
     "C 회수 우세하나 보존 최저, B/D 100%",
     "수량은 노이즈 범위지만 Codex 실측 t2i 토큰 보존율에서 C가 84.6%로 최저 — 많이 캐고 많이 흘림. B/D는 적게 캐되 전량 관통."),
    ("아웃룩(의상)",
     [f"{cnt(k,'outlook')}종 · 매핑 {len(D['outlooks'][k])}건" for k in KEYS],
     "GPT-5.6 우세(커버리지)",
     "D state continuity 우세 — 실사용 기준",
     "관점 차 → Codex 반론 수용: 생성 커버리지는 C(16종)지만 실제 선택샷 사용 기준으론 D가 우세 — 민숙 O05 평상복(S5·S18)→O09 피묻은홈웨어(S12) 상태 연속성 실사용을 DB 재검증으로 확인. C는 주연 3인 각 1종만 실사용 + 시신 상태 캐논(C18) outlook 미연결, B의 미용작업복은 미사용. 취합: '생성 수'가 아니라 shot assignment 실사용이 평가 기준."),
    ("beat / shot 세분도",
     [f"{D['beats'][k]}b/{shots_total[k]}s (선택 {sel_total[k]})" for k in KEYS],
     "트레이드오프 — Sol 과세분 확정",
     "C recall 우세 / 2.6~3.2배 과분해, B/A 균형 우세",
     "양측 독립 일치: Gemini Pro 3회(56~60beat) 수렴 vs Sol 155(+160%≫노이즈 ±7%) = Sol 고유 습성. recall은 C, 실사용 균형은 B/A. 선택 beat 수(Codex 실측 46/49/70/42)도 동일 패턴."),
    ("샷 문장 품질 (Codex 신규 축)",
     ["평균 51.1자", "44.1자", "29.2자 · slugline 오염", "42.6자"],
     "(2회차 비교에서 동급 판정)",
     "동작 중심 품질 D/A 우세, C 얼굴 편향",
     "Codex 발견 수용(DB 재검증 완료): ①C 선택샷 설명이 평균 29자로 정보 손실 + 자전거 씬(S6/S9/S22/S24)에서 A/B/D는 전신 주행 동작 보존, C는 얼굴 클로즈업 편향 ②C의 screenplay heading이 영어 slugline('EXT. GEUMWOLSAN MOUNTAIN TRAIL — DAY')로 재작성돼 원문 한국어 제목 계층 오염 — A/B 보존, D 경미 2건."),
    ("scene_director → VE 배정",
     [f"공백 {ve_rate[k]} · 결함성 {len(DEFECTS[k])}건" for k in KEYS],
     "Gemini 우세 — Sol 결함성 4~6건",
     "A 핵심 배정 최상 / C 핵심 누락 / D 동반 보조 누락",
     "핵심 인물 축은 양측 독립 일치 — C만 명백 결함(S3sh6 혜수·S12sh22 민숙 전신·S12sh39: 동일 샷을 양측이 독립 지목). Codex 보완 수용: 'VE 공백 0'만 세면 맹점 — D는 배열이 비어 있지 않아도 동반 보조 인물(S17 고양이·S8 여직원·S19 남직원·S20 보안직원)이 캐논 부재로 누락. 이 축은 감독이 아니라 엔티티 회수율(위 1행)과 결합된 문제."),
    ("이미지 접점 — VE 캐릭터 ID 토큰 보존율 (Codex 신규 축)",
     ["89.8%", "92.3%", "87.5%", "94.6%"],
     "(scene_detail ID 누락 이력으로 간접 감지)",
     "D 최고 94.6% / C 최저 87.5%",
     "scene_detail t2i까지 캐릭터 ID가 관통한 비율(Codex 실측). D의 미보존 3건은 검은 형상/반사 실루엣 등 매개 표현이라 설명 가능, C는 실재 피사체(남자직원·고양이·보안직원) 누락. 이미지 생성 handoff는 D 확실 우세 — 내 기존 'scene_detail ID 누락 이력' 판정과 정합."),
    ("에피소드 요약",
     [f"{len(D['episode_summary'][k])}자" for k in KEYS],
     "Gemini 우세(충실도)",
     "A 인과·정보 보존 최고 / C 손실적 / B 오탈자 / D 뉘앙스 오류",
     "취합: 길이가 아니라 인과 보존이 기준 — A가 시신 소실→CCTV/GPS→사진→포구→안개의 인과 완전 보존으로 최고. C(Sol) 204자는 정확하나 제작 요약으론 과손실(양측 일치). B '리영' 오탈자 2회, D는 GPS 좌표·사진 단서를 '금월도 좌표'로 합침."),
    ("속도 (beat/shot/scene_detail)",
     [f"{D['durations']['beat_extract'].get(k,'—')}s / {D['durations']['shot_extract'].get(k,'—')}s / {D['durations']['scene_detail'].get(k,'—')}s" for k in KEYS],
     "Gemini 우세",
     "(미측정)",
     "동일 스텝 기준 Gemini 계열 일관 우세. Sol scene_detail 891s는 샷당 환산해도 열세."),
]
summary_rows = "\n".join(
    "<tr><td class=area>{}</td>{}<td>{}</td><td>{}</td><td class=why>{}</td></tr>".format(
        esc(a), "".join(f"<td>{esc(c)}</td>" for c in cols), esc(cl), esc(cx), esc(w))
    for a, cols, cl, cx, w in SUMMARY)

def ent_grid(t):
    cols = []
    for k in KEYS:
        lis = "\n".join(f"<li><b>{esc(e['id'])}</b> {esc(e['name'])}</li>" for e in ents[k] if e["type"] == t)
        cols.append(f"<div><h4>{SHORT[k]} ({cnt(k,t)})</h4><ul>{lis}</ul></div>")
    return f"<div class=cols4>{''.join(cols)}</div>"

name_map = {k: {e["id"]: e["name"] for e in ents[k]} for k in KEYS}
def chars_of(k, s):
    ids = D["scene_chars"][k].get(str(s), [])
    return ", ".join(f"{i}({name_map[k].get(i,'?')})" for i in ids) or "<span class=warn>없음</span>"
all_scenes = sorted({int(x) for k in KEYS for x in D["scene_chars"][k]})
scene_char_rows = "\n".join(
    f"<tr><td>S{s}</td>" + "".join(f"<td>{chars_of(k,s)}</td>" for k in KEYS) + "</tr>" for s in all_scenes)

def ve_table(k):
    out = []
    for s in D["selected"][k]:
        if s["ve_chars"]: continue
        key = f"S{s['scene']}sh{s['shot']}"
        cls = "defect" if key in DEFECTS[k] else ""
        tag = "결함성" if key in DEFECTS[k] else "정당(사물/풍경)"
        out.append(f"<tr class='{cls}'><td>{key}</td><td>{esc(s['desc'])}</td><td>{tag}</td></tr>")
    return (f"<h4>{SHORT[k]} — {len(D['ve_empty'][k])}/{sel_total[k]}건</h4>"
            f"<div class=tablewrap><table><tr><th>샷</th><th>설명</th><th>분류</th></tr>{''.join(out)}</table></div>")

def outlook_col(k):
    lis = "\n".join(f"<li>{esc(o['char'])} → {esc(o['outlook'])}</li>" for o in D["outlooks"][k])
    return f"<div><h4>{SHORT[k]} ({len(D['outlooks'][k])})</h4><ul>{lis}</ul></div>"

def summary_col(k):
    return f"<div><h4>{SHORT[k]} ({len(D['episode_summary'][k])}자)</h4><pre>{esc(D['episode_summary'][k])}</pre></div>"

HTML = f"""<!DOCTYPE html>
<html lang=ko><head><meta charset=utf-8>
<meta name=viewport content="width=device-width, initial-scale=1">
<title>분석 모델 4회차 비교 — Claude·Codex 교차판정 취합</title>
<style>
:root {{ color-scheme: dark; }}
body {{ margin:0; padding:24px; background:#111418; color:#e6e6e6; font:14px/1.6 -apple-system,'Apple SD Gothic Neo',sans-serif; }}
h1 {{ font-size:21px; }} h2 {{ font-size:18px; margin-top:40px; border-bottom:1px solid #333; padding-bottom:6px; }}
h4 {{ margin:8px 0; color:#9ecbff; }}
table {{ border-collapse:collapse; width:100%; margin:12px 0; }}
th, td {{ border:1px solid #2a2f36; padding:6px 9px; text-align:left; vertical-align:top; }}
th {{ background:#1c2128; position:sticky; top:0; }}
tr.diff td {{ background:#1e2433; }}
tr.imgrow td {{ color:#666; background:#14161a; }}
tr.defect td {{ background:#3a1d1d; }}
td.sid {{ font-family:ui-monospace,monospace; font-size:12px; white-space:nowrap; }}
td.purpose {{ min-width:180px; }}
td.area {{ font-weight:700; }}
td.why {{ color:#b9c2cc; min-width:260px; }}
td.na {{ color:#555; }}
.badge {{ padding:2px 8px; border-radius:10px; font-size:12px; white-space:nowrap; }}
.bg {{ background:#14432a; color:#7ee2a8; }} .bm {{ background:#1d3a5f; color:#8ec9ff; }}
.bt {{ background:#4a3d17; color:#e7cd6f; }} .bw {{ background:#3d2a4a; color:#cf9fef; }}
.cols4 {{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }}
.cols4 ul {{ margin:4px 0; padding-left:16px; }}
.note {{ color:#8a939e; font-size:11.5px; }}
.warn {{ color:#ff8f8f; }}
pre {{ background:#161b22; border:1px solid #2a2f36; padding:10px; white-space:pre-wrap; overflow-x:auto; font-size:12.5px; }}
.box {{ background:#161b22; border:1px solid #2a2f36; border-radius:8px; padding:12px 16px; margin:12px 0; }}
.verdict {{ border-left:4px solid #7ee2a8; }}
.tablewrap {{ overflow-x:auto; }}
@media (max-width:1100px) {{ .cols4 {{ grid-template-columns:repeat(2,1fr); }} }}
</style></head><body>
<h1>분석 모델 4회차 전수 비교 — GPT-5.5 혼합(×2) / GPT-5.6 전면 / Gemini 원복 · 텍스트 파이프라인 · <span style="color:#8ec9ff">Claude+Codex 교차판정 취합</span></h1>
<div class=box>
<b>비교 4회차</b> (동일 시나리오 금월도 1부 풀 E2E):<br>
① <b>v16 정정검증</b> <code>1b4a975b</code> (07-08) — 구모델: gpt=GPT-5.5 + gemini-pro 14스텝 + Flash 보조<br>
② <b>W22 1회차</b> <code>da87ee4a</code> (07-10) — ①과 동일 구성 → <u>①↔② 차이 = 순수 회차 편차(노이즈 기준선)</u><br>
③ <b>3회차</b> <code>7872eda9</code> (07-10) — GPT-5.6 Sol/Terra 전면<br>
④ <b>4회차</b> <code>8207aadc</code> (07-11) — Gemini 원복(감독·추출 14스텝 gemini-pro, 잔여 주력=Sol)<br>
<b>주의</b>: ⓐ ①②는 구버전 팩(entity_all v7·shot_selection v6 등 07-10 수정 이전)으로 실행 — 엔티티 축 일부 차이는 팩 유래 가능 ⓑ 핵심 감독·추출 스텝(인물 목록/beat/shot/director/detail)은 ①②④가 같은 Gemini Pro — 즉 이 축은 “Gemini Pro 3회 재현 vs Sol 1회” 구도이며 GPT-5.5는 해당 스텝을 맡은 적이 없음 ⓒ 4회차 육안 불합격(서구화)은 이미지·직행 캐논 축 문제로 본 비교와 별개.
</div>

<h2>1. 단계별 사용 모델 + 용도 (파란 행 = 회차 간 모델 상이, 회색 = 이미지 단계 · 비교 제외)</h2>
<div class=tablewrap><table>
<tr><th>스텝</th><th>용도</th><th>{LABEL['v16']}</th><th>{LABEL['r1']}</th><th>{LABEL['gpt']}</th><th>{LABEL['gemini']}</th></tr>
{step_table}
</table></div>

<h2>2. 산출물 정량·정성 비교 — Claude · Codex 교차판정 취합 (전수조사)</h2>
<div class=tablewrap><table>
<tr><th>영역</th><th>v16</th><th>1회차</th><th>3회차(5.6)</th><th>4회차(원복)</th><th>Claude 판정</th><th>Codex 판정 (독립)</th><th>취합 결론</th></tr>
{summary_rows}
</table></div>

<div class="box" style="border-left:4px solid #8ec9ff">
<h3 style="margin-top:0">Claude ↔ Codex 교차판정 — 일치·불일치</h3>
<b>방법</b>: Codex는 Claude 분석 결과를 일절 받지 않고 DB(step_run/entity_canon/scene_still/character_outlook/episode)와 에피소드 원문 30씬만으로 독립 판정. Codex 신규 주장은 Claude가 DB 재검증 후 취합(slugline 오염·O05→O09 실사용 확인 완료).
<ul>
<li><b>독립 일치 (고신뢰)</b>: ①Sol beat/shot 과세분(2.6~3.2배) ②Sol 감독의 핵심 인물 VE 누락 — 동일 샷(S3sh6·S12sh22·S12sh39)을 양측이 독립 지목 ③Sol 요약 과손실 ④하이브리드가 정답(모델 단독 승부 아님) ⑤단일 회차 비교의 통계 한계.</li>
<li><b>관점 차 → 조정</b>: Claude는 승격·커버리지(생성량)를 강점으로 평가, Codex는 SOT 정밀도·실사용·이미지 접점 중심 — <b>Codex 기준 수용</b>: 캐릭터 "C 회수율 우세 / A 정밀도 우세"로 분리, 아웃룩은 실사용 기준 D 우세로 수정, C 엔티티 출력의 SOT 직승격 반대.</li>
<li><b>Codex 신규 기여 (Claude 미측정 축)</b>: t2i 캐릭터 ID 토큰 보존율(D 94.6% 최고/C 87.5% 최저)·소품 토큰 보존(C 84.6% 최저)·샷 설명 평균 자수(C 29자)·동작 샷 품질(자전거 씬 C 얼굴 편향)·screenplay heading slugline 오염(C)·아웃룩 실사용 검증·A(v16) 재평가(제작용 종합 A≈D&gt;B&gt;C).</li>
<li><b>Claude 고유 기여</b>: 노이즈 기준선(v16↔1회차 동일 구성) 프레임, "S3 혜수 미배정=모델 불문" 구기록의 오판정 정정(4회차 혜수=C04 정상 배정 — Codex도 "D의 S3 혜수·S24 수리영 정상" 독립 확인), 스텝 소요시간 축.</li>
</ul>
</div>

<div class="box verdict">
<h3 style="margin-top:0">통합 종합 판정 (Claude + Codex 취합)</h3>
<ul>
<li><b>제작용(이미지 handoff) 순위: A≈D &gt; B &gt; C / 회수율 순위: C &gt; A &gt; B≈D</b> (Codex) — Claude 동의: "많이 캐는 모델"과 "안정적으로 넘기는 모델"이 다르다.</li>
<li><b>합의 하이브리드 아키텍처</b>: GPT(Sol) 엔티티 추출은 <u>후보 발굴기</u>로만 사용 → evidence-bound merge + variant-family dedupe 게이트 통과 후 캐논 승격 → beat/shot/director/consistency(캐논·VE 확정)는 Gemini Pro 유지. Claude 초기 권고(엔티티 추출 단순 재이관)를 Codex 반론 반영해 <u>dedupe 게이트 필수</u>로 보강.</li>
<li><b>보조 인물 커버리지 갭(D)</b>: VE 공백 0이어도 고양이·마트 직원·보안직원 캐논 부재로 동반 인물 누락 — 하이브리드에서 GPT 후보 발굴이 메울 지점이 정확히 여기.</li>
<li><b>장소 상태 분열(D)</b>: 정보는 보존하되 장소 캐논이 아닌 별도 state layer로 — 명명 시간 소착은 팩 시간 불변 조항으로 잠금.</li>
<li><b>통계 한계 (양측 합의)</b>: A↔B(동일 구성)조차 캐릭터 14↔11·후보샷 172↔215로 크게 다르고 4회차는 코드/팩도 상이 — 순수 "GPT vs Gemini" 인과 결론 불가. <b>다음 액션 후보: 동일 커밋·동일 팩·동일 입력에서 모델 라우팅만 바꾼 paired 재실행</b>.</li>
<li><b>GPT-5.5→5.6 승급 회귀 없음</b> — 공통 gpt 담당 스텝은 4회차에서도 이상 없음 (Claude).</li>
</ul>
</div>

<h2>3. 캐릭터 엔티티 4회차 나란히</h2>
{ent_grid('character')}
<h2>4. 장소 엔티티 4회차 나란히 <span class=note>(4회차의 시간·상태 소착 명명 주목)</span></h2>
{ent_grid('location')}
<h2>5. 소품 엔티티</h2>
{ent_grid('prop')}

<h2>6. 씬별 인물 배정(VE 합집합) — scene_director 산출</h2>
<div class=tablewrap><table>
<tr><th>씬</th><th>v16</th><th>1회차</th><th>3회차(5.6)</th><th>4회차(원복)</th></tr>
{scene_char_rows}
</table></div>

<h2>7. VE 캐릭터 공백 선택샷 전수 + 결함 분류 (붉은 행 = 결함성)</h2>
{ve_table('v16')}
{ve_table('r1')}
{ve_table('gpt')}
{ve_table('gemini')}
<p class=note>※ 결함성 = 샷 설명에 인물이 명시됐는데 VE 공백. 1회차 S18sh14/S21sh5는 ‘사진 속 인물’ 경계 사례. 1회차 S7(로트와일러 2샷)은 감독 결함이 아니라 엔티티 미승격 유래로 정당 분류. 3회차 S25sh17/S28sh15는 어린 강민숙(C14)·여자아이(C15)를 승격해 놓고 미배정이라 결함성.</p>

<h2>8. 아웃룩(의상) 매핑</h2>
<div class=cols4>{''.join(outlook_col(k) for k in KEYS)}</div>

<h2>9. 에피소드 요약 전문</h2>
<div class=cols4>{''.join(summary_col(k) for k in KEYS)}</div>

<p class=note>생성: 2026-07-11 · 데이터 원천: PostgreSQL theroad (step_run / entity_canon / scene_still / character_outlook / episode) · 실험 산출물(커밋 금지) · 빌더: scratchpad/forest_exp/model_compare/build_gallery4.py</p>
</body></html>"""

with open(OUT, "w") as f:
    f.write(HTML)
print("written", os.path.abspath(OUT), len(HTML))
