#!/usr/bin/env python3
"""전체 파이프라인 개발자 상세도 빌더 — 탭 구조.

출력 = docs/architecture/pipeline-full.html

실행:
    cd backend && .venv/bin/python ../docs/architecture/_build_pipeline_doc.py

원칙: 문서의 사실은 전부 코드에서 뽑는다 — 매니페스트(스텝 수 포함), 스텝 클래스
(파일·docstring·줄수), 프롬프트 팩 디렉토리(최신 버전)를 실행 시점에 직접
읽는다. 손으로 쓰는 것은 구간 해설과 전용 상세도뿐이다. 그래서 스텝이
늘거나 팩이 발행되면 다시 돌리기만 하면 문서가 따라온다.
"""
from __future__ import annotations

from datetime import datetime
import html
import inspect
import json
import re
import sys
from pathlib import Path
from zoneinfo import ZoneInfo

REPO = Path(__file__).resolve().parents[2]
OUT = REPO / "docs" / "architecture" / "pipeline-full.html"
GENERATED_AT = datetime.now(ZoneInfo("Asia/Seoul")).strftime(
    "%Y-%m-%d %H:%M KST"
)
sys.path.insert(0, str(REPO / "backend"))


def collect():
    """매니페스트·스텝 클래스·팩을 코드에서 직접 읽는다."""
    from app.core.step_manifest import STEP_MANIFEST
    from app.core.steps import STEP_CLASSES

    steps = []
    for sid, v in STEP_MANIFEST.items():
        d = dict(v)
        d["step_id"] = sid
        steps.append(d)
    steps.sort(key=lambda x: float(x.get("order", 999)))

    files = {}
    for sid, cls in STEP_CLASSES.items():
        try:
            f = (inspect.getsourcefile(cls) or "").split("TheRoad-I1/")[-1]
            loc = len(inspect.getsource(cls).splitlines())
        except Exception:  # noqa: BLE001
            f, loc = "", 0
        # 자기 docstring만 — getdoc 은 상위 StepRunner 것을 물려받아
        # "파이프라인 단계 실행기"가 20개 카드에 복사되던 결함이 있었다.
        doc = (cls.__dict__.get("__doc__") or "").strip()
        if not doc:
            mod = sys.modules.get(cls.__module__)
            doc = (inspect.getdoc(mod) or "").strip() if mod else ""
        files[sid] = {"file": f, "cls": cls.__name__, "doc": doc, "loc": loc}

    def vkey(v):
        m = re.match(r"(\d+)\.(\d+)", v)
        return (int(m.group(1)), int(m.group(2))) if m else (0, 0)

    packs = {}
    for d in sorted((REPO / "prompts" / "_base").iterdir()):
        if not d.is_dir():
            continue
        vers = sorted([p.name for p in d.iterdir() if p.is_dir()], key=vkey)
        if not vers:
            continue
        packs[d.name] = {
            "versions": len(vers), "latest": vers[-1],
            "files": sorted(p.name for p in (d / vers[-1]).glob("*.md"))}

    # 스텝 → 팩 매핑. 이름이 같은 팩만 붙이면 절반이 빠진다(팩 85 중 40).
    # 클래스 본문에 팩 이름이 문자열로 있으면 **확정**, 같은 파일에 여러
    # 스텝이 있어 모듈 수준에서만 보이면 **추정**으로 구분해 표시한다.
    packmap = {}
    names = sorted(packs)
    for sid, cls in STEP_CLASSES.items():
        try:
            csrc = inspect.getsource(cls)
        except Exception:  # noqa: BLE001
            csrc = ""
        hit = [p for p in names if re.search(rf'["\']{re.escape(p)}["\']', csrc)]
        if hit:
            packmap[sid] = {"packs": hit, "sure": True}
            continue
        try:
            msrc = Path(inspect.getsourcefile(cls) or "").read_text("utf-8")
        except Exception:  # noqa: BLE001
            msrc = ""
        hit = [p for p in names if re.search(rf'["\']{re.escape(p)}["\']', msrc)]
        if hit:
            packmap[sid] = {"packs": hit, "sure": False}

    # 체크포인트 데이터 계약 = 실행된 CP 에서 실측한다. 스키마 문서를 손으로
    # 쓰면 곧 어긋나므로, 가장 많이 진행된 에피소드 하나를 자동으로 찾아
    # `data` 키·schema_version·resolved_model 을 읽는다(프로젝트 식별자는
    # 문서에 싣지 않는다 — 작품 데이터는 문서의 사실이 아니다).
    cpschema, cp_total = {}, 0
    proj = REPO / "projects"
    best, bestn = None, 0
    if proj.is_dir():
        for p in proj.iterdir():
            eps = p / "checkpoints" / "episodes"
            if not eps.is_dir():
                continue
            for ep in eps.iterdir():
                if not ep.is_dir():
                    continue
                n = len([d for d in ep.iterdir() if (d / "manifest.json").is_file()])
                if n > bestn:
                    best, bestn = ep, n
    if best:
        cp_total = bestn
        for d in sorted(best.iterdir()):
            man = d / "manifest.json"
            if not man.is_file():
                continue
            try:
                j = json.loads(man.read_text(encoding="utf-8"))
            except Exception:  # noqa: BLE001
                continue
            data = j.get("data")
            cpschema[d.name] = {
                "keys": sorted(data)[:14] if isinstance(data, dict) else [],
                "key_total": len(data) if isinstance(data, dict) else 0,
                "schema_version": j.get("schema_version"),
                "resolved_model": j.get("resolved_model"),
                "counts": {k: j.get(k) for k in (
                    "applicable_count", "completed_count", "failed_count")
                    if j.get(k) is not None},
            }
    return steps, files, packs, packmap, cpschema, cp_total


STEPS, FILES, PACKS, PACKMAP, CPSCHEMA, CP_TOTAL = collect()

BY_ID = {s["step_id"]: s for s in STEPS}


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


def nid(step_id: str) -> str:
    return "N" + step_id.replace(".", "_").replace("-", "_")


def mlabel(text: str) -> str:
    """mermaid 라벨 — 따옴표로 감싸고 내부 특수문자를 무해화한다."""
    t = str(text).replace('"', "'").replace("\n", " ")
    for ch in "()[]{}<>|":
        t = t.replace(ch, " ")
    return '"' + t + '"'


