"""seed 품질 2R — LLM 프롬프트 변형 저작 결정론 계약 (2026-07-16 사용자 지시).

seed 생성을 "동일 프롬프트 N롤"에서 "LLM(Sol)이 N종 프롬프트 변형 저작
(v1=시나리오 원어 근거, 나머지=상이 접근) → 각 1롤 → VLM 블라인드 판정
선정"으로 교체하기 위한 순수 결정론 반쪽 — 저작 스키마/입력 조립/출력
검증/롤 프롬프트 조립/공유 conformance 브리프. LLM IO 는
outdoor_structure_seed_step 이 담당한다.

진단 4건 매핑(2026-07-16 밤 실측):
  ① 규모 서술 전무 → author_sys MASS AND SCALE 절(전형 위임 저작 —
    수치는 코드/팩 어디에도 없음)
  ② 개구부 정합 판정 축 부재 → conformance_brief_en + judge/critique
    addendum (brief 위반=미학보다 선행)
  ③ THE LOCATION=씬 헤딩 원문 → location_line_en 저작(조립은 저작본만
    사용, raw 헤딩 미주입)
  ④ 원어 질감 소실 → v1 원어 인용(evidence_ko)→시각 사실 변환 계약

안전 불변절(seed_head/EXCLUDED/no_people)은 코드 조립 — LLM 저작이
계약을 떨어뜨릴 수 없다.
"""
from __future__ import annotations

from typing import Any, Dict, List, Mapping, Optional, Sequence

VARIANTS_PACK_MODULE = "seed_prompt_variants"
AUTHOR_MODEL = "gpt"  # 저작=분석 주력 Sol (모델 분업 계약)
# 사용자 확정 계약(2026-07-16): 변형=정확히 3종(v1=원어, v2·v3=상이 접근).
# still_recipe_roll_count 와 독립 — 스틸 롤 수 조정이 seed 저작 수를
# 조용히 바꾸는 결합 차단 (Codex 재리뷰 HIGH-2).
SEED_VARIANT_COUNT = 3

