#!/usr/bin/env python3
"""GPT(3회차) vs Gemini(4회차) 텍스트 파이프라인 비교 갤러리 빌더 (실험 전용, 커밋 금지)"""
import json, html, os

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

ALIAS = {
    "gpt": "GPT-5.6 Sol", "gpt-terra": "GPT-5.6 Terra",
    "gpt-mini": "Gemini 3.1 Flash <span class=note>(gpt-mini alias 매핑)</span>",
    "gemini-pro": "Gemini 3.1 Pro", "gemini-flash": "Gemini 3.1 Flash",
    "gemini-lite": "Gemini Flash-Lite", "gemini-image": "Gemini 3.1 Flash Image",
    "-": "코드(LLM 미사용)", "mixed": "혼합(이미지)", None: "—",
}
def model_label(run, alias):
    if run == "gpt" and alias == "gpt-mini":
        return "GPT-5.6 Sol <span class=note>(Luna 폐지 안전망)</span>"
    return ALIAS.get(alias, alias or "—")

# (step_id, 용도, 이미지 단계 여부)
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", "저빈도(3씬 이하) 요소 LLM 필터링", 0),
    ("entity_detail", "요소 상세 (stable traits)", 0),
    ("entity_t2i", "요소 참조 이미지용 T2I 프롬프트", 0),
    ("shot_selection", "씬별 최종 스틸 shot 선택 (최대 3)", 0),
    ("scene_director", "씬 감독 — 인물 배정(present_entity_ids) 확정 = 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_place_spec", "W22 직행 — 야외 장소 마커 스펙 저작", 0),
    ("outdoor_place_canon", "W22 직행 — 장소 캐논(실사+맵) 판정·재투영", 0),
    ("outdoor_shot_grounding", "W22 직행 — 샷 접지(존·앵커·카메라)", 0),
    ("world_guide", "월드 가이드 문서", 0),
    ("floor_plan_render", "실내 FP 렌더", 1),
    ("floor_plan_light_sidecar", "FP 조명 사이드카", 1),
    ("floor_plan_overlay_payload", "FP 오버레이 페이로드", 1),
    ("floor_plan_semantic_readback", "FP 의미 판독", 1),
    ("ref_image_gen", "요소 참조 이미지 생성", 1),
    ("composite_image_gen", "합성 참조 이미지 생성", 1),
    ("character_state_variant", "인물 상태 변형 이미지", 1),
    ("background_render", "배경 렌더 (실내 bg + 야외 plate)", 1),
    ("scene_image_pipeline", "씬 스틸 이미지 생성", 1),
]

def esc(s):
    return html.escape(str(s if s is not None else ""))

def badge(v):
    cls = {"GPT 우세": "bg", "Gemini 우세": "bm", "동급/트레이드오프": "bt"}.get(v, "bt")
    return f'<span class="badge {cls}">{esc(v)}</span>'

rows = []
for sid, purpose, is_img in STEPS:
    st = D["steps"].get(sid, {})
    g, m = st.get("gpt", {}), st.get("gemini", {})
    ga, ma = g.get("model"), m.get("model")
    diff = (ga != ma) and not is_img
    dur = X["durations"].get(sid, {})
    durtxt = ""
    if dur.get("gpt") is not None or dur.get("gemini") is not None:
        durtxt = f"{dur.get('gpt','—')}s / {dur.get('gemini','—')}s"
    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>"
        f"<td>{model_label('gpt', ga)}</td><td>{model_label('gemini', ma)}</td>"
        f"<td class=num>{g.get('completed','—')} / {m.get('completed','—')}</td>"
        f"<td class=num>{durtxt}</td></tr>")
step_table = "\n".join(rows)

# ---- 정량 요약 + 판단 ----
ents = {k: D["entities"][k] for k in ("gpt", "gemini")}
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 ("gpt", "gemini")}
sel_total = {k: len(D["selected"][k]) for k in ("gpt", "gemini")}
beats_total = {k: sum(X["beats"][k].values()) for k in ("gpt", "gemini")}
ve_empty = D["ve_empty"]