# ── 탭 정의 ────────────────────────────────────────────────────────────
# key, 제목, 스텝 id 목록(순서 = 실행 순서), 해설
TABS = [
    ("overview", "0 · 전체 조감", [], ""),
    ("text", "1 · 텍스트 준비", [
        "planning_doc_analysis", "text_cleanup", "scene_segmentation",
        "episode_summary", "visual_world_rules", "scene_save",
        "scene_summary",
    ], """
시나리오 원문 한 편이 들어와 <b>씬 단위로 쪼개져 저장</b>되기까지의 구간이다.
이 구간의 산출이 뒤의 모든 것의 근거가 되므로, 여기서 잘리거나 뭉개지면
하류 전체가 조용히 빈약해진다.

<div class="note"><b>절대 규칙 — 자르지 않는다.</b> 시나리오 전문을 한 번에
전달한다. 청킹·<code>[:400]</code> 류 절단 금지. 씬 분할(<code>scene_split</code>)은
v4 에서 제거됐고 원본 씬이 그대로 보존된다.</div>

<code>scene_segmentation</code> 은 LLM 이 <b>정규식 파서를 정의</b>하고 코드가
그것을 실행하는 형태다 — 글자 패턴으로 의미를 판단하지 않는다는 규칙과,
세그먼테이션이라는 기계적 작업을 구분한 결과다.
<code>visual_world_rules</code> 는 이 작품의 물리적 존재 판단 기준을 만들어
<code>director_notes</code> 로 beat·shot·감독 단계에 전달된다.
"""),
    ("beatshot", "2 · Beat → Shot 분해", [
        "beat_extract", "shot_extract", "shot_validator", "shot_selection",
    ], """
v4 파이프라인의 계층 축이다. <b>씬 → beat(상태 변화) → shot(스틸컷) →
선택된 shot</b> 으로 내려간다. DB 의 <code>scene_still</code> 한 행이 곧 1 shot 이고
<code>scene_index</code> 로 씬에 묶인다.

<div class="note"><b>모델 배정 근거(2026-07-11 4회차 전수 비교).</b>
beat·shot 추출은 Gemini Pro 다. Sol 은 회수율이 높은 대신 beat/shot 을
<b>+160% 과세분</b>하고 핵심 배정에 결함이 있었다. 반대로 엔티티 후보
발굴은 Sol 이 낫다 — 그래서 인물 추출만 Sol 로 되돌렸다.</div>

<code>shot_selection</code> 은 씬당 최대 3개를 고르고, 그 뒤 단계는 선택된
shot 에만 붙는다. 각 shot 은 촬영 기법 2종을 받아 T2I variation 2개가 된다.
"""),
    ("entity", "3 · 엔티티 추출", [
        "entity_character_list", "entity_all_character",
        "entity_extract_character", "entity_all_location",
        "entity_extract_location", "entity_all_prop", "entity_extract_prop",
        "entity_merge", "entity_relation", "entity_filter", "entity_detail",
        "entity_t2i",
    ], """
<b>인물 → 배경 → 소품</b> 3단계를 각각 별도 스텝으로 분리했다. 리스팅(후보
회수)과 추출(상세 확정)이 또 나뉘어 있어, 회수율이 좋은 모델과 확정이
정확한 모델을 따로 쓸 수 있다.

<div class="note"><b>GPT 엔티티 출력은 후보 발굴용이다 — SOT 직승격 금지.</b>
dedupe 게이트가 후속 과제로 남아 있다. <code>entity_merge</code> 가 중복을
병합하고 <code>entity_filter</code> 가 저빈도(3씬 이하) 요소를 LLM 으로 걸러낸다.</div>

요소의 <b>변형</b>은 같은 엔티티를 덮어쓰지 않고 별도 EntityCanon 으로 두고
<code>entity_relation</code> 이 RelationFact 로 의존성을 잇는다. 변신·상태
변화 캐릭터가 한 스틸컷에서 원본과 변형으로 동시에 나오지 않게 하는 근거다.
"""),
    ("direction", "4 · 연출 · 스테이징", [
        "scene_director", "shot_director", "scene_camera_flow",
        "shot_dependency", "outlook_phase1", "outlook_phase2",
        "outlook_phase3", "shot_staging", "shot_essence_extraction",
        "scene_consistency", "outlook_dedup",
    ], """
누가 화면에 있고, 무엇을 입고 있고, 카메라가 어디서 보는지를 확정하는
구간이다.

<div class="note"><b>visible_entities(VE)는 LLM 이 만들지 않는다.</b>
<code>scene_director</code> 가 확정한 데이터에서 <b>코드가 자동 구축</b>한다.
그리고 하류 T2I 프롬프트가 VE 밖 엔티티를 쓰면 retry 후 강제 제거된다 —
이것이 "씬에 배정된 인물 전체를 한 컷에 다 넣지 않는다"를 강제하는 층이다.</div>

아웃룩은 <b>3단계</b>다 — 목록 추출(phase1) → 씬별 매핑(phase2) → 병합
정리(phase3). 의상은 인물과 별도 축이라 T2I 에서 복합 ID(<code>C08O09</code>)로
받지 않고 <b>인물·아웃룩을 따로 받아 코드가 조합</b>한다.
<code>shot_staging</code> 이 조명·무드의 SOT 이다(<code>scene_still.lighting_json</code>
아님 — v4 는 그 필드를 저작하지 않는다).
"""),
    ("space", "5 · 배경 계획 · 도면", [
        "background_classify", "background_master_plan", "floor_plan_prompt",
        "floor_plan_render", "floor_plan_light_sidecar",
        "floor_plan_overlay_payload", "base_location_dossier",
        "floor_plan_geometry_readback", "floor_plan_semantic_readback",
        "shot_projection_card", "bg_space_partition", "dwelling_zone_map",
        "shot_aware_bg_render_plan", "background_prompt",
        "episode_reference_policy", "visual_continuity_anchor",
    ], """
배경을 <b>샷마다 새로 상상하지 않게</b> 만드는 구간이다. 장소를 그룹으로
묶고(<code>background_classify</code>), 도면을 그리고, 그 도면에서 카메라가
무엇을 볼 수 있는지를 기계적으로 되읽는다.

<div class="note"><b>readback 이 두 겹인 이유.</b>
<code>floor_plan_geometry_readback</code> 은 좌표·기하를,
<code>floor_plan_semantic_readback</code> 은 마커가 <b>의미대로 그려졌는지</b>를
따로 확인한다. 이미지 모델은 도면을 그리라고 하면 형태는 맞추고 라벨을
어긋나게 두는 일이 잦다.</div>

<div class="note"><b>도면 재개는 성공 항목만 재사용한다(2026-08-19).</b>
<code>floor_plan_geometry_readback</code> 은 설정 지문, 도면별 입력 지문
(dossier + 같은 경로의 이미지 bytes), 검토 HTML 존재가 모두 같을 때 성공한
도면을 1비트도 바꾸지 않고 옮긴다. 실패 도면만 다시 읽고, <code>force</code> 는
재사용하지 않는다. 재사용분은 실제 VLM 호출 수에 더하지 않는다.</div>

<code>shot_projection_card</code> 가 샷별 가시 범위를 카드로 만들고,
<code>bg_space_partition</code> · <code>dwelling_zone_map</code> 이 plate group 과
거주 구역을 나눈다. 그 위에서 <code>shot_aware_bg_render_plan</code> 이
"이 샷의 배경을 어떤 판으로 렌더할지"를 정한다.
"""),
    ("detail", "6 · 샷 상세 · T2I 프롬프트", [
        "scene_detail", "shot_dependency_t2i", "t2i_review",
        "zoom_continuity_anchor",
    ], """
확정된 연출·배경 위에서 <b>실제 이미지 프롬프트</b>가 나오는 구간이다.

<div class="note"><b>T2I 단일 스틸컷 원칙.</b> 하나의 <code>t2i_prompt</code> 는
한 순간·한 시공간만 담는다. ①변형 캐릭터는 그 순간의 형태 ID 하나만
②몽타주·회상·인터컷·꿈·평행액션이 섞인 씬은 하나만 선택 ③씬에 배정된
전체 인물을 한 컷에 다 넣지 않는다. 콘티형으로 단순하게 — 한 컷 = 한 문장,
인물 2~3명이 최대다.</div>

<code>t2i_review</code> 가 프롬프트를 검수하고, VE 위반(배정되지 않은 엔티티
등장)이 발견되면 retry 후 강제 제거한다.
"""),
    ("outdoor", "7 · 야외 구조물 계보", [
        "outdoor_site_layout", "outdoor_place_spec", "outdoor_lane_plan",
        "outdoor_place_canon", "outdoor_frame_mode", "outdoor_shot_grounding",
        "shot_ref_classify", "background_share_plan", "shot_continuity",
        "outdoor_structure_form_reference", "outdoor_structure_seed",
    ], "__OUTDOOR__"),
    ("image", "8 · 이미지 생성", [
        "world_guide", "space_set_bg", "ref_image_gen", "composite_image_gen",
        "character_state_variant", "background_render", "shot_conti_light",
        "scene_image_pipeline",
    ], """
참조 이미지 → 합성 → 배경 → 콘티 → 최종 샷으로 내려가는 구간이다.
앞 단계 산출이 여기서 실제 그림이 된다.

<div class="note"><b>계보(lineage)가 곧 일관성이다.</b> 인물 참조를 만들고
(<code>ref_image_gen</code>), 인물+아웃룩을 합성하고(<code>composite_image_gen</code>),
상태 변형을 따로 만든다(<code>character_state_variant</code>). 배경은
<code>background_render</code> 가 그리고, <code>shot_conti_light</code> 가 경량
콘티를 얹은 뒤 <code>scene_image_pipeline</code> 이 최종 샷을 합성한다.</div>

<div class="note"><b>최종 스틸 엔진과 시네마틱 변환은 별도 축이다.</b>
기본 생성 엔진은 <code>nb2</code>(Gemini image)이고 설정으로
<code>grok2</code>(xAI Grok Imagine 2.0)를 고를 수 있다. 생성 엔진과 무관하게
시네마틱 변환을 켜면 선정 원본 한 장을 Grok i2i 로 변환하며, 선정 원본은
이전 샷 연속성 앵커로 보존한다.</div>

<div class="note"><b>수정 편집 참조 선별(2026-08-19).</b> 기능을 켜면 결함
검사가 각 지적에 <code>needs_ref_indices</code> 를 구조 필드로 내고, 수정 호출은
요구된 참조만 붙인다. 선별 칸이 없는 지적이 하나라도 섞인 옛 기록은 안전하게
전부 첨부하며, 없는 엔티티를 새로 넣으라는 지적은 전용 지시 절을 더한다.
선별 여부와 누락은 <code>records.json</code> 의 <code>fix_ref_gate</code>에 남는다.</div>

<div class="note"><b>Grok 발송 보호(2026-08-18~19).</b> 프롬프트와 참조 라벨의
UTF-8 합계가 7,900B를 넘으면 샷 고유 재료가 아닌 일반 규칙 절만 순서대로
덜고, 실제 모델 상한 8,000B를 넘을 때만 발송 전에 막는다. HTTP 200 본문에
실린 <code>429</code>·<code>5xx</code> 오류도 제한 횟수 안에서 다시 보내되,
검열 거부는 비용만 반복되므로 이 재시도에서 제외한다.</div>

<div class="note"><b>검열된 시네마틱 변환은 원본으로 종결한다(2026-08-19).</b>
같은 원본 지문에서 검열 거부가 설정 횟수에 닿으면 <code>declined</code>를
기록하고 원본을 최종본으로 확정한다. 다음 재개는 유료 변환을 다시 부르지
않으며, 원본 지문이 바뀌면 새 입력으로 다시 시도한다.</div>

<div class="note"><b>콘티는 러프해야 한다(2026-07-27 육안 지시).</b> 세밀한
콘티를 주면 하류가 그 화풍을 물려받아 최종이 "만화 위 실사"가 된다. 러프
연필이라야 실사 배경 + 선화 인물이 깨끗하게 분리된다. 대신 러프할수록 방향
정보가 성기므로 <b>방향 텍스트를 결정론으로 주입</b>하는 것과 짝을 이룬다.</div>

<div class="note"><b>코드로 이미지에 그리지 않는다.</b> PIL 마커·콘·텍스트
오버레이는 전면 금지다. 시각 요소는 항상 이미지 모델의 몫이고, 맵 위에
카메라·엔티티를 표시하는 단계는 <b>i2i 로 작화</b>한다.</div>
"""),
    ("contract", "9 · 실행 계약", [], "__CONTRACT__"),
    ("all", "10 · 전체 스텝 표", [], ""),
    ("legacy", "11 · 레거시 · 폐기", [], """
<code>lifecycle</code> 이 <code>active</code> 가 아닌 스텝이다. <b>삭제하지 않는
것이 규칙</b>이라 코드에 남아 있고, 매니페스트가 상태를 들고 있다. 새 경로를
읽을 때 이 목록에 있는 스텝은 건너뛰어야 한다.
"""),
]