VARIANTS_PACK_VERSION_MAP = {
    "1": "1.202607161810",
    # v2 (Codex 재리뷰 BLOCKING-1): addendum 병합 폐기 — judge_still/
    # critique 는 film-still 계약(동일 프롬프트 전제+샷 프레이밍·인물
    # identity/wardrobe/pose 축)이라 변형 seed 판정과 전제 충돌. seed
    # 전용 judge_sys/critique_sys 전면 교체(conformance·물리 일관성·
    # 영구/일시 요소만).
    "2": "2.202607161840",
    # v3 (2026-07-16 밤 사용자 육안 반려 — 로케이션 헌팅 계약): v2 산출
    # =고립 건물+감옥 같은 무생활감+소설식 서술. 원인=seed_head v4 의
    # '주변 창작 금지'+transient 전면 배제+위상 서술 문체 흡수. 교체:
    # ①scout_head/plate_rules/excluded_head 를 이 팩이 소유(structure_
    # seed 팩 의존 제거) — 건물=주 피사체+실존 거주 동네 맥락(전형 위임),
    # 생활 흔적=이미지 위임, 배제=씬 순간 연출만 ②author=간결 스카우트
    # 노트 문체+층수·색·자재 확정 저작(전형 위임 — 코드 하드코딩 0)
    # ③judge/critique 에 '실존 거주 동네' 축(고립·유기·시설감=위반).
    "3": "3.202607161948",
    # v4 (2026-07-16 사용자 육안: "한국에 저런 색 물탱크·건물 없어"):
    # 지역 사실성 계약 — 서술하는 모든 요소(벽·지붕·문·계단·물탱크·담·
    # 포장 등)의 색·재질=해당 지역 실물 전형(시나리오 세계관 근거,
    # 특정 색 하드코딩 0 — LLM 도출). author REGIONAL AUTHENTICITY 절+
    # judge 3b 축+critique 결함 항목(수정=지역 표준 마감 명명).
    "4": "4.202607162015",
    # v5 (Codex v4 리뷰 HIGH-1): v4 'today' 하드코딩이 세계관 시대를 무시
    # (시대극에서 현대 마감 승인 위험) → 색·재질 전형 기준=WORLD ANCHOR·
    # 시나리오가 확정한 지역+시대(anachronistic 금지), location_line 에
    # 시대 앵커 병기(인물 문구 금지), judge 3b/critique 도 region+period.
    "5": "5.202607162021",
    # v6 (2026-07-22 E2E11 육안 ① 옥탑 규모 과소): 규모 계약이 variants
    # 경로에서 전면 소실되던 결손 수정 — E2E11 실측 롤 프롬프트 30/30 에
    # SCALE 절 0(interior 증거는 저작 입력까지만 도달, 출력 계약 미강제 +
    # 조립 미부착 + structure_seed v5 human_scale 은 variants OFF 경로
    # 전용 死코드). v5 전체 사본 + human_scale_clause/scale_clause
    # (structure_seed v5 원문 사본 — 계약 드리프트 방지)를 이 팩이 소유,
    # 조립은 코드 불변 안전절(아래 _SCALE_VARIANT_PACKS).
    "6": "6.202607220235",
    # v7 (2026-07-22 E2E13 fix②): v6 전체 사본 + author_sys 개정 3절 —
    # ①SIZE IS EVIDENCE-DERIVED(크기 형용사는 시나리오 명시 시에만,
    # INTERIOR NOTES 수용력에서 크기를 사실로 도출 + brief 에 판정 가능
    # 사실로 명기 — 옥탑 빌라 'small/compact' 저작이 롤 SCALE SPEC 과
    # 정면 충돌하던 실측 근본 대응) ②THE MAIN MASS SUPPORTS ITS PARTS
    # (본체 규모가 부속 구조의 존재·크기를 물리적으로 지지) ③REGIONAL
    # FRONTAGE TYPOLOGY(상업·공공 시설=지역 전형 간판·유리 전면·진열 —
    # 색·재질에 한정돼 있던 지역 전형 계약의 유형 사각 제거, 마트
    # '무간판 솔리드 전면' 저작 실측 근본 대응). 지역 문구 하드코딩 0.
    "7": "7.202607222356",
    # v8 (2026-07-23 캔ary 육안 2건): v7 전체 사본 + ①ROOM INVENTORY —
    # 시나리오 원문이 증거하는 실내 방 전수 열거 후 그 수용 풋프린트
    # 커밋(원문>요약 노트, 별도 진입 방=별도 방, '좁다'=밀집감이지 축소
    # 면허 아님, 거주 방 수와 정합한 외부 창 개수 명시 — entity 요약이
    # 안방+자기방을 '방' 단수로 병합해 과소 저작되던 실측) ②간판=존재+
    # 판독 가능한 평범한 현지어 상호를 사실로 커밋(시나리오 고유명사
    # 금지 — v7 'no readable text' 과잉 제약이 critique 로 간판 문안을
    # 지우던 실측), judge/critique 의 leaked text 를 overlay 한정으로
    # 구분(브리프 커밋 간판=필수).
    # ※ 지시문 전용 판(4e565239 상태) — 구조화 필드 계약은 v9 소유.
    #   생성 후 in-place 수정분(덮어쓰기 금지 위반, Codex v8b BLOCKING-1)
    #   은 git 복원으로 철회하고 v9 로 재발행했다.
    "8": "8.202607230809",
    # v9 (2026-07-23 Codex v8b 재리뷰): v8 in-place 수정분의 정식 재발행
    # (버전 디렉터리 불변 계약 복구 — resolved 버전명 drift 로 config
    # hash·author_fp 자연 무효화) + evidence-bound 계약 확장 —
    # ①room_inventory 항목에 scene_index(증거 씬)+verbatim 인용,
    # applicability 구조화(room_inventory_applicable — 적용 대상인데 빈
    # 배열인 저작을 validator 가 fail-closed, 코드 keyword 분류 0)
    # ②표시 필요 여부 구조화 판정+근거 — 상업
    # 한정 'business name' 문구를 시설 유형 적합 현지어 간판 문안으로
    # 일반화, 판정↔문안 정합을 validator 잠금 ③그 문안
    # =한 줄·제어문자/쌍따옴표 금지(raw replace 조립 안전) ④INTERIOR
    # SCENES 입력 블록(실내 씬 원문 — 방 전수·규모 증거 전용).
    "9": "9.202607230848",
    # v10 (2026-07-23 슬라이스 E 육안 "사람 사는 곳 같지 않아" — 주거
    # 생활감 sterile 근본 대응, records 실측: 롤 B/C 는 생활감 보유,
    # judge 가 구조 축만으로 sterile A 를 선정 + critique 'total
    # absence' 문턱 미발동 + fix 전면 보존으로 sterility 가 look
    # authority 로 잠김): v9 전체 사본 + ①judge_sys axis 2 — 타깃
    # 자체가 현재 사용 중으로 읽혀야 함(이웃만 생활감 & 타깃 bare =
    # axis-2 위반, verdict 에 명시 요구) ②critique_sys — 문턱을
    # '타깃 또는 그 표면·출입부·옥외 영역이 bare/미사용'으로 강화 +
    # 수정=지역·시대 전형의 unstaged 일상 흔적 추가(선택은 이미지
    # 위임) ③plate_rules — 생활 흔적 '환영(방임)'을 사실 서술로 전환
    # (타깃 자체가 사용 흔적을 지님, 이웃보다 빈 타깃=오답). 객체
    # 열거 0 유지(유형 중립 — v8 리뷰 합의 승계), author_sys 등
    # 나머지 파일 byte-identical. Codex 리뷰 2사이클 반영 재발행(버전
    # 디렉터리 불변 계약 — 미소비 동일 사이클 신규 타임스탬프, Codex
    # 수용 확인. 소비 후 변경은 v11 신규 디렉터리):
    # plate_rules=브리프 active-use 커밋 조건부+'committed use 와
    # 불일치하는 bare/emptied' 한정(신축·연식은 커밋 사실로 보존)+
    # 브리프 상태·연식 우선 / judge=axis 2 원복+axis 3 lexicographic
    # (구조 위반 수·심각도 최소화 우선 → 구조 동급 후보 사이에서만
    # active-use tie-break — sterile 은 구조 tie 를 못 이기고, 생활감은
    # 구조 위반을 면책 못함).
    "10": "10.202607231950",
    # v11 (2026-07-30 사용자 육안 반려 "저런 주유소가 어디있어"): 씨드가
    # 주유기 한 대만 덜렁 선 형태로 나왔다. 원인은 검색·참조가 아니라 저작
    # 계약이었다 — ①`STRUCTURE SPEC` 을 "Sole authority" 로 준 탓에 저작이
    # spec 에 없는 것을 **부재로 확정**했고(실측 brief: "no enclosed
    # building, canopy, doors, or windows") ②spec item 1개를 개수로 읽어
    # "exactly one dispenser" 로 못박았으며 ③그 부정문이 judge/critique
    # 기준이 되어 **캐노피 있는 후보가 오히려 위반**이 됐다. 게다가 judge·
    # critique 의 "permanent elements added" 조항이 두 번째 잠금이었다.
    # 실측 전수: 25그룹 중 부정 단정 13 · 개수 단정 9 · zero-storey 8.
    # → ①spec 권위를 "적힌 것"에 한정하고 부재 추론 금지 ②부재 선언 문장
    # 금지(시나리오가 명시한 경우만) ③개수는 시나리오가 말할 때만 ④judge·
    # critique 에서 "그 시설 유형의 표준 구성요소는 추가가 아니다, 오히려
    # 없는 것이 위반" 을 명문화.
    "11": "11.202607302040",
    # v12 (2026-07-31 사용자 육안 반려 "집 규모도 너무 작고, 옥상으로
    # 올라가는 계단이 아니야"): bg_multifamily_residence 실측 추적 결과
    # 저작은 3층 10×12m 를 요구했고 T2I 가 2층으로 그렸다. 저작 계약의
    # 남은 구멍은 둘이었다.
    # ①**다세대인데 한 세대 크기로 읽힌다** — INTERIOR NOTES 는 한 세대
    #   증거인데 "그 방들을 담을 만큼"만 요구해, 여러 세대를 담는 유형의
    #   건물이 단독주택 규모로 확정될 수 있었다. → 유형이 다세대면 전 세대
    #   분을 담도록 명시하고 브리프에 검사 가능한 사실로 싣는다.
    # ②**수직 동선의 도착점이 자기참조로 비었다** — spec 이 "rises toward
    #   its access point" 로 뭉갠 것을 저작이 그대로 승계해("reaches the
    #   alley-side access point") 이미지 모델이 층을 임의로 정했다. 실제로
    #   시나리오는 답을 갖고 있다(S12 계단을 올라간다 → S13 문을 열어제친다
    #   → 집안 = 한 동선). → 씬 연결을 읽어 도착점을 지목하게 하고
    #   "access point/entry landing" 류 자기참조 표현을 금지.
    # scale_clause 도 개정: 실내 증거는 **한 세대**의 것이며, 사건이 남긴
    # 파손·혈흔·어지러움은 크기 판단에서 제외한다(장소 정체성이 사건 직후
    # 상태로 굳는 실측 — L10 "난장판이 된 단촐한 투룸").
    "12": "12.202607310315",
    # v13 (2026-07-31 실측 — 판정이 병목이었다): 같은 브리프 3롤 중 **두
    # 장이 요구대로 4층**이었는데 judge 가 3층짜리에 10점을 주고 그것을
    # 골랐다(A=10 / B=6 / C=4, 실측 bg_multifamily_residence). 같은 산출을
    # critique 는 "3층이라 4층 지침 위배"로 정확히 셌다 — 세는 능력은 있는데
    # **고르는 단계에서 그 축이 힘을 못 썼다**.
    # 원인 둘: ①층수를 "세라"는 절차가 없어 인상으로 판단했고 ②상위 축인
    # 2번(현실감·생활감)이 정도 비교로 쓰여, 밤에 불 켜지고 오토바이·쓰레기통
    # 이 놓인 후보가 대낮의 깔끔한 후보를 이겨 3번(층수)까지 가지도 않았다.
    # → ①2번을 PASS/FAIL 로 못박고 "생동감으로 순위 매기지 말라" 명문화
    #   ②3번 앞머리에 **계수 절차**를 넣어 반지하·필로티·기단층까지 세어
    #   verdict 에 "본 층수 vs 브리프 층수"를 적게 하고, **층수가 맞는 후보가
    #   틀린 후보를 무조건 이긴다**를 명시.
    "13": "13.202607310424",
    # v14 (2026-08-02 사용자 지시): 표시물 통칭과 업종 목록을 프롬프트
    # 에서 뺀다. "간판이 필요한 업종인가"를 판단시키던 절을 없애고,
    # 활자가 있다면 어디서든 같은 내용이라는 한 줄로 줄였다. 저작 출력
    # 도 필요여부/근거/문안 세 칸에서 lettering_text 한 칸으로
    # (빈 문자열 = 활자 없음). 절 파일도 lettering_clause 로 개명.
    "14": "14.202608022336",
    # v15 (2026-08-03 사용자 지시): 규모를 치수가 아니라 **배수**로 말하게
    # 한다 — "이미지 모델이 수치를 반영하지 않는다"는 실측(저작이 확정형
    # 으로 쓴 치수가 그림을 바꾸지 않았고, 조사해도 정확히 알 수 없다).
    # 사람 키·문 폭·층·방 개수처럼 같은 화면에 함께 서는 것과 비교한다.
    # 함께: 주변 서술의 "한두 문장" 제한 해제(구조물은 주변과의 관계로
    # 읽힌다) · 활자가 놓이는 면을 문안 한 줄이 아니라 **만들어진 방식**
    # 까지 서술(위치·크기·재질·조명·병기·색 분할).
    "15": "15.202608030032",
    # v16 (2026-08-03 사용자 육안 반려 4건): 조사가 사실을 가져왔는데 저작이
    # 쓰지 않았다. ①활자·표식은 **사람이 만든 것에만** — 자연 지형에는 아무것도
    # 붙이지 않는다 ②표식은 보통 **여러 곳에 한 체계**로 있다(단수 서술 폐기)
    # ③보여 주는 것이 이름만이 아니다 — 그 장소가 실제로 내거는 실용 정보까지,
    # 더 큰 조직에 속하면 그 조직의 배열·색 분할·표식 관례대로(이름만 작품 것)
    # ④"signs of active use" 의 중의성 제거(traces) — 자연물에 팻말이 생기던
    # 두 번째 경로.
    "16": "16.202608030345",
    # v17 (2026-08-03 사용자 개념 정정): 범주가 좁았다. 사용자 지적 —
    # "인간이 만든 모든 표현물에 가깝지 않을까 … 광고판, 디자인 인쇄물,
    # 낙서, 그래피티 등 모든 것". 활자가 놓이는 면만 보던 것을 **사람이
    # 표면에 얹은 시각물 일체**로 넓힌다(글자·숫자·표장·도상·인쇄물·
    # 붙인 것·칠한 것·뿌린 것·색면·판·상자·허가 없이 남긴 것). 자연물에는
    # 그 가족 전체가 없다는 규칙도 같이 넓어진다.
    "17": "17.202608030403",
    # v18 (2026-08-03 실측): 저작이 **근거 없는 형태를 지어내면 참조
    # 사진이 그것을 이기지 못한다.** 편의점 앞 앉을 자리에 저작이
    # 색·재질·다리까지 확정해 두자, 그 물건이 찍힌 참조를 3장 붙여도
    # 산출은 저작이 쓴 대로 나왔다. 같은 조건에서 그 형태 서술 한 문장만
    # 빼자 참조 속 실물이 그대로 들어왔다. → 명세가 든 **별개의 물건**은
    # 있다는 것과 어디 있는지까지만 쓰고, 형태·구성·색·재질은 시나리오·
    # 명세·사전조사가 정한 것이 아니면 쓰지 않는다. (구조물 자신의 살
    # ―벽·지붕·개구부―에 대한 기존 확정 규칙은 그대로다.)
    "18": "18.202608031301",
    # v19 (2026-08-03 사용자 육안 지적): 가장 큰 표시면에 **그 시설의
    # 종류를 가리키는 낱말**이 걸렸다(저작 산출 실측 3건). 지침에 금지
    # 둘(작품 고유명사·실재 등록명)만 있고 "그런 장소가 실제로 내거는
    # 것"이라고만 적혀 있으니, 모델에게 남는 안전한 선택지가 종류 낱말
    # 하나뿐이었다. 그 자리의 몫이 **이 장소를 같은 종류의 다른 곳과
    # 구별하는 것**임을 명시하고, 이름은 시나리오가 주는 것에서 그 지역
    # 관례대로 조립하게 한다(조립 방식은 조사와 참조 사진에서 온다).
    # ★함께: 종류 낱말이 옳은 자리도 못 박는다 — 가까이서 읽는 작은
    #   실용 표시. 규칙의 축은 낱말이 아니라 **자리와 크기**다.
    "19": "19.202608031545",
    # v20 (2026-08-03 사용자 육안 반려 "네이밍이 최악이야"): v19 가 종류
    # 낱말은 몰아냈으나 그 자리를 **장면이 채웠다** — 밤에 벌어진 일에서
    # 때(밤)와 그 장면의 행위(쉼)를 따와 이름을 지었고, 읽는 사람에게
    # 그 장소가 아니라 **그 장면**이 먼저 왔다. v19 의 "어디 서 있고 누구를
    # 상대하는지에서 조립하라"가 그 문을 열어 뒀다.
    # → 이름은 **가리키는 것이지 묘사하는 것이 아니다**. 이야기를 지워도
    #   남는 것으로만 짓는다. 때·사건·정서·의미에서 짓지 않는다.
    #   판별식도 준다 — 한낮에 지나가는, 이 이야기를 모르는 사람에게 그
    #   거리의 다른 이름들만큼 심심해야 한다. 뜻이 있어 보이면 틀렸다.
    "20": "20.202608031740",
    # v21 (2026-08-03 사용자 지적 "네이밍은 알아서 하라고 하면 할 텐데?"):
    # v19~v20 이 **금지를 쌓은 것**이 문제였다. v19 의 "네가 지어낸 방식
    # 으로는 짓지 마라"가 작명 자체를 막아 놓고 실재 상호도 금지하니,
    # 모델에게 남은 재료가 장면뿐이라 때+행위로 이름을 지었다(밤길쉼터).
    # v20 은 거기에 금지를 더 얹어 같은 함정을 키웠다.
    # → 긴 금지 두 조항을 **짧은 지시 하나**로 줄인다: 그 지역에서 그런
    #   곳이 실제로 불리는 대로 짓되, 시나리오가 줄 게 없으면 **지어내라**
    #   (지명 비슷한 말을 만들어도 된다). 남긴 금지는 한 마디뿐이다 —
    #   때·사건에서 짓지 않는다. 간결성이 정확성보다 앞선다는 그 규칙.
    "21": "21.202608031955",
}