SUMMARY = [
    ("캐릭터 엔티티", f"{cnt('gpt','character')}종 — 실명·상태 변형 분리(인간형/활성/사진 왜곡)·단역/동물 승격(검은 고양이·의사·보안팀)",
     f"{cnt('gemini','character')}종 — “한국인 남자/여자”, “검은 형상” 등 범용 명명, 단역·동물 미승격",
     "GPT 우세", "명명 구체성과 변형 분리가 참조 정합·VE 커버리지에 직결. Gemini의 범용 명명은 캐릭터 구별력이 약하고, 고양이 미승격이 S17 VE 공백으로 이어짐."),
    ("장소 엔티티", f"{cnt('gpt','location')}종 — 시간 불변(한 장소=한 캐논)",
     f"{cnt('gemini','location')}종 — 같은 장소를 상태·날씨·시간별로 분열(옥탑방 내부 3종, 해안도로 2종, “해 질 무렵의 숲속”)",
     "GPT 우세", "Gemini의 시간·상태 소착은 배경 캐논 공유를 파편화 — ‘같은 장소 일관성’ 저하의 구조적 리스크. spec 시간 불변 계약(v2) 철학과도 배치."),
    ("소품 엔티티", f"{cnt('gpt','prop')}종 — 좌표 메모지·지도·가족사진 등 서사 소품 세분",
     f"{cnt('gemini','prop')}종 — 핵심만 간결",
     "GPT 우세", "서사 단서 소품(좌표 메모지·접근 지도)의 개별 승격이 스틸 소품 배치 정확도에 유리."),
    ("아웃룩(의상)", f"{cnt('gpt','outlook')}종 / 매핑 {len(D['outlooks']['gpt'])}건 — 전 캐릭터 커버",
     f"{cnt('gemini','outlook')}종 / 매핑 {len(D['outlooks']['gemini'])}건 — 민숙 ‘피묻은홈웨어’ 상태 의상 포착",
     "GPT 우세", "커버리지 우위. 단 양쪽 모두 상태 의상 개념은 작동."),
    ("beat 추출", f"총 {beats_total['gpt']}개 (씬당 평균 {beats_total['gpt']/30:.1f})",
     f"총 {beats_total['gemini']}개 (씬당 평균 {beats_total['gemini']/30:.1f})",
     "동급/트레이드오프", "GPT는 과세분 경향(후속 스텝 비용·시간 증가), Gemini는 간결. 스틸 후보 다양성 vs 경제성의 트레이드오프."),
    ("shot 추출", f"총 {shots_total['gpt']}개, 선택 {sel_total['gpt']} (S18 최대 54샷)",
     f"총 {shots_total['gemini']}개, 선택 {sel_total['gemini']}",
     "동급/트레이드오프", "GPT의 2.8배 세분화는 선택 다양성엔 유리하나 shot_selection·scene_detail 비용을 크게 늘림. 품질 자체는 양쪽 성립."),
    ("scene_director → VE 배정", f"VE 캐릭터 공백 선택샷 {len(ve_empty['gpt'])}/{sel_total['gpt']}건 (35%) — 이 중 결함성(설명에 인물 명시) 4건+: S3sh6 혜수, S12sh22 강민숙 전신, S12sh39 문신, S18sh48 얼굴",
     f"VE 캐릭터 공백 {len(ve_empty['gemini'])}/{sel_total['gemini']}건 (19%) — 전수 검토 결과 사물/풍경 인서트로 전부 정당(예외 S17 고양이는 엔티티 미승격 탓)",
     "Gemini 우세", "기지 결함(S3/S24 VE 공백)은 GPT(Sol) 회차 유래로 재확인. Gemini는 S3에 혜수(C04)를 정상 배정 — 인물 배정 신뢰도에서 명백히 우세."),
    ("에피소드 요약", "204자 — 간결하나 아크 생략 (Sol)",
     "356자 — 서사 아크·인과 충실 (Flash)",
     "Gemini 우세", "하위 티어(Flash)임에도 요약 충실도가 높음."),
    ("scene_detail T2I", "구조 동일(composer 조립) — 문장 정밀. 단 캐릭터 ref 0 계열 결함 이력(S8sh12 등)",
     "구조 동일 — 문체 유사, 분위기 서술 풍부",
     "동급/트레이드오프", "T2I 골격은 코드 조립이라 모델 차이가 작게 드러남. GPT 회차의 ID 누락 이력만 감점."),
    ("속도(대표 스텝)", "beat 89s · shot 357s · scene_detail 891s(71샷)",
     "beat 53s · shot 318s · scene_detail 539s(57샷)",
     "Gemini 우세", "샷 수 차이를 감안해도 Gemini(Pro/Flash) 쪽이 일관되게 빠름."),
]
summary_rows = "\n".join(
    f"<tr><td class=area>{esc(a)}</td><td>{g}</td><td>{m}</td><td>{badge(v)}</td><td class=why>{esc(w)}</td></tr>"
    for a, g, m, v, w in SUMMARY)