OUTDOOR_INTRO = """
이 문서의 나머지 구간이 "모든 장소"를 다룬다면, 여기는 <b>야외에 서 있는
사람이 지은 구조물</b> 하나를 확정 실사로 만드는 전용 계보다. 건물의 형태·
규모·재질·표시면이 샷마다 흔들리면 같은 장소로 읽히지 않기 때문에 별도
계보로 떨어져 있다.

<div class="note"><b>권위를 쪼갠다 — 이 계보의 핵심 설계.</b><br>
· <b>뼈대(형태)</b> = 선 스케치 — 층수·개구부·계단 주행이 또렷하다<br>
· <b>표면(재질·풍화·분위기)</b> = 검색으로 회수한 실제 사진<br>
· <b>표시면의 글자</b> = 작품의 것(참조의 상호는 절대 베끼지 않는다)<br>
· <b>없는 정보</b> = 사전조사(typology prior)가 원본어 검색으로 메운다<br>
한 장의 참조에 전부 맡기면 <b>나쁜 참조의 구조가 그대로 전이된다</b> —
실측으로 확인된 결함이고, 권위 분리가 그것을 끊었다.</div>

<div class="note warn"><b>구현 상태.</b> 아래 5스텝 계보 중 현재 코드에
있는 것은 <code>outdoor_structure_form_reference</code> 와
<code>outdoor_structure_seed</code> 둘뿐이다. <code>seed_plan</code> ·
<code>skeleton</code> · <code>place_mark</code> 는 설계·실험만 끝났고 스텝 파일이
없다. 사전조사 모듈(<code>typology_prior</code>)은 존재하지만 <b>아무 스텝도
호출하지 않는다</b>. 심층 상세도는
<a href="./outdoor-structure-pipeline.html">야외 구조물 개발자 상세도</a> 에 있다.</div>
"""