# 규모 계약 스템(human_scale_clause + scale_clause)이 실리는 팩 —
# assemble_variant_roll_prompts / build_conformance_prompt 조립 분기.
# v5 이하 = 기존 조립 byte-identical.
_SCALE_VARIANT_PACKS = frozenset(
    {"6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17",
     "18", "19", "20", "21"})

# 구조화 room_inventory/활자 문안 계약이 실리는 팩 — 저작 스키마·
# validator·조립(집계 절) 게이트 (Codex v8 리뷰 BLOCKING-1·HIGH-3:
# 지시문만으로는 방 병합·간판 회피가 비결정적으로 재발 — 구조화 출력
# fail-closed 로 잠근다). v8 은 복원으로 지시문 전용이 되어 제외 —
# 구조화 스키마·집계 스템(room_capacity/활자 절)은 v9 부터.
# ★새 팩을 발행하면 이 두 집합에도 반드시 넣어야 한다. 빠뜨리면 스키마·
# 조립이 조용히 구 계약으로 내려가 **간판 구조화와 규모 계약이 통째로
# 사라진다**(2026-07-30 v11 실측: 저작 산출 키가 8개→3개, 브리프에서
# HUMAN-SCALE CALIBRATION·활자 절 소실). 조용한 하강이라
# 산출을 열어보기 전에는 드러나지 않는다 —
# test_seed_variants_pack_gates.py 가 활성 팩의 게이트 포함을 강제한다.
_INVENTORY_PACKS = frozenset(
    {"9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21"})