# ---- 엔티티 나란히 목록 ----
def ent_list(k, t):
    lis = []
    for e in ents[k]:
        if e["type"] != t: continue
        lis.append(f"<li><b>{esc(e['id'])}</b> {esc(e['name'])}</li>")
    return "<ul>" + "\n".join(lis) + "</ul>"

def ent_section(t, title):
    return (f"<div class=cols><div><h4>GPT 회차 ({cnt('gpt',t)})</h4>{ent_list('gpt',t)}</div>"
            f"<div><h4>Gemini 회차 ({cnt('gemini',t)})</h4>{ent_list('gemini',t)}</div></div>")

# ---- VE 공백 상세 ----
def ve_rows(k, defect_ids):
    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 defect_ids else ""
        tag = "결함성" if key in defect_ids else "정당(사물/풍경)"
        out.append(f"<tr class='{cls}'><td>{key}</td><td>{esc(s['desc'])}</td><td>{tag}</td></tr>")
    return "\n".join(out)

GPT_DEFECTS = {"S3sh6", "S12sh22", "S12sh39", "S18sh48", "S25sh17", "S28sh15"}
GEM_DEFECTS = set()

# ---- 씬 캐릭터 배정 표 (S3~) ----
name_map = {k: {e["id"]: e["name"] for e in ents[k]} for k in ("gpt", "gemini")}
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>"
scene_char_rows = "\n".join(
    f"<tr><td>S{s}</td><td>{chars_of('gpt',s)}</td><td>{chars_of('gemini',s)}</td></tr>"
    for s in sorted(int(x) for x in set(D["scene_chars"]["gpt"]) | set(D["scene_chars"]["gemini"])))

# ---- 아웃룩 ----
def outlook_list(k):
    return "<ul>" + "\n".join(f"<li>{esc(o['char'])} → {esc(o['outlook'])}</li>" for o in D["outlooks"][k]) + "</ul>"

# ---- t2i 샘플 ----
def t2i_block(k):
    b = []
    for s in X["t2i"][k][:3]:
        b.append(f"<p class=cap>S{s['scene']} sh{s['shot']}</p><pre>{esc(s['t2i'])}</pre>")
    return "\n".join(b)