# ── 구간 전용 상세도 (손으로 쓴 것 — 코드 흐름을 읽어 그린다) ────────
EXTRA = {
    "outdoor": [
        ("현행 — 검색 그라운딩 형태 참조 (21.915) 내부", """flowchart TD
  S1["place spec items 유무로 대상 선별<br/>spec 결손 = no_spec 으로 명시 제외"]
  S2["검색 지시문 저작<br/>원본어 질의 · 작품 고유명사 배제"]
  S3["웹 검색 · 후보 다운로드<br/>cand_NN.png"]
  S4{"이중 판정<br/>GPT + Gemini"}
  S5["form_ref 확정<br/>path + sha256 + asset_id"]
  S6["mandatory_group_ids<br/>= 소비자의 fail-closed 기준"]
  S1 --> S2 --> S3 --> S4 --> S5 --> S6
  S4 -. "회수 부족" .-> S3
  style S4 fill:#fff3d6,stroke:#c8922a
  style S5 fill:#d6ecff,stroke:#2a6fc8
  style S6 fill:#e8f5e9,stroke:#2e7d32""",
         """심판 점수 척도가 서로 달라(0~100 vs 0~10) 한쪽이 평균을 지배하던
         결함이 실측돼 팩 v4 에서 척도를 맞췄다. 검색 질의는 <b>원본어</b>가
         최고 점수 기준 3/3 우세였다(3그룹 A/B)."""),
        ("현행 — 구조물 씨드 (21.92) 내부 루프", """flowchart TD
  B["브리프 저작<br/>규모 · 층수 · 재질 · 표시 문안"]
  R["3변형 롤 생성<br/>form_ref 를 참조로 첨부"]
  J{"judge<br/>층수 계수 절차 · 현실감 PASS/FAIL"}
  C["critique<br/>결함 목록"]
  F["i2i 수정"]
  RJ{"수정본 재판정<br/>원본 포함 2후보 블라인드"}
  OUT(["확정 씨드"])
  B --> R --> J --> C --> F --> RJ --> OUT
  J -. "eligible 0 = fail-closed" .-> X["그룹 실패<br/>가장 덜 나쁜 후보 채택 금지"]
  style J fill:#fff3d6,stroke:#c8922a
  style RJ fill:#fff3d6,stroke:#c8922a
  style OUT fill:#d6ecff,stroke:#2a6fc8
  style X fill:#ffe0e0,stroke:#c62828""",
         """<b>층수 병목의 진범은 judge 였다.</b> 3롤 중 두 장이 요구대로
         4층인데 3층짜리가 10점으로 뽑혔다 — 생성이 아니라 <b>선택</b>이
         문제였고, 팩 v13 에서 계수 절차를 넣자 같은 이미지 재판정 2회 모두
         뒤집혔다. 판정을 거친 산출은 "생성이 만든 것"이 아니라 "판정이 고른
         것"이므로, 생성 탓을 하기 전에 탈락 후보부터 열어야 한다."""),
        ("목표 — 5스텝 계보 (설계 확정 · 대부분 미구현)", """flowchart TD
  A["21.915 form_reference<br/>후보 풀 · form_ref · look_ref · 이름 관례"]
  B["21.916 seed_plan<br/>저작 A + 전략 판정 B"]
  C["21.917 skeleton<br/>선 스케치 + 구조 게이트"]
  D["21.92 seed<br/>뼈대 + 표면 2권위 합성"]
  E["21.925 place_mark<br/>표시면 재작화 · effective_seed"]
  P["사전조사 typology_prior<br/>siting → scale → composition → naming"]
  P -. "침묵한 항목만" .-> B
  A --> B --> C --> D --> E
  style A fill:#d6ecff,stroke:#2a6fc8
  style B fill:#efe3ff,stroke:#7b4fbd
  style C fill:#efe3ff,stroke:#7b4fbd
  style D fill:#efe3ff,stroke:#7b4fbd
  style E fill:#efe3ff,stroke:#7b4fbd
  style P fill:#fff3d6,stroke:#c8922a""",
         """<b>사전조사는 순서가 계약이다.</b> <code>siting</code>(입지)을 먼저
         묻지 않고 규모를 물으면, 틀린 유형을 조사한 뒤 그 가정에 인용을
         붙여 준다 — validator 가 "첫 문항은 siting" 을 강제한다. 채택 규칙도
         "두 검색 일치"에서 <code>[single source]</code> 생존으로 개정됐다.
         초판이 가장 필요한 항목을 버렸기 때문이다."""),
    ],
    "image": [
        ("이미지 계보 — 무엇이 무엇을 참조하는가", """flowchart TD
  E["엔티티 T2I 프롬프트<br/>15 entity_t2i"]
  R(["23 ref_image_gen<br/>인물 참조"])
  O(["24 composite_image_gen<br/>인물 + 아웃룩"])
  V(["24.5 character_state_variant<br/>상태 변형"])
  BG(["24.72 background_render<br/>배경 판"])
  SEED(["21.92 구조물 씨드"])
  CT(["24.73 shot_conti_light<br/>러프 콘티"])
  FIN(["25 scene_image_pipeline<br/>최종 샷"])
  E --> R --> O --> V
  SEED -.-> BG
  BG --> CT
  O -. "인물 참조" .-> FIN
  V -. "변형 상태" .-> FIN
  BG --> FIN
  CT --> FIN
  style FIN fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
  style SEED fill:#efe3ff,stroke:#7b4fbd""",
         """최종 샷은 <b>배경 · 콘티 · 인물 참조</b>를 함께 받는다. 콘티가
         세밀하면 그 화풍이 최종까지 전이되므로 <b>러프 연필</b>로 유지하고,
         대신 방향은 텍스트로 결정론 주입한다. 맵 위 마커 표시가 필요하면
         코드로 그리지 않고 <b>i2i 로 작화</b>한다."""),
    ],
}


def extras_html(key):
    out = []
    for title, code, note in EXTRA.get(key, []):
        out.append(f'<h3>{e(title)}</h3>'
                   f'<pre class="mermaid">{code}</pre>'
                   f'<div class="note">{note}</div>')
    return "\n".join(out)


def step_rows(ids):
    rows = []
    for sid in ids:
        s = BY_ID.get(sid)
        if not s:
            continue
        f = FILES.get(sid, {})
        pack = PACKS.get(sid)
        rows.append((s, f, pack))
    return rows


def render_flow(ids, tab_key):
    """탭 내 흐름 — 구간 내 의존은 실선, 구간 밖 선행은 단일 노드로 접는다.

    외부 의존을 노드마다 펼치면 20개 넘게 붙어 라벨이 뭉개진다(실측).
    어느 스텝이 무엇을 선행으로 받는지는 아래 스텝 카드의 '선행' 항목이
    전수로 들고 있으므로, 흐름도는 구간 내 순서를 읽는 데 집중시킨다.
    """
    inside = [i for i in ids if i in BY_ID]
    inset = set(inside)
    lines = ["flowchart TD"]
    ext_targets, ext_deps = [], set()
    for sid in inside:
        for dep in BY_ID[sid].get("depends_on", []) or []:
            if dep not in inset and dep in BY_ID:
                ext_deps.add(dep)
                if sid not in ext_targets:
                    ext_targets.append(sid)
    if ext_deps:
        lines.append(
            f"  EXT[/{mlabel('앞 구간 산출 ' + str(len(ext_deps)) + '종')}/]")
    for sid in inside:
        s = BY_ID[sid]
        label = f"{s['order']} {s.get('label', sid)}\\n{sid}"
        shape = "([%s])" if s.get("step_type") == "asset" else "[%s]"
        lines.append(f"  {nid(sid)}{shape % mlabel(label)}")
    for sid in inside:
        for dep in BY_ID[sid].get("depends_on", []) or []:
            if dep in inset:
                lines.append(f"  {nid(dep)} --> {nid(sid)}")
    for sid in ext_targets:
        lines.append(f"  EXT -.-> {nid(sid)}")
    for sid in inside:
        s = BY_ID[sid]
        if s.get("lifecycle") != "active":
            lines.append(
                f"  style {nid(sid)} fill:#eeeeee,stroke:#999,"
                "stroke-dasharray: 5 3")
        elif s.get("step_type") == "asset":
            lines.append(f"  style {nid(sid)} fill:#d6ecff,stroke:#2a6fc8")
        elif s.get("provider") == "gemini":
            lines.append(f"  style {nid(sid)} fill:#e8f5e9,stroke:#2e7d32")
    if ext_deps:
        lines.append("  style EXT fill:#fbfbfb,stroke:#bbb")
    return "\n".join(lines)