_COUNT_WORDS = {1: "ONE", 2: "TWO", 3: "THREE", 4: "FOUR", 5: "FIVE"}


def resolve_variants_pack_version(version: str) -> str:
    if version not in VARIANTS_PACK_VERSION_MAP:
        raise ValueError(f"seed_prompt_variants 팩 버전 없음: {version}")
    return VARIANTS_PACK_VERSION_MAP[version]


def variant_ids(count: int) -> List[str]:
    if count < 1 or count > len(_COUNT_WORDS):
        raise ValueError(f"variant count out of range: {count} (must be 1~5)")
    return [f"v{i + 1}" for i in range(count)]


def load_author_sys(count: int, version: str = "1") -> str:
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_variants_pack_version(version)
    word = _COUNT_WORDS.get(count, str(count))
    return load_prompt(
        VARIANTS_PACK_MODULE, "author_sys", count_word=word,
        version=resolved,
    )


def load_variant_addendums(version: str = "1") -> Dict[str, str]:
    """(v1 전용 — 이력 보존) v2 부터 load_variant_judge_texts 로 대체."""
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_variants_pack_version(version)
    return {
        "judge_addendum": load_prompt(
            VARIANTS_PACK_MODULE, "judge_addendum", version=resolved),
        "critique_addendum": load_prompt(
            VARIANTS_PACK_MODULE, "critique_addendum", version=resolved),
    }