HTML = f"""<!DOCTYPE html>
<html lang=ko><head><meta charset=utf-8>
<meta name=viewport content="width=device-width, initial-scale=1">
<title>분석 모델 비교 — GPT-5.6 vs Gemini (텍스트 파이프라인)</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:22px; }} 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.num {{ white-space:nowrap; text-align:right; }}
td.sid {{ font-family:ui-monospace,monospace; font-size:12.5px; white-space:nowrap; }}
td.purpose {{ min-width:220px; }}
td.area {{ font-weight:700; white-space:nowrap; }}
td.why {{ color:#b9c2cc; }}
.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; }}
.cols {{ display:grid; grid-template-columns:1fr 1fr; gap:18px; }}
.cols ul {{ margin:4px 0; padding-left:18px; }}
.note {{ color:#8a939e; font-size:11.5px; }}
.warn {{ color:#ff8f8f; }}
.cap {{ color:#9ecbff; margin:10px 0 4px; }}
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; }}
</style></head><body>
<h1>분석 모델 비교 — GPT-5.6(3회차) vs Gemini 원복(4회차) · 텍스트 파이프라인 전체</h1>
<div class=box>
<b>비교쌍</b>: 3회차 = 프로젝트 <code>7872eda9</code> (GPT-5.6 Sol/Terra 주력) · 4회차 = <code>8207aadc</code> (Gemini 원복 커밋 8c0719fe — 14스텝 gemini-pro 복귀). 동일 시나리오(금월도 1부) 풀 E2E, 분석 스텝 프롬프트 팩 동일(4회차 추가 수정은 야외 직행 전용).<br>
<b>주의</b>: ① 단일 회차 비교 — LLM 비결정성에 의한 회차 간 편차 존재 ② 4회차 육안 불합격(서구화)은 <u>이미지·직행 캐논 축</u>의 문제로 이 텍스트 비교와 별개(사용자 확인: “gemini/gpt 문제 아님”) ③ 5회차는 텍스트 분석 구성이 4회차와 동일하여 4회차로 대표.
</div>

<h2>1. 단계별 사용 모델 + 용도 (파란 행 = 두 회차 모델이 다른 스텝, 회색 = 이미지 단계 · 비교 제외)</h2>
<div class=tablewrap><table>
<tr><th>스텝</th><th>용도</th><th>GPT 회차 모델</th><th>Gemini 회차 모델</th><th>산출 수 (GPT/Gem)</th><th>소요 (GPT/Gem)</th></tr>
{step_table}
</table></div>

<h2>2. 산출물 정량·정성 비교 + 판단</h2>
<div class=tablewrap><table>
<tr><th>영역</th><th>GPT 회차 (Sol/Terra)</th><th>Gemini 회차 (Pro/Flash)</th><th>판단</th><th>근거</th></tr>
{summary_rows}
</table></div>

<div class="box verdict">
<h3 style="margin-top:0">종합 판정 (Claude)</h3>
<b>혼합 배분이 정답 — 계열별 우위가 명확히 갈린다.</b>
<ul>
<li><b>엔티티 추출·캐논 명명 계열 → GPT(Sol/Terra) 우세</b>: 실명·상태 변형 분리·단역/동물 승격·시간 불변 장소. Gemini의 범용 명명(“한국인 남자”)과 장소 시간 소착(옥탑방 내부 3분열)은 참조 정합·장소 일관성에 구조적 감점.</li>
<li><b>감독·배정 계열(scene_director → VE) → Gemini Pro 우세</b>: 기지 결함이던 인물 명시 샷 VE 공백은 GPT 회차 유래로 재확인(결함성 4건+ vs 0건). 현 Gemini 원복 구성이 이 축엔 맞다.</li>
<li><b>beat/shot 추출 → 트레이드오프</b>: GPT 2.8배 세분(546 vs 193샷)은 다양성 vs 비용. 현 요구(씬당 최대 3선택)에는 Gemini의 간결함으로 충분.</li>
<li><b>후속 실험 제안</b>: 현 구성(Gemini 원복)에서 <u>엔티티 추출 계열(entity_character_list / entity_all·extract_character / entity_extract_location)만 GPT 재이관</u>하는 하이브리드가 양쪽 강점을 취하는 1순위 후보. 단, 장소 시간 소착은 팩 계약(시간 불변 조항)으로도 교정 가능성이 있어 모델 교체 전 팩 실험이 저비용.</li>
</ul>
</div>

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

<h2>6. 씬별 인물 배정(VE 합집합) — scene_director 산출 비교</h2>
<div class=tablewrap><table>
<tr><th>씬</th><th>GPT 회차</th><th>Gemini 회차</th></tr>
{scene_char_rows}
</table></div>

<h2>7. VE 캐릭터 공백 선택샷 전수 + 결함 분류 (붉은 행 = 결함성: 설명에 인물이 명시됐는데 VE 공백)</h2>
<h4>GPT 회차 — {len(ve_empty['gpt'])}/{sel_total['gpt']}건</h4>
<div class=tablewrap><table><tr><th>샷</th><th>설명</th><th>분류</th></tr>{ve_rows('gpt', GPT_DEFECTS)}</table></div>
<h4>Gemini 회차 — {len(ve_empty['gemini'])}/{sel_total['gemini']}건</h4>
<div class=tablewrap><table><tr><th>샷</th><th>설명</th><th>분류</th></tr>{ve_rows('gemini', GEM_DEFECTS)}</table></div>
<p class=note>※ S25sh17/S28sh15(사진 속 인물)는 ‘매체 속 공간’ 규칙상 경계 사례지만, GPT 회차는 어린 강민숙(C14)·열두 살 여자아이(C15)를 엔티티로 승격해 놓고도 배정하지 않아 결함성으로 분류.</p>

<h2>8. 아웃룩(의상) 매핑</h2>
<div class=cols><div><h4>GPT 회차 ({len(D['outlooks']['gpt'])}건)</h4>{outlook_list('gpt')}</div>
<div><h4>Gemini 회차 ({len(D['outlooks']['gemini'])}건)</h4>{outlook_list('gemini')}</div></div>

<h2>9. 에피소드 요약 전문</h2>
<div class=cols>
<div><h4>GPT 회차 (Sol, 204자)</h4><pre>{esc(D['episode_summary']['gpt'])}</pre></div>
<div><h4>Gemini 회차 (Flash, 356자)</h4><pre>{esc(D['episode_summary']['gemini'])}</pre></div>
</div>

<h2>10. scene_detail T2I 샘플 (수리영 단독 리액션 계열 동형 샷)</h2>
<div class=cols>
<div><h4>GPT 회차</h4>{t2i_block('gpt')}</div>
<div><h4>Gemini 회차</h4>{t2i_block('gemini')}</div>
</div>

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

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