MODEL_BADGE = {
    "gemini-pro": "gem", "gemini-flash": "gem", "gemini-lite": "gem",
    "gemini-image": "img", "gpt-image-2": "img", "gpt": "gpt",
    "gpt-mini": "gpt", "mixed": "mix", "-": "code",
}


def cards_html(ids):
    out = []
    for s, f, pack in step_rows(ids):
        sid = s["step_id"]
        model = s.get("default_model", "")
        badge = MODEL_BADGE.get(model, "gpt")
        life = s.get("lifecycle", "active")
        deps = s.get("depends_on") or []
        chips = []
        if s.get("fan_out"):
            chips.append('<span class="chip">병렬 fan-out</span>')
        if s.get("applicability") and s["applicability"] != "always":
            chips.append(f'<span class="chip">{e(s["applicability"])}</span>')
        if s.get("resume_sensitive"):
            chips.append('<span class="chip">resume 민감</span>')
        if s.get("consumes_downstream"):
            chips.append('<span class="chip">하류 소비</span>')
        if s.get("modifies_checkpoints"):
            chips.append(
                '<span class="chip">CP 수정: '
                + e(", ".join(s["modifies_checkpoints"])) + "</span>")
        if life != "active":
            chips.append(f'<span class="chip bad">{e(life)}</span>')
        doc = (f.get("doc") or "").strip()
        doc_first = doc.split("\n")[0] if doc else ""
        doc_rest = "\n".join(doc.split("\n")[1:]).strip() if doc else ""
        pm = PACKMAP.get(sid)
        pack_html = ""
        if pm:
            items = []
            for pn in pm["packs"]:
                p = PACKS.get(pn) or {}
                items.append(
                    f'<code>{e(pn)}</code> v<code>{e(p.get("latest", "?"))}</code>'
                    f'<span class="muted">·{p.get("versions", "?")}판'
                    f'·{len(p.get("files", []))}절</span>')
            tag = "" if pm["sure"] else (
                ' <span class="chip">모듈 수준 추정</span>')
            pack_html = ('<div class="kv"><span>프롬프트 팩</span><span>'
                         + " ".join(items) + tag + "</span></div>")
        elif pack:
            pack_html = (
                f'<div class="kv"><span>프롬프트 팩</span><code>{e(sid)}</code> '
                f'v<code>{e(pack["latest"])}</code></div>')
        cs = CPSCHEMA.get(sid)
        cp_html = ""
        if cs and (cs["keys"] or cs["counts"]):
            keys = " ".join(f"<code>{e(k)}</code>" for k in cs["keys"])
            more = (f'<span class="muted">외 {cs["key_total"] - len(cs["keys"])}'
                    "</span>") if cs["key_total"] > len(cs["keys"]) else ""
            cnt = cs["counts"]
            cnt_s = (f'<span class="muted">적용 {cnt.get("applicable_count","-")}'
                     f' · 완료 {cnt.get("completed_count","-")}'
                     f' · 실패 {cnt.get("failed_count","-")}</span>'
                     ) if cnt else ""
            rm = (f'<span class="muted">해석 모델 '
                  f'<code>{e(cs["resolved_model"])}</code></span>'
                  ) if cs.get("resolved_model") else ""
            cp_html = (
                '<div class="kv cp"><span>CP 산출</span><span>'
                + (keys + " " + more if keys else
                   '<i class="muted">data 없음(자산만 남김)</i>')
                + "</span></div>"
                + (f'<div class="kv"><span></span>{cnt_s} {rm}</div>'
                   if (cnt_s or rm) else ""))
        out.append(f"""
<div class="card{'' if life == 'active' else ' dim'}">
  <div class="chd">
    <span class="ord">{e(s['order'])}</span>
    <span class="ttl">{e(s.get('label', sid))}</span>
    <code class="sid">{e(sid)}</code>
    <span class="m {badge}">{e(model)}</span>
  </div>
  <div class="chips">{''.join(chips)}</div>
  {'<p class="doc">' + e(doc_first) + '</p>' if doc_first else ''}
  {'<details><summary>구현 주석 전문</summary><pre>' + e(doc_rest)
   + '</pre></details>' if doc_rest else ''}
  <div class="kv"><span>구현</span><code>{e(f.get('file', '—'))}</code>
    {'<span class="muted">' + str(f.get('loc', 0)) + '줄</span>'
     if f.get('loc') else ''}</div>
  <div class="kv"><span>선행</span>{
      ' '.join('<code>' + e(d) + '</code>' for d in deps) or '<i>없음</i>'}</div>
  {pack_html}
  {cp_html}
  <div class="kv"><span>계약</span>
    <span class="muted">type={e(s.get('step_type'))} ·
    schema v{e(s.get('schema_version')
              or (cs or {}).get('schema_version') or '—')} ·
    provider={e(s.get('provider'))}</span></div>
</div>""")
    return "\n".join(out)