def load_variant_judge_texts(
    count: int, version: str = "2",
) -> Dict[str, str]:
    """seed 변형 전용 판정·결함 계약 (v2 — film-still 계약 전면 교체)."""
    from app.modules.pipeline.multiroll_select import roll_labels
    from app.modules.prompt_loader import load_prompt

    resolved = resolve_variants_pack_version(version)
    word = _COUNT_WORDS.get(count, str(count))
    labels = ", ".join(roll_labels(count))
    return {
        "judge_sys": load_prompt(
            VARIANTS_PACK_MODULE, "judge_sys", count_word=word,
            label_list=labels, version=resolved,
        ),
        "critique_sys": load_prompt(
            VARIANTS_PACK_MODULE, "critique_sys", version=resolved),
    }


def build_author_schema(
    count: int, *, include_inventory: bool = False,
) -> Dict[str, Any]:
    """저작 출력 스키마 — variants 정확히 count 개, id enum 고정.

    include_inventory (v9+, Codex BLOCKING-1·HIGH-3 + v8b 재리뷰):
    room_inventory(항목=identity 라벨+증거 씬 scene_index+원문 verbatim
    인용)+applicability 구조화(room_inventory_applicable — LLM 판정,
    코드 keyword 분류 금지)+lettering_text(공유 활자 문안, 없으면 빈
    문자열)를 required 구조화
    필드로 강제. False=기존 byte-identical (구팩 계약 불변).
    """
    ids = variant_ids(count)
    props: Dict[str, Any] = {
            "location_line_en": {"type": "string"},
            "conformance_brief_en": {"type": "string"},
    }
    req = ["location_line_en", "conformance_brief_en", "variants"]
    if include_inventory:
        props["room_inventory_applicable"] = {"type": "boolean"}
        props["room_inventory"] = {
            "type": "array",
            "items": {
                "type": "object", "additionalProperties": False,
                "properties": {
                    "label_en": {"type": "string"},
                    "scene_index": {"type": "integer"},
                    "evidence_ko": {"type": "string"},
                },
                "required": ["label_en", "scene_index", "evidence_ko"],
            },
        }
        props["lettering_text"] = {"type": "string"}
        req = ["location_line_en", "conformance_brief_en",
               "room_inventory_applicable", "room_inventory",
               "lettering_text", "variants"]
    schema = {
        "type": "object", "additionalProperties": False,
        "properties": {
            **props,
            "variants": {
                "type": "array", "minItems": count, "maxItems": count,
                "items": {
                    "type": "object", "additionalProperties": False,
                    "properties": {
                        "variant_id": {"type": "string", "enum": ids},
                        "approach_ko": {"type": "string"},
                        "evidence_ko": {"type": "array",
                                        "items": {"type": "string"}},
                        "prompt_en": {"type": "string"},
                    },
                    "required": ["variant_id", "approach_ko",
                                 "evidence_ko", "prompt_en"],
                },
            },
        },
        "required": req,
    }
    return schema


def build_author_user_content(
    *,
    scene_indices: Sequence[int],
    scene_texts: Mapping[int, str],
    scene_headings: Mapping[int, str],
    structure_desc: str,
    interior_note_en: str,
    exterior_note_en: str,
    world_anchor: str,
    excluded_transient_en: str,
    count: int,
    prior_violations: Sequence[str] = (),
    interior_scene_indices: Sequence[int] = (),
    typology_facts_block: str = "",
) -> str:
    """저작 입력 조립 — 시나리오 원문 전문 무절단 (절대 규칙).

    scene 원문 결손 = fail-closed (근거 없는 저작 차단).
    prior_violations (Codex 재리뷰 NARROW-4): 재시도 시 이전 시도의 구조
    위반 목록을 교정 블록으로 병기 — 동일 요청 맹복 반복 차단.
    interior_scene_indices (v9, Codex v8b ③ 배선 결손): 같은 building
    실내 멤버 loc 의 씬 원문 — 야외 씬만으로는 room_inventory 가
    원리적으로 방 전수를 열거 못하던 결손(S12/S14/S18 미전달 실측)의
    봉합. 별도 블록으로 병기(방 전수·규모 증거 전용 — 외관·연출 아님).
    빈 시퀀스=기존 조립 byte-identical.
    """
    variant_ids(count)  # count 범위 검증
    if not scene_indices:
        raise ValueError("seed 변형 저작: scene_indices 비어 있음")
    interior_only = [
        si for si in interior_scene_indices if si not in set(scene_indices)]
    missing = [
        si for si in [*scene_indices, *interior_only]
        if not (scene_texts.get(si) or "").strip()
    ]
    if missing:
        raise ValueError(
            f"seed 변형 저작: 씬 원문 결손 scene_indices={missing}")
    if not (structure_desc or "").strip():
        raise ValueError("seed 변형 저작: structure_desc 결손")

    def _block(si: int) -> str:
        heading = (scene_headings.get(si) or "").strip()
        head = f"[SCENE {si}] {heading}".rstrip()
        return f"{head}\n{scene_texts[si]}"

    scene_blocks = [_block(si) for si in scene_indices]
    interior_blocks = [_block(si) for si in interior_only]

    parts: List[str] = []
    if (world_anchor or "").strip():
        parts.append(f"WORLD ANCHOR:{world_anchor}")
    parts.append(
        "STRUCTURE SPEC (topology SOT — sole authority for what exists "
        "and where):\n" + structure_desc
    )
    if (interior_note_en or "").strip():
        parts.append(
            f"INTERIOR NOTES (size evidence only):\n{interior_note_en}")
    if (exterior_note_en or "").strip():
        parts.append(
            f"EXTERIOR NOTES (look evidence):\n{exterior_note_en}")
    if (excluded_transient_en or "").strip():
        parts.append(
            "EXCLUDED TRANSIENT ELEMENTS (never part of the permanent "
            f"site):\n{excluded_transient_en}"
        )
    parts.append(
        "SCENARIO SCENES (original language, complete text — primary "
        "evidence):\n\n" + "\n\n".join(scene_blocks)
    )
    if interior_blocks:
        parts.append(
            "INTERIOR SCENES (original language, complete text — scenes "
            "set inside this structure's interior; evidence for the room "
            "inventory and for size only, never for exterior look or "
            "staging):\n\n" + "\n\n".join(interior_blocks)
        )
    # 유형 사전지식 — 시나리오·spec 이 둘 다 침묵한 자리에만 쓰이는 최하위
    # 권위. 블록 헤더가 그 서열을 스스로 말한다(typology_prior 소유).
    # 빈 문자열이면 조립이 기존과 byte-identical 이다.
    if (typology_facts_block or "").strip():
        parts.append(typology_facts_block.strip())
    cleaned_violations = [
        str(v).strip() for v in prior_violations if str(v).strip()]
    if cleaned_violations:
        parts.append(
            "PREVIOUS ATTEMPT REJECTED — your previous answer violated "
            "the output contract as listed below. Produce a corrected "
            "answer that fixes every item:\n"
            + "\n".join(f"- {v}" for v in cleaned_violations)
        )
    return "\n\n".join(parts)


def _ws_norm(s: Any) -> str:
    """whitespace-normalized 표현 — 개행·연속 공백을 단일 공백으로."""
    return " ".join(str(s).split())


def validate_variant_output(
    data: Any, count: int, *, require_inventory: bool = False,
    scene_texts: Optional[Mapping[int, str]] = None,
) -> List[str]:
    """저작 출력 구조 검증 — 위반 목록 반환 (빈 리스트=통과).

    구조 검증만(개수/공백/pairwise 동일성/원문 인용 provenance byte
    대조) — 글자/substring 의미 판단 금지.
    require_inventory (v9+): 구조화 필드 형상·정합을 fail-closed 로
    잠근다 (Codex v8b ③④⑤ — validator 에 두어 재시도 루프와 sidecar
    cache 재검증 경로를 모두 커버):
    - room_inventory_applicable=bool 판정 필수, true↔비어 있지 않은
      inventory / false↔빈 inventory (적용 대상인데 빈 배열인 저작 차단,
      코드 keyword 분류 없음 — 판정 주체는 LLM).
    - 항목=label_en(비공백)+scene_index(공급 씬)+evidence_ko(비공백),
      label 중복 금지(동일 정체성 이중 계수 차단).
    - scene_texts=공급 씬 원문 필수(Codex v9 리뷰 NARROW-1: 미공급=
      위반 fail-closed — 호출자가 인자를 빠뜨리면 evidence-bound 보장이
      묵시 해제되던 fail-open 차단): 인용은 해당 scene_index 원문의
      whitespace-normalized exact substring 이어야 함(날조 인용으로 방을
      발명하는 경로 차단 — 의미 판단 아닌 provenance 대조).
    - lettering_text=한 줄·제어문자/쌍따옴표 금지 (lettering_clause 의 raw
      replace 조립이 개행·따옴표에 깨지던 NARROW — 결정론 형식 잠금).
    """
    if not isinstance(data, dict):
        return ["저작 출력이 객체가 아님"]
    violations: List[str] = []
    if not str(data.get("location_line_en") or "").strip():
        violations.append("location_line_en 결손")
    if not str(data.get("conformance_brief_en") or "").strip():
        violations.append("conformance_brief_en 결손")
    if require_inventory:
        if scene_texts is None:
            violations.append(
                "scene_texts 미공급 — v9 evidence-bound 검증은 공급 씬 "
                "원문 없이 수행 불가 (fail-closed)")
        applicable = data.get("room_inventory_applicable")
        if not isinstance(applicable, bool):
            violations.append("room_inventory_applicable 비불리언")
        inv = data.get("room_inventory")
        if not isinstance(inv, list):
            violations.append("room_inventory 비배열")
        else:
            if applicable is True and not inv:
                violations.append(
                    "room_inventory_applicable=true 인데 room_inventory "
                    "빈 배열 — 증거된 실내 공간을 전수 열거할 것")
            if applicable is False and inv:
                violations.append(
                    "room_inventory_applicable=false 인데 room_inventory "
                    "비어 있지 않음 — 판정과 열거 모순")
            labels_seen: List[str] = []
            for i, it in enumerate(inv):
                if not isinstance(it, dict):
                    violations.append(f"room_inventory[{i}] 항목이 객체가 아님")
                    continue
                lab = str(it.get("label_en") or "").strip()
                if not lab:
                    violations.append(f"room_inventory[{i}]: label_en 공백")
                si = it.get("scene_index")
                if not isinstance(si, int) or isinstance(si, bool):
                    violations.append(
                        f"room_inventory[{i}]: scene_index 비정수")
                    si = None
                quote = str(it.get("evidence_ko") or "").strip()
                if not quote:
                    violations.append(
                        f"room_inventory[{i}]: evidence_ko(원문 인용) 공백")
                elif scene_texts is not None and si is not None:
                    if si not in scene_texts:
                        violations.append(
                            f"room_inventory[{i}]: scene_index={si} 는 "
                            "공급된 씬이 아님")
                    elif _ws_norm(quote) not in _ws_norm(
                            scene_texts.get(si) or ""):
                        violations.append(
                            f"room_inventory[{i}]: 인용이 SCENE {si} "
                            f"원문에 없음(verbatim 필요): {quote[:40]!r}")
                key = lab.lower()
                if lab and key in labels_seen:
                    violations.append(
                        f"room_inventory label 중복: {lab!r}")
                labels_seen.append(key)
        # 활자 유무는 빈 문자열로 표현한다 — 별도 판단 칸을 두지 않는다.
        text = data.get("lettering_text")
        if not isinstance(text, str):
            violations.append("lettering_text 비문자열")
        else:
            if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
                violations.append(
                    "lettering_text 에 개행/제어문자 — 한 줄이어야 함")
            if '"' in text:
                violations.append("lettering_text 에 쌍따옴표 금지")
    variants = data.get("variants")
    if not isinstance(variants, list) or len(variants) != count:
        violations.append(
            f"variants 개수 위반 (기대 {count}, 실제 "
            f"{len(variants) if isinstance(variants, list) else type(variants).__name__})"
        )
        return violations
    ids: List[Any] = []
    bodies: List[str] = []
    for item in variants:
        if not isinstance(item, dict):
            violations.append("variant 항목이 객체가 아님")
            continue
        vid = item.get("variant_id")
        ids.append(vid)
        body = str(item.get("prompt_en") or "").strip()
        if not body:
            violations.append(f"{vid}: prompt_en 공백")
        bodies.append(body)
        if not isinstance(item.get("evidence_ko"), list):
            violations.append(f"{vid}: evidence_ko 비배열")
        if not str(item.get("approach_ko") or "").strip():
            violations.append(f"{vid}: approach_ko 공백")
    expected = variant_ids(count)
    if ids != expected:
        violations.append(f"variant_id 순서/집합 위반: {ids} (기대 {expected})")
    nonempty = [b for b in bodies if b]
    if len(set(nonempty)) != len(nonempty):
        violations.append("prompt_en 중복 — 변형이 아님")
    return violations