def overview_html():
    active = [s for s in STEPS if s.get("lifecycle") == "active"]
    dep = [s for s in STEPS if s.get("lifecycle") == "deprecated"]
    img = [s for s in active if s.get("category") == "image"]
    gem = [s for s in active if s.get("provider") == "gemini"]
    oai = [s for s in active if s.get("provider") == "openai"]
    fan = [s for s in active if s.get("fan_out")]
    flow = """flowchart TD
  A["1 텍스트 준비<br/>0 ~ 7"] --> B["2 Beat → Shot<br/>7.1 ~ 7.25 · 15.5"]
  B --> C["3 엔티티 추출<br/>6.5 · 8 ~ 15"]
  C --> D["4 연출 · 스테이징<br/>16 ~ 19.9"]
  D --> E["5 배경 계획 · 도면<br/>19.51 ~ 21.66"]
  E --> F["6 샷 상세 · T2I<br/>21.7 ~ 21.73"]
  F --> G["7 야외 구조물 계보<br/>21.76 ~ 21.92"]
  F --> H["8 이미지 생성<br/>22 ~ 25"]
  G --> H
  style A fill:#eef4ff,stroke:#3f6fd8
  style B fill:#eef4ff,stroke:#3f6fd8
  style C fill:#eef4ff,stroke:#3f6fd8
  style D fill:#fff3d6,stroke:#c8922a
  style E fill:#fff3d6,stroke:#c8922a
  style F fill:#fff3d6,stroke:#c8922a
  style G fill:#efe3ff,stroke:#7b4fbd
  style H fill:#e8f5e9,stroke:#2e7d32"""
    return f"""
<p class="lead">시나리오 텍스트 한 편이 들어와 <b>씬별 최종 이미지</b>가 나오기까지
파이프라인은 <b>{len(STEPS)}개 스텝</b>(활성 {len(active)} · 폐기 {len(dep)})으로
구성된다. 각 스텝은 체크포인트를 남기고, 다음 스텝은 그 체크포인트만 읽는다 —
그래서 중간부터 재개할 수 있고, 어디서 잘못됐는지 되짚을 수 있다.</p>

<div class="stats">
  <div><b>{len(STEPS)}</b><span>전체 스텝</span></div>
  <div><b>{len(active)}</b><span>활성</span></div>
  <div><b>{len(img)}</b><span>이미지 계열</span></div>
  <div><b>{len(fan)}</b><span>병렬 fan-out</span></div>
  <div><b>{len(oai)}</b><span>OpenAI</span></div>
  <div><b>{len(gem)}</b><span>Gemini</span></div>
  <div><b>{len(PACKS)}</b><span>프롬프트 팩</span></div>
</div>

<h3>구간 흐름</h3>
<pre class="mermaid">{flow}</pre>

<h3>모델 분업 — 왜 이렇게 나뉘어 있나</h3>
<p>모델은 취향이 아니라 <b>실측 비교</b>로 배정됐다(2026-07-11 4회차 전수 +
교차 검증).</p>
<table class="tbl">
<tr><th>계열</th><th>모델</th><th>담당</th><th>배정 근거</th></tr>
<tr><td>확정 · 감독</td><td><code>gemini-pro</code></td>
<td>beat/shot 추출, shot_validator, scene_director, scene_detail,
scene_consistency, 배경·소품 추출, entity_t2i, 아웃룩 3단계</td>
<td>감독·배정·실사용 연속성에서 우세. Sol 은 beat/shot 을 +160% 과세분했다.</td></tr>
<tr><td>분석 주력</td><td><code>gpt</code> (Sol)</td>
<td>분석 37스텝 + 인물 추출 3스텝</td>
<td>후보 회수율과 구체적 명명에서 우세 — 그래서 인물 추출만 되돌렸다.</td></tr>
<tr><td>보조</td><td><code>gemini-flash</code> / <code>gpt-mini</code></td>
<td>세그먼테이션, 요약, 필터, shot 선택, 번역</td>
<td>정확도 요구가 낮고 양이 많은 자리.</td></tr>
<tr><td>이미지</td><td><code>gemini-image</code> / <code>gpt-image-2</code> /
<code>grok-imagine-image-2.0</code></td>
<td>참조·합성·배경·콘티·씨드·최종 스틸 생성과 선택적 시네마틱 변환</td>
<td>최종 스틸은 <code>nb2</code> 기본, <code>grok2</code> 선택형이며 시네마틱
변환은 생성 엔진과 독립된 Grok i2i 단계다.</td></tr>
<tr><td>판정(VLM)</td><td>GPT + Gemini 합산</td>
<td>이미지 검증, 비교 선택, 앵글 추천, 실내/실외 판정</td>
<td>한쪽 심판이 평균을 지배하는 문제가 실측돼 <b>이중 판정</b>으로 갔다.</td></tr>
</table>

<h3>이 문서를 읽는 법</h3>
<ul class="read">
<li>탭 하나가 파이프라인의 한 구간이다. 각 탭은 <b>흐름도 + 스텝 카드</b>로
되어 있고, 카드의 사실(모델·선행·팩·파일·줄수)은 전부 코드에서 뽑았다.</li>
<li>흐름도에서 <span class="lg" style="background:#e8f5e9">초록</span> = Gemini,
<span class="lg" style="background:#d6ecff">파랑</span> = 자산 생성,
<span class="lg" style="background:#eeeeee">회색 점선</span> = 폐기,
<span class="lg" style="background:#fbfbfb">흐린 사각</span> = 이 구간 밖에서
들어오는 선행 스텝이다.</li>
<li>문서와 코드가 어긋나면 <b>코드가 맞다</b>. 이 문서는 매니페스트·클래스·
팩 디렉토리를 그대로 읽어 생성한다.</li>
</ul>
"""


def all_table_html():
    rows = []
    for s in STEPS:
        sid = s["step_id"]
        f = FILES.get(sid, {})
        life = s.get("lifecycle", "")
        rows.append(f"""<tr class="{'dimrow' if life != 'active' else ''}">
<td>{e(s['order'])}</td><td><code>{e(sid)}</code></td>
<td>{e(s.get('label'))}</td><td><code>{e(s.get('default_model'))}</code></td>
<td>{e(s.get('step_type'))}</td>
<td>{e(s.get('applicability'))}</td>
<td>{'✓' if s.get('fan_out') else ''}</td>
<td>{e(life)}</td>
<td><code class="sm">{e(f.get('file', '').replace('backend/app/core/steps/', ''))}</code></td>
</tr>""")
    return f"""
<p class="lead">매니페스트 전수 {len(STEPS)}행. <code>order</code> 순.</p>
<table class="tbl sticky">
<tr><th>order</th><th>step_id</th><th>이름</th><th>모델</th><th>type</th>
<th>적용 조건</th><th>병렬</th><th>lifecycle</th><th>구현 파일</th></tr>
{''.join(rows)}
</table>"""