def _inventory_clauses(
    *, resolved: str, data: Mapping[str, Any],
) -> List[str]:
    """v9+ 코드 조립 집계 절 — 방 이름은 하류에 노출하지 않고 개수만
    (Codex BLOCKING-1), 간판 문안은 정확 문자열로 전 이미지 동일 반복
    (HIGH-3). 스템은 {count}/{text} 치환을 str.replace 로 수행(문안에
    중괄호가 있어도 안전 — 한 줄·제어문자/따옴표 금지는 validator 가
    선행 잠금)."""
    from app.modules.prompt_loader import load_prompt

    parts: List[str] = []
    inv = data.get("room_inventory")
    if isinstance(inv, list) and inv:
        stem = load_prompt(
            VARIANTS_PACK_MODULE, "room_capacity_clause",
            version=resolved).strip()
        parts.append(stem.replace("{count}", str(len(inv))))
    text = str(data.get("lettering_text") or "").strip()
    if text:
        stem = load_prompt(
            VARIANTS_PACK_MODULE, "lettering_clause",
            version=resolved).strip()
        parts.append(stem.replace("{text}", text))
    return parts


def _excluded_clause(excluded_transient_en: str) -> str:
    """build_structure_seed_prompt 와 동일 문구 — 계약 드리프트 방지."""
    return (
        "EXCLUDED — TRANSIENT ELEMENTS (confirmed not part of the "
        "permanent site; never depict): " + excluded_transient_en
    )


def _scale_clauses(
    *, resolved: str, interior_note_en: str,
) -> List[str]:
    """규모 계약 절 조립 (v6+ 전용) — 불변 안전절=코드 조립.

    human_scale_clause = interior 증거 유무와 무관하게 상시(고정 앵커:
    문·창·층고 대비 크기 진실). scale_clause + INTERIOR = 같은 building
    실내 loc 서술이 있을 때만(size evidence — 씬 파생 데이터, dangling
    금지). LLM 저작(prompt_en)이 규모 서술을 떨어뜨려도 이 절이 최종
    프롬프트와 conformance 브리프 양쪽에 남는다 (E2E11 ① 재발 차단).
    """
    from app.modules.prompt_loader import load_prompt

    parts = [
        load_prompt(
            VARIANTS_PACK_MODULE, "human_scale_clause", version=resolved
        ).strip()
    ]
    if (interior_note_en or "").strip():
        parts.append(
            load_prompt(
                VARIANTS_PACK_MODULE, "scale_clause", version=resolved
            ).strip()
            + f"\nINTERIOR: {interior_note_en}"
        )
    return parts