def contract_html():
    """스텝 간 실제 인터페이스 — 체크포인트 manifest 계약."""
    ex = None
    for sid in ("outdoor_structure_form_reference", "shot_staging", "scene_save"):
        if CPSCHEMA.get(sid):
            ex = (sid, CPSCHEMA[sid])
            break
    ex_html = ""
    if ex:
        sid, cs = ex
        ex_html = (
            f'<p>예 — <code>{e(sid)}</code>: schema v{e(cs["schema_version"])}, '
            f'해석 모델 <code>{e(cs["resolved_model"])}</code>, '
            f'<code>data</code> 키 {cs["key_total"]}개'
            + (f' ({" ".join("<code>" + e(k) + "</code>" for k in cs["keys"][:6])} …)'
               if cs["keys"] else "") + "</p>")
    return f"""
<p class="lead">스텝은 서로를 직접 부르지 않는다. <b>앞 스텝이 체크포인트에
남긴 것만</b> 읽는다 — 그래서 중간부터 재개할 수 있고, 어느 스텝이 무엇을
망쳤는지 되짚을 수 있다. 이 탭은 그 계약을 설명한다.</p>

<h3>체크포인트 manifest 필드</h3>
<table class="tbl">
<tr><th>필드</th><th>뜻</th><th>왜 있나</th></tr>
<tr><td><code>step_id</code> · <code>run_id</code></td><td>스텝 식별자와 실행 회차</td>
<td>같은 스텝을 다시 돌렸을 때 어느 회차 산출인지 구분한다.</td></tr>
<tr><td><code>status</code></td><td><code>completed</code> / <code>failed</code> / <code>running</code></td>
<td><code>running</code> 인 채로 남으면 <b>스테일 락</b>이 된다. 아래 참조.</td></tr>
<tr><td><code>data</code></td><td>이 스텝의 산출 본문</td>
<td><b>다음 스텝이 읽는 유일한 창구.</b> 각 스텝 카드의 "CP 산출"이 실측 키다.</td></tr>
<tr><td><code>schema_version</code></td><td>data 구조의 버전</td>
<td>구조가 바뀌면 올린다. 소비자가 구 CP 를 그대로 먹지 않게 막는다.</td></tr>
<tr><td><code>config_hash</code></td><td>실질 입력의 지문</td>
<td>모델·팩·플래그가 바뀌면 값이 달라져 재실행 대상이 된다.
<b>유효성 판정 장치가 아니다</b> — hash 가 달라도 실행은 되므로, 불가능한
조합은 별도 정책 SOT 가 실행 전에 거부한다.</td></tr>
<tr><td><code>resolved_model</code></td><td>alias 뒤의 실제 모델</td>
<td><code>gpt</code> 같은 alias 는 설정으로 바뀐다. 무엇이 실제로 돌았는지 남긴다.</td></tr>
<tr><td><code>applicable_count</code> · <code>completed_count</code> · <code>failed_count</code></td>
<td>대상 수 / 성공 / 실패</td>
<td>부분 성공을 그대로 기록한다 — 실패를 0으로 덮지 않는다.</td></tr>
<tr><td><code>project_config_snapshot</code></td><td>실행 시점 설정 사본</td>
<td>나중에 설정이 바뀌어도 그때 무엇으로 돌았는지 재구성할 수 있다.</td></tr>
</table>
{ex_html}

<h3>재개 · 락 · 재시도</h3>
<div class="note"><b>재개는 CP 단위다.</b> 완료된 스텝은 건너뛰고 실패·미실행
지점부터 이어 간다. 그래서 긴 실행이 중간에 죽어도 처음부터 다시 돌리지
않는다.</div>
<div class="note warn"><b>스테일 락은 <code>force</code> 로 뺏을 수 없다.</b>
만료 판정은 <code>started_at</code> 기준 3600초이고, 회수는 <code>resume</code>
경로에서만 일어난다. 게다가 <b>재시도가 <code>started_at</code> 을 갱신</b>해
만료가 계속 미뤄진다(3577초 → 425초로 리셋된 실측이 있다). 대응은 두드리는
것이 아니라 3600초를 넘긴 뒤 <code>resume</code> 을 한 번 부르는 것이다.</div>
<div class="note"><b>부분 실패는 그룹 단위로 격리한다.</b> 한 그룹이 실패해도
나머지는 진행하고, 실패는 <code>failed_count</code> 와 그룹 status 에 남는다.
다만 <b>fail-closed</b> 로 잠근 자리(형태 참조 결손 등)는 조용히 degrade 하지
않고 그 그룹을 세운다 — 조용한 하강이 사용자 지시를 아무도 모르게 어기기 때문이다.</div>

<h3>병렬과 모델 alias</h3>
<ul class="read">
<li><b>fan-out</b> 표시가 있는 스텝은 씬·샷·그룹 단위로 병렬 실행된다.
카드의 칩으로 표시했다.</li>
<li>모델은 <code>gpt</code> · <code>gemini-pro</code> 같은 <b>alias</b> 로 적히고
설정이 물리 모델로 푼다. alias 뒤가 바뀌면 산출이 달라지므로 일부 스텝은
물리 모델까지 <code>config_hash</code> 에 싣는다.</li>
<li>이미지·VLM 판정은 <b>이중 판정</b>(GPT + Gemini)을 쓰는 자리가 있다.
한쪽 심판이 평균을 지배하던 결함이 실측돼 척도를 맞췄다.</li>
</ul>

<h3>이 문서의 사실 출처</h3>
<p class="muted">스텝 메타 = <code>app/core/step_manifest.py</code> ·
구현/설명 = 스텝 클래스 docstring · 팩 = <code>prompts/_base/</code> 디렉터리 ·
<b>CP 산출 키 = 실제 실행 체크포인트 {CP_TOTAL}개에서 실측</b>(작품 식별자는
싣지 않는다). 빌더 = <code>docs/architecture/_build_pipeline_doc.py</code>.</p>
"""


def legacy_html():
    ids = [s["step_id"] for s in STEPS if s.get("lifecycle") != "active"]
    rows = []
    for sid in ids:
        s = BY_ID[sid]
        rows.append(
            f"<tr><td>{e(s['order'])}</td><td><code>{e(sid)}</code></td>"
            f"<td>{e(s.get('label'))}</td><td>{e(s.get('lifecycle'))}</td>"
            f"<td><code>{e(s.get('replaced_by') or '—')}</code></td></tr>")
    return f"""<table class="tbl">
<tr><th>order</th><th>step_id</th><th>이름</th><th>상태</th><th>대체</th></tr>
{''.join(rows)}</table>"""