def assemble_variant_roll_prompts(
    *,
    data: Mapping[str, Any],
    labels: Sequence[str],
    world_anchor: str,
    excluded_transient_en: str,
    seed_pack_version: str,
    variants_pack_version: str = "1",
    interior_note_en: str = "",
) -> Dict[str, str]:
    """라벨→전체 롤 프롬프트 (순서 고정: labels[i] ↔ variants[i]).

    불변 안전절=코드 조립(LLM 저작이 계약을 떨어뜨릴 수 없음).
    THE LOCATION=저작본(location_line_en)만 — raw 씬 헤딩 미주입(진단 ③).

    v3+(로케이션 헌팅 계약): head/rules/excluded 를 seed_prompt_variants
    팩이 소유 — structure_seed 팩(고립 플레이트 계약) 의존 제거.
    world_anchor 원문(인물 문구 포함 가능)도 미병기 — 지역 앵커는
    location_line_en·저작 본문이 담당. v1/v2 선택 시=기존 조립 그대로.

    v6+(E2E11 ①): 규모 계약도 불변 안전절 — human_scale(상시) +
    scale_clause+INTERIOR(interior_note_en 있을 때만) 를 코드 조립.
    v5 이하 = interior_note_en 무시(기존 byte-identical).
    """
    from app.modules.prompt_loader import load_prompt

    variants = data.get("variants") or []
    if len(labels) != len(variants):
        raise ValueError(
            f"라벨 수({len(labels)}) != 변형 수({len(variants)})")
    location = str(data.get("location_line_en") or "").strip()
    if not location:
        raise ValueError("location_line_en 결손 — validate 선행 필요")

    excluded = (excluded_transient_en or "").strip()
    out: Dict[str, str] = {}

    if variants_pack_version not in ("1", "2"):
        resolved = resolve_variants_pack_version(variants_pack_version)
        scout_head = load_prompt(
            VARIANTS_PACK_MODULE, "scout_head", version=resolved).strip()
        plate_rules = load_prompt(
            VARIANTS_PACK_MODULE, "plate_rules", version=resolved).strip()
        excluded_head = load_prompt(
            VARIANTS_PACK_MODULE, "excluded_head",
            version=resolved).strip()
        scale_parts: List[str] = []
        if variants_pack_version in _SCALE_VARIANT_PACKS:
            scale_parts = _scale_clauses(
                resolved=resolved, interior_note_en=interior_note_en)
        if variants_pack_version in _INVENTORY_PACKS:
            scale_parts = scale_parts + _inventory_clauses(
                resolved=resolved, data=data)
        for lab, item in zip(labels, variants):
            parts = [
                scout_head,
                str(item.get("prompt_en") or "").strip(),
                f"THE LOCATION: {location}",
                *scale_parts,
            ]
            if excluded:
                parts.append(f"{excluded_head}\n{excluded}")
            parts.append(plate_rules)
            out[lab] = "\n\n".join(parts)
        return out

    from app.modules.pipeline.outdoor_structure_seed import (
        SEED_PACK_MODULE,
        resolve_seed_pack_version,
    )

    resolved = resolve_seed_pack_version(seed_pack_version)
    seed_head = load_prompt(
        SEED_PACK_MODULE, "seed_head", version=resolved).strip()
    no_people = load_prompt(
        SEED_PACK_MODULE, "no_people", version=resolved).strip()

    for lab, item in zip(labels, variants):
        parts = [
            f"A photographic plate{world_anchor}.",
            seed_head,
            str(item.get("prompt_en") or "").strip(),
            f"THE LOCATION: {location}",
        ]
        if excluded:
            parts.append(_excluded_clause(excluded_transient_en))
        parts.append(no_people)
        out[lab] = "\n\n".join(parts)
    return out


def build_conformance_prompt(
    *,
    data: Mapping[str, Any],
    excluded_transient_en: str,
    variants_pack_version: str = "1",
    interior_note_en: str = "",
) -> str:
    """judge/critique 공용 브리프 — 후보 공유 불변 사실 (진단 ② 검출 축).

    v3+ (Codex v3 리뷰 HIGH-1): 배제 절을 생성 프롬프트와 동일한 scoped
    문구(excluded_head — 씬 순간 연출만 배제, ordinary generic
    equivalents 허용)로 병기 — 생성이 허용한 생활 전형을 판정·수정이
    'never depict' 로 도로 제거하는 경로 차단. v1/v2=기존 byte-identical.

    v6+ (E2E11 ①): 규모 계약 절을 생성 프롬프트와 동일 문구로 병기 —
    judge/critique 가 규모 위반(interior 증거 대비 과소/과대)을 검출·수정
    지시할 수 있는 축. v5 이하 = interior_note_en 무시(byte-identical).
    """
    brief = str(data.get("conformance_brief_en") or "").strip()
    location = str(data.get("location_line_en") or "").strip()
    if not brief or not location:
        raise ValueError("conformance brief/location 결손 — validate 선행 필요")
    parts = [
        "SHARED CONFORMANCE BRIEF (invariant visual facts every candidate "
        "must satisfy):",
        brief,
        f"THE LOCATION: {location}",
    ]
    if variants_pack_version in _SCALE_VARIANT_PACKS:
        parts.extend(_scale_clauses(
            resolved=resolve_variants_pack_version(variants_pack_version),
            interior_note_en=interior_note_en,
        ))
    if variants_pack_version in _INVENTORY_PACKS:
        parts.extend(_inventory_clauses(
            resolved=resolve_variants_pack_version(variants_pack_version),
            data=data,
        ))
    excluded = (excluded_transient_en or "").strip()
    if excluded:
        if variants_pack_version not in ("1", "2"):
            from app.modules.prompt_loader import load_prompt

            resolved = resolve_variants_pack_version(variants_pack_version)
            excluded_head = load_prompt(
                VARIANTS_PACK_MODULE, "excluded_head",
                version=resolved).strip()
            parts.append(f"{excluded_head}\n{excluded}")
        else:
            parts.append(
                "EXCLUDED — TRANSIENT ELEMENTS (never depict): "
                + excluded_transient_en
            )
    return "\n\n".join(parts)