def build():
    tabs_nav, panes = [], []
    for i, (key, title, ids, intro) in enumerate(TABS):
        tabs_nav.append(
            f'<button class="tab{" on" if i == 0 else ""}" '
            f'data-t="{key}">{e(title)}</button>')
        if key == "overview":
            body = overview_html()
        elif key == "contract":
            body = contract_html()
        elif key == "all":
            body = all_table_html()
        elif key == "legacy":
            dead = [s["step_id"] for s in STEPS if s.get("lifecycle") != "active"]
            body = (intro + legacy_html()
                    + f"<h3>스텝 {len(dead)}개 — 상세</h3>" + cards_html(dead))
        else:
            head = OUTDOOR_INTRO if intro == "__OUTDOOR__" else intro
            body = (head
                    + '<h3>구간 흐름 — 스텝 순서와 의존</h3>'
                    + '<pre class="mermaid">'
                    + render_flow(ids, key) + "</pre>"
                    + extras_html(key)
                    + f"<h3>스텝 {len(step_rows(ids))}개 — 상세</h3>"
                    + cards_html(ids))
        panes.append(
            f'<section class="pane{" on" if i == 0 else ""}" '
            f'id="p-{key}">{body}</section>')

    return f"""<!doctype html>
<html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>전체 파이프라인 — 개발자 상세도</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
:root{{color-scheme:light}}
*{{box-sizing:border-box}}
body{{margin:0;background:#ffffff;color:#14181f;
 font:15px/1.7 -apple-system,"Apple SD Gothic Neo","Noto Sans KR",sans-serif}}
header{{padding:22px 28px 0;max-width:1500px;margin:0 auto}}
h1{{margin:0 0 6px;font-size:26px;letter-spacing:-.4px;color:#0d1117}}
.sub{{color:#4a5260;margin:0 0 14px;font-size:14px}}
nav{{position:sticky;top:0;z-index:20;background:#ffffff;
 border-bottom:2px solid #e3e7ee;padding:0 28px;margin-bottom:8px}}
nav .in{{max-width:1500px;margin:0 auto;display:flex;flex-wrap:wrap;gap:2px}}
.tab{{appearance:none;border:0;background:transparent;color:#4a5260;
 padding:11px 14px;font:600 13.5px/1 inherit;cursor:pointer;
 border-bottom:3px solid transparent;white-space:nowrap}}
.tab:hover{{color:#14181f;background:#f4f6fa}}
.tab.on{{color:#1a56c4;border-bottom-color:#1a56c4}}
main{{max-width:1500px;margin:0 auto;padding:8px 28px 80px}}
.pane{{display:none}} .pane.on{{display:block}}
h3{{margin:30px 0 10px;font-size:17px;color:#0d1117;
 border-left:4px solid #1a56c4;padding-left:9px}}
p,li{{color:#232a35}}
.lead{{font-size:15.5px;color:#232a35;margin:10px 0 18px}}
code{{background:#eef1f6;color:#123;padding:1px 5px;border-radius:4px;
 font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}}
code.sm{{font-size:11.5px;background:transparent;padding:0;color:#4a5260}}
.note{{background:#f3f7ff;border-left:4px solid #1a56c4;padding:11px 14px;
 margin:12px 0;border-radius:0 6px 6px 0;font-size:14px;color:#1c2431}}
.note.warn{{background:#fff7ed;border-left-color:#c8791a}}
.note b{{color:#0d1117}}
/* ── 다이어그램: 어떤 상속에서도 검은 글자를 유지한다 ── */
.mermaid{{background:#f7f9fc;border:1px solid #e3e7ee;border-radius:8px;
 padding:18px;margin:14px 0;overflow-x:auto;color:#111 !important}}
.mermaid svg{{max-width:100%;height:auto}}
.mermaid svg text,.mermaid svg tspan{{fill:#111 !important}}
.mermaid .nodeLabel,.mermaid .edgeLabel,.mermaid .label,
.mermaid foreignObject div,.mermaid foreignObject span,
.mermaid p{{color:#111 !important;fill:#111 !important}}
.mermaid .edgeLabel{{background:#f7f9fc !important}}
.mermaid .cluster text{{fill:#111 !important}}
.stats{{display:flex;flex-wrap:wrap;gap:10px;margin:16px 0 6px}}
.stats div{{background:#f4f6fa;border:1px solid #e3e7ee;border-radius:8px;
 padding:10px 16px;min-width:104px}}
.stats b{{display:block;font-size:22px;color:#1a56c4;line-height:1.2}}
.stats span{{font-size:12px;color:#4a5260}}
.tbl{{width:100%;border-collapse:collapse;margin:12px 0;font-size:13.5px}}
.tbl th{{background:#eef1f6;text-align:left;padding:8px 10px;
 border-bottom:2px solid #d5dbe5;color:#0d1117;font-weight:700}}
.tbl td{{padding:7px 10px;border-bottom:1px solid #eceff4;
 vertical-align:top;color:#232a35}}
.tbl.sticky th{{position:sticky;top:0;z-index:5;
 box-shadow:0 1px 0 #d5dbe5}}
.dimrow td{{color:#8b93a1;background:#fafbfc}}
.card{{border:1px solid #e3e7ee;border-radius:9px;padding:14px 16px;
 margin:11px 0;background:#fff}}
.card.dim{{background:#fafbfc;border-style:dashed}}
.chd{{display:flex;align-items:center;gap:9px;flex-wrap:wrap}}
.ord{{background:#1a56c4;color:#fff;border-radius:5px;padding:2px 8px;
 font:700 12px/1.6 ui-monospace,Menlo,monospace}}
.ttl{{font-weight:700;font-size:15.5px;color:#0d1117}}
.sid{{font-size:12.5px}}
.m{{margin-left:auto;font:700 11px/1 inherit;padding:4px 8px;border-radius:20px}}
.m.gem{{background:#e8f5e9;color:#1b5e20}} .m.gpt{{background:#e8eefc;color:#123f8f}}
.m.img{{background:#fdeaf3;color:#8e1457}} .m.mix{{background:#fff3d6;color:#7a5410}}
.m.code{{background:#eceff4;color:#3a4250}}
.chips{{margin:8px 0 4px;display:flex;gap:6px;flex-wrap:wrap}}
.chip{{background:#eef1f6;color:#3a4250;border-radius:20px;
 padding:3px 10px;font-size:11.5px}}
.chip.bad{{background:#fde8e8;color:#9b1c1c}}
.doc{{margin:8px 0;color:#232a35}}
.kv{{display:flex;gap:8px;font-size:13px;margin-top:5px;flex-wrap:wrap;
 align-items:baseline}}
.kv>span:first-child{{color:#6b7280;min-width:66px;font-size:12px}}
.muted{{color:#6b7280;font-size:12px}}
details{{margin:8px 0}} summary{{cursor:pointer;color:#1a56c4;font-size:13px}}
details pre{{background:#f7f9fc;border:1px solid #e3e7ee;border-radius:6px;
 padding:11px;overflow-x:auto;font-size:12.5px;color:#232a35;
 white-space:pre-wrap;margin:7px 0 0}}
.read li{{margin:5px 0}}
.lg{{display:inline-block;padding:0 8px;border-radius:4px;
 border:1px solid #ccd3de}}
a{{color:#1a56c4}}
</style></head><body>
<header>
<h1>전체 파이프라인 — 개발자 상세도</h1>
<p class="sub">{GENERATED_AT} 생성 · 매니페스트 {len(STEPS)}스텝 · 스텝 클래스
{len(FILES)}개 · 프롬프트 팩 {len(PACKS)}개를 코드에서 직접 읽어 생성했다.
문서와 코드가 어긋나면 코드가 맞다.</p>
</header>
<nav><div class="in">{''.join(tabs_nav)}</div></nav>
<main>{''.join(panes)}</main>
<script>
mermaid.initialize({{startOnLoad:false, theme:'neutral',
  flowchart:{{curve:'basis', useMaxWidth:true, htmlLabels:true}},
  themeVariables:{{
    fontFamily:'-apple-system,"Apple SD Gothic Neo","Noto Sans KR",sans-serif',
    fontSize:'13px', textColor:'#111', primaryTextColor:'#111',
    secondaryTextColor:'#111', tertiaryTextColor:'#111',
    lineColor:'#5b6472', primaryColor:'#eef4ff', primaryBorderColor:'#3f6fd8',
    nodeTextColor:'#111', mainBkg:'#ffffff', clusterBkg:'#fbfcfe'}}}});
async function draw(key){{
  const pane = document.getElementById('p-' + key);
  if(!pane) return;
  const nodes = [...pane.querySelectorAll('.mermaid:not([data-processed])')];
  if(nodes.length){{ try{{ await mermaid.run({{nodes}}); }}catch(e){{}} }}
}}
function activate(key, setHash){{
  const tab = document.querySelector('.tab[data-t="' + key + '"]');
  const pane = document.getElementById('p-' + key);
  if(!tab || !pane) return false;
  document.querySelectorAll('.tab').forEach(x => x.classList.remove('on'));
  document.querySelectorAll('.pane').forEach(x => x.classList.remove('on'));
  tab.classList.add('on');
  pane.classList.add('on');
  if(setHash) location.hash = key;
  draw(key);
  return true;
}}
document.querySelectorAll('.tab').forEach(b => b.addEventListener('click', () => {{
  activate(b.dataset.t, true);
  window.scrollTo(0, 0);
}}));
window.addEventListener('hashchange', () => {{
  const k = (location.hash || '').replace('#','');
  if(k) activate(k, false);
}});
if(!activate((location.hash || '').replace('#',''), false)){{
  activate('{TABS[0][0]}', false);
}}
</script></body></html>"""


if __name__ == "__main__":
    OUT.write_text(build(), encoding="utf-8")
    covered = set()
    for _, _, ids, _ in TABS:
        covered |= set(ids)
    missing = [s["step_id"] for s in STEPS
               if s["step_id"] not in covered and s.get("lifecycle") == "active"]
    print(f"작성: {OUT} ({OUT.stat().st_size:,} bytes)")
    print(f"탭 {len(TABS)}개 · 활성 스텝 커버리지 누락: {missing or '없음'}")
