# Review package: 37e44068..b4089b84 ## Commits b4089b84 fix(lane): 마네킹 유출 3층 가드 — producer 거부/hash 접기/소비자 무조건 검사 ## Files changed backend/app/core/steps/shot_conti_light_step.py | 92 ++++++++++++- backend/app/services/still_recipe_service.py | 46 +++++++ backend/tests/core/test_shot_conti_light_lane.py | 164 +++++++++++++++++++++-- backend/tests/unit/test_still_recipe_bgfirst.py | 106 +++++++++++++++ 4 files changed, 393 insertions(+), 15 deletions(-) ## Diff diff --git a/backend/app/core/steps/shot_conti_light_step.py b/backend/app/core/steps/shot_conti_light_step.py index 4299ac8c..54b4a49b 100644 --- a/backend/app/core/steps/shot_conti_light_step.py +++ b/backend/app/core/steps/shot_conti_light_step.py @@ -62,20 +62,32 @@ LANE_GEOMETRY_PACK_VERSION = "4" # v13: marker_check 에서 틴트 등 스타일 축 제외 (배치·방향만 판정) # v14 (2026-07-26 사용자 확정 — 3변형 격리 실측): 인물 방향 반전 원인이 # 렌더 스타일임을 확정 — 콘티=마네킹(mannequin_frame), 콘티 참조=마커 맵 # 1장(plate_look 제거 → plate_look_on=False), 관할 명문화(수평 배치= # staging 우선). 배경·인물 외형은 뒤 단계에서 i2i 로 얹는다. LANE_SKETCH_PACK_VERSION = "14" LANE_LEAKAGE_MAX_ATTEMPTS = 3 # 3 (2026-07-25): 마커 계약이 쐐기·화살표·라벨로 늘어 1차 시도 실패 # 여지가 커졌다 — 검증 재시도 1회 추가 (i2i 1콜 비용, 소진=fail-closed). LANE_MARKER_ANNOTATE_MAX_ATTEMPTS = 3 +# 마네킹 콘티 ↔ 배경/엔티티 마네킹 교체 계약의 짝 버전. 어느 한쪽만 +# 바뀌면 CP 를 무효화해야 하므로 config hash 에 접힌다. +MANNEQUIN_CHAIN_CONTRACT_VERSION = "1" + + +def _mannequin_sketch_packs(): + """마네킹 저작 sketch 팩 집합 (지연 import — 모듈 로드 순환 방지).""" + from app.modules.pipeline.outdoor_marker_map import ( + _MANNEQUIN_SKETCH_PACKS, + ) + + return _MANNEQUIN_SKETCH_PACKS def _resolve_openai_client(): """background_render_step._resolve_openai_client 와 동일 패턴.""" from openai import OpenAI from app.core.config import settings return OpenAI( api_key=settings.openai_api_key, timeout=float(settings.llm_timeout_image_gen), @@ -183,20 +195,36 @@ class ShotContiLightStep(StepRunner): payload["lane_sketch_pack"] = resolve_sketch_pack_version( LANE_SKETCH_PACK_VERSION ) # 재리뷰 HIGH-1: 직렬화 계약(v4 스케치 실질 입력)도 스탬프 — # completed CP 는 _execute 를 안 타 sidecar 지문만으로는 # 무효화되지 않는다. payload["lane_geometry_text_version"] = _geometry_text_version payload["lane_evidence_validation_version"] = ( _lane_evidence_version ) + # 2026-07-26: 마네킹 팩은 하류 교체 계약과 짝이다 — lane-prev + # 플래그까지 접지 않으면 v14 CP 를 만든 뒤 그 플래그만 내려 + # CP 재사용+legacy 경로 유출이 가능하다(구 hash 는 bgfirst/ + # full 만 접었다). lane pipe OFF 는 lane 콘티 자체가 없어 + # 유출 표면이 없으므로 opt-in stamping 관례를 지킨다 + # (OFF byte-identical — 무관 프로젝트 전량 재렌더 금지). + if LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs(): + payload["lane_mannequin_chain"] = { + "contract": MANNEQUIN_CHAIN_CONTRACT_VERSION, + "bgfirst": bool(getattr( + settings, "still_bgfirst_enabled", False)), + "bgfirst_full": bool(getattr( + settings, "still_bgfirst_full_enabled", False)), + "lane_prev": bool(getattr( + settings, "still_lane_prev_bgfirst_enabled", False)), + } # R1 (Codex 설계 리뷰 BLOCKING-1): 플레이트 권위 선행 판정도 출력 # (콘티가 그려지는 플레이트) 실질 입력 — ON 시만 스탬프. if bool(getattr(settings, "still_plate_select_enabled", False)): from app.modules.pipeline.plate_select import ( PLATE_AUTHORITY_VERSION, resolve_prompt_version as _plate_select_pack, ) payload["still_plate_select_enabled"] = True payload["plate_select_pack"] = _plate_select_pack("1") @@ -226,20 +254,40 @@ class ShotContiLightStep(StepRunner): @staticmethod def _no_plate_conti_on() -> bool: """fix④ full 게이트 — bgfirst 자체가 ON 일 때만 유효 (OFF 인데 full 만 ON 이면 콘티는 v1 그대로, 서비스가 fail-closed).""" from app.core.config import settings return bool( getattr(settings, "still_bgfirst_full_enabled", False) ) and bool(getattr(settings, "still_bgfirst_enabled", False)) + @staticmethod + def _mannequin_chain_ready() -> bool: + """마네킹 콘티를 만들어도 되는 조건 — 하류 교체 계약이 살아 있나. + + 마네킹 산출은 배경 i2i·엔티티 단계의 교체 계약과 짝으로만 + 유효하다. 세 플래그 중 하나라도 꺼지면 서비스가 lane_chain=False + 로 계산해 마네킹을 legacy 조립 참조로 넣고, 그 경로엔 교체 계약이 + 없어 마네킹이 최종 스틸까지 유출된다(2026-07-26 설계 실측). + """ + from app.core.config import settings + + return all( + bool(getattr(settings, name, False)) + for name in ( + "still_bgfirst_enabled", + "still_bgfirst_full_enabled", + "still_lane_prev_bgfirst_enabled", + ) + ) + def _char_refs_by_tag(self) -> Dict[str, Any]: """샷별 캐릭터 비례 참조 — VE(scene_still SOT) → character 엔티티 primary reference ImageAsset 경로. (BGFIRST2 v2 콘티 전용 — outlook v14 보편 계약으로 primary=기본 모습.) 결손=해당 샷 참조 없음(fail-safe — 콘티 자체를 막지 않는다).""" from app.core.file_paths import resolve_image_path from app.models.project import EntityCanon, ImageAsset, SceneStill from app.modules.pipeline.shot_ref_classify import tag_of char_name_by_id: Dict[str, str] = { @@ -741,21 +789,22 @@ class ShotContiLightStep(StepRunner): 단계 복원, i2i): base map(클린 canon map)→geometry(LLM)→ **i2i 마커 맵**(이미지 모델이 1:1 캔버스로 CAM·시선·슬롯 마커 작화, 코드 드로잉 금지 준수)→마커 VLM 검증(원본 대조 2이미지, 소진=fail-closed)→스토리보드 스케치(gpt edit, 참조=마커 맵+geometry ID-free 텍스트 절)→leakage VLM gate. - 레인2(structure_plate): 맵·마커·마커 스케치 **제거** — A/B 정책 entry(ab_select_pending)만 기록하고 일반 경량 콘티 경로에 병합. finalize(finalize_structure_ab_entries)가 ready/bypass/failed 확정. persisted plan 소비 직전 재검증(fail-closed), camera_direction - 결손=AppError(Codex 배선 조건 ③). + 결손=AppError(Codex 배선 조건 ③). 팩이 마네킹 저작(v14+)이면 + 진입 시 하류 교체 계약 3플래그를 요구한다(마네킹 유출 1층). 반환 {"lane_contis": {tag: {...}}}. 샷 단위 실패 격리(leakage 소진 =failed). 사이드카 지문(lane_records.json)으로 재개. """ from app.core.errors import AppError from app.modules.llm.llm_client import call_structured from app.modules.pipeline.multiroll_gemini import ( atomic_write_bytes, png_part, ) from app.modules.pipeline.multiroll_select import ( @@ -892,20 +941,59 @@ class ShotContiLightStep(StepRunner): si, shi = sh.get("scene_index"), sh.get("shot_index") if si is not None and shi is not None: cam_by_tag[f"S{int(si)}sh{int(shi)}"] = ( sh.get("camera_direction") or "") from app.modules.pipeline.outdoor_direct_common import is_selected from app.modules.pipeline.shot_conti_light import ( build_pose_clauses, ) + # 마네킹 유출 1층(producer 거부): 마네킹 팩으로 콘티를 굽기 전에 + # 하류 교체 계약이 살아 있는지 확인한다. 계약이 없으면 마네킹이 + # legacy 조립 참조로 흘러 최종 스틸까지 남는다 — 굽지 않는 쪽이 + # 안전하다(fail-closed). + if ( + LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs() + and not self._mannequin_chain_ready() + ): + raise AppError( + code="step.config.lane_mannequin_chain_off", + message=( + f"lane sketch 팩 v{LANE_SKETCH_PACK_VERSION}(마네킹)" + "인데 still_bgfirst_enabled/" + "still_bgfirst_full_enabled/" + "still_lane_prev_bgfirst_enabled 가 모두 ON 이 아님 " + "— 마네킹 콘티는 배경/엔티티 교체 계약과 짝으로만 " + "유효하다 (마네킹 유출 방지, fail-closed)" + ), + status_code=422, + ) + + # 2026-07-26: 소비자(still_recipe_service)가 팩 계약을 exact + # revalidate 할 수 있도록 resolved 팩명을 CP entry 에 영속한다 — + # 이전에는 lane_records sidecar 지문 extra 에만 있어서 구 팩 + # 콘티를 신 계약으로 조용히 소비할 수 있었다. 생성/reuse 양 + # 경로에 같은 스탬프를 쓴다(reuse 는 지문에 sketch_pack 이 접혀 + # 있어 항상 동일 팩). + _pack_stamp: Dict[str, Any] = { + "lane_sketch_pack": resolve_sketch_pack_version( + LANE_SKETCH_PACK_VERSION), + "lane_geometry_pack": resolve_prompt_version( + LANE_GEOMETRY_PACK_VERSION), + "mannequin_chain_contract": ( + MANNEQUIN_CHAIN_CONTRACT_VERSION + if LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs() + else "" + ), + } + leakage_sys = load_prompt( "marker_map_sketch", "leakage_judge", version=resolve_sketch_pack_version(LANE_SKETCH_PACK_VERSION), ) # v5: i2i 마커 맵 검증 계약 (존재·근사 위치·플랜 보존) marker_check_sys = load_marker_check_sys(LANE_SKETCH_PACK_VERSION) sketch_fn = self._make_lane_sketch_fn() # v9(케이스1 단계 C·D): 스케치 2번째 참조=장소 실사(canon master) plate_look_on = LANE_SKETCH_PACK_VERSION in _plate_look_packs geometry_config = {"outdoor_marker_geometry": {"model": "gpt"}} @@ -1129,20 +1217,21 @@ class ShotContiLightStep(StepRunner): base_map_path=base_map, base_map_asset_id=base_asset_id, marker_map_path=str(marker_map_path), marker_check=prev_rec.get("marker_check"), marker_prompt=prev_rec.get("marker_prompt"), marker_attempts=prev_rec.get("marker_attempts"), geometry=prev_rec.get("geometry"), leakage=prev_rec.get("leakage"), prompt=prev_rec.get("prompt"), reused=True, + **_pack_stamp, ) if plate_look_on: entry.update( master_png_path=master_png, master_asset_id=master_asset_id, ) continue # stale 산출 아카이브 (지문 불일치/force) archive_stale_output(sketch_path) archive_stale_output(marker_map_path) @@ -1310,20 +1399,21 @@ class ShotContiLightStep(StepRunner): image_path=str(sketch_path), base_map_path=base_map, base_map_asset_id=base_asset_id, marker_map_path=str(marker_map_path), marker_check=marker_check, marker_prompt=_marker_prompt, geometry=geometry, geometry_attempts=g_out["attempts"], leakage=verdict, prompt=sketch_prompt, + **_pack_stamp, ) if plate_look_on: # 케이스1 단계 C 소비 기록 — 콘티가 실제로 참조한 장소 # 실사 (lineage/감사; 소비자는 이 경로로 배경 권위 추적) entry.update( master_png_path=master_png, master_asset_id=master_asset_id, ) sidecar[tag] = { "input_fingerprint": fingerprint, diff --git a/backend/app/services/still_recipe_service.py b/backend/app/services/still_recipe_service.py index f9a80ebe..1d41ad13 100644 --- a/backend/app/services/still_recipe_service.py +++ b/backend/app/services/still_recipe_service.py @@ -24,20 +24,55 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple logger = logging.getLogger(__name__) def _now() -> str: return datetime.now(timezone.utc).isoformat() +def _require_mannequin_chain( + lane_entries: Dict[str, Any], *, chain_ready: bool, +) -> None: + """마네킹 콘티 CP ↔ 체인 플래그 정합 — lane_chain 분기 **밖**에서 + 무조건 호출한다. + + lane_chain=True 안에서만 검사하면 플래그를 내린 뒤 v14 CP 를 재사용 + 하는 경로가 그대로 남는다(그때 lane_chain 은 False 라 검사 자체가 + 실행되지 않는다). + """ + from app.core.errors import AppError + from app.modules.pipeline.outdoor_marker_map import ( + _MANNEQUIN_SKETCH_PACKS, + ) + + if chain_ready: + return + for tag, entry in (lane_entries or {}).items(): + if not isinstance(entry, dict): + continue + pack = str(entry.get("lane_sketch_pack") or "") + selector = pack.split(".", 1)[0] + if selector in _MANNEQUIN_SKETCH_PACKS: + raise AppError( + code="still_recipe.lane_mannequin_chain_off", + message=( + f"lane 콘티({tag})가 마네킹 팩 {pack} 로 생성됐는데 " + "bgfirst/bgfirst_full/lane_prev 플래그가 모두 ON 이 " + "아님 — 마네킹이 legacy 조립으로 유출된다 " + "(fail-closed)" + ), + status_code=422, + ) + + def _load_cp( projects_dir: str, project_id: str, episode_id: str, step_id: str ) -> Dict[str, Any]: cp = ( Path(projects_dir) / project_id / "checkpoints" / "episodes" / episode_id / step_id / "manifest.json" ) if cp.exists(): try: return json.loads(cp.read_text(encoding="utf-8")) @@ -301,20 +336,31 @@ def run_still_recipe_generation( # selector = LANE_PROMPT_VERSION (모듈 SOT). from app.modules.pipeline.still_recipe import ( COMPLEX_AB_CONTRACT_VERSION, COMPLEX_PROMPT_VERSION as _COMPLEX_PACK, LANE_PROMPT_VERSION as _LANE_PACK, SEED_BG_PROMPT_VERSION as _SEED_BG_PACK, STILL_CONDUCT_PROMPT_VERSION as _CONDUCT_PACK_SEL, ) lane_conti: Dict[str, Any] = conti_data.get("lane_conti", {}) or {} + # 마네킹 유출 3층(소비자 거부): 마네킹 CP ↔ 체인 플래그 정합을 + # lane_chain 분기와 무관하게 여기서 무조건 검사한다(분기 안에서 하면 + # 플래그를 내린 뒤 lane_chain=False 가 되어 검사 자체가 실행되지 + # 않는다 — 같은 구멍이 남는다). + _require_mannequin_chain( + lane_conti, + chain_ready=bool(getattr(settings, "still_bgfirst_enabled", False)) + and bool(getattr(settings, "still_bgfirst_full_enabled", False)) + and bool(getattr( + settings, "still_lane_prev_bgfirst_enabled", False)), + ) # R1: 콘티형 샷 플레이트 권위 — shot_conti_light 선행 판정 기록 소비 plate_authority: Dict[str, Any] = ( conti_data.get("plate_authority", {}) or {} ) plate_map = resolve_shot_plate_map(projects_dir, project_id, episode_id) # 맵 기반 플레이트가 성립한 샷은 스틸 참조도 맵 플레이트로 대체 from app.modules.pipeline.shot_ref_classify import parse_tag map_plate_keys: set = set() diff --git a/backend/tests/core/test_shot_conti_light_lane.py b/backend/tests/core/test_shot_conti_light_lane.py index 2c3cfee3..c97bd0bd 100644 --- a/backend/tests/core/test_shot_conti_light_lane.py +++ b/backend/tests/core/test_shot_conti_light_lane.py @@ -1,16 +1,17 @@ """ShotContiLightStep lane 분기 결정론 테스트 (Stage D). 생성(LLM/이미지)은 진입 전에 fail-closed 되는 분기 위주 — 게이트/재검증/ 결손 계약/hash 스탬프만 검증. fixture 전부 시나리오 중립 SAMPLE. """ +from contextlib import ExitStack from unittest.mock import MagicMock, patch import pytest from app.core.steps.shot_conti_light_step import ShotContiLightStep def _make_step(): step = ShotContiLightStep.__new__(ShotContiLightStep) step.project_id = "SAMPLE_PROJECT" @@ -58,36 +59,55 @@ def _cps(lane="map_marker", quote="달려간다", camera="정면 와이드"): "outdoor_structure_seed": {"data": {"groups": {}}}, "shot_validator": {"data": {"scenes": [ {"scene_index": 3, "shots": [ {"shot_index": 1, "location_id": "L01", "description": "달려가는 인물"}]}, ]}}, "shot_staging": {"data": {"shots": staging_shots}}, } -def _run_lane(step, scene_texts=None, tmp_path=None): +def _chain_patches(on=True): + """마네킹 체인 3플래그 patch — sketch 팩 v14(마네킹)는 하류 교체 + 계약과 짝으로만 유효해서, lane 분기 진입 자체가 세 플래그 ON 을 + 요구한다(producer 가드). 기존 분기 계약 테스트는 그 가드보다 뒤의 + 동작을 보려는 것이므로 체인을 켜 둔다.""" + return [ + patch(f"app.core.config.settings.{name}", on, create=True) + for name in ( + "still_bgfirst_enabled", + "still_bgfirst_full_enabled", + "still_lane_prev_bgfirst_enabled", + ) + ] + + +def _run_lane(step, scene_texts=None, tmp_path=None, chain_on=True): + from contextlib import ExitStack from pathlib import Path - return step._run_lane_conti( - scene_texts=scene_texts if scene_texts is not None - else {3: "그가 달려간다."}, - continuity={}, - location_by_scene={}, - scene_headings={}, - selected_keys=None, # is_selected: None=전체 선택 관례 확인용 - out_dir=Path(tmp_path or "/tmp/SAMPLE_lane"), - # 2026-07-17 seed-bg: typed 배경 권위 해석 입력 (테스트=빈 배정) - plate_map={}, - assign_by_key={}, - force=False, - ) + with ExitStack() as stack: + for p in _chain_patches(chain_on): + stack.enter_context(p) + return step._run_lane_conti( + scene_texts=scene_texts if scene_texts is not None + else {3: "그가 달려간다."}, + continuity={}, + location_by_scene={}, + scene_headings={}, + selected_keys=None, # is_selected: None=전체 선택 관례 확인용 + out_dir=Path(tmp_path or "/tmp/SAMPLE_lane"), + # 2026-07-17 seed-bg: typed 배경 권위 해석 입력 (테스트=빈 배정) + plate_map={}, + assign_by_key={}, + force=False, + ) def test_lane_noop_without_bindings(tmp_path): step = _make_step() step._load_prev_checkpoint = lambda sid: {"data": {"groups": {}}} out = _run_lane(step, tmp_path=tmp_path) assert out == {"lane_contis": {}} def test_lane_revalidation_fail_closed(tmp_path): @@ -588,10 +608,126 @@ def test_config_hash_marker_contract_sensitivity(): h_model = step._config_hash() with patch("app.modules.pipeline.outdoor_marker_map." "MARKER_ANNOTATE_CONTRACT_VERSION", 999, create=True): h_contract = step._config_hash() with patch("app.modules.pipeline.outdoor_marker_map." "MARKER_GEOMETRY_CONTRACT_VERSION", 999, create=True): h_geo = step._config_hash() assert h_model != h_base assert h_contract != h_base assert h_geo != h_base + + +# ── 마네킹 유출 3층 가드 (2026-07-26): producer 거부 + CP 무효화 ────── + + +def test_mannequin_pack_requires_all_three_chain_flags(monkeypatch): + from app.core.config import settings + + for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled", + "still_lane_prev_bgfirst_enabled"): + monkeypatch.setattr(settings, on, True, raising=False) + assert ShotContiLightStep._mannequin_chain_ready() is True + + # 어느 하나라도 꺼지면 마네킹 팩은 준비되지 않았다 + for off in ("still_bgfirst_enabled", "still_bgfirst_full_enabled", + "still_lane_prev_bgfirst_enabled"): + monkeypatch.setattr(settings, off, False, raising=False) + assert ShotContiLightStep._mannequin_chain_ready() is False + monkeypatch.setattr(settings, off, True, raising=False) + + +def test_lane_mannequin_producer_fail_closed(tmp_path, monkeypatch): + """1층: 체인이 꺼진 채로는 마네킹 콘티를 굽지 않는다 — 상류 자산이 + 전부 갖춰진 상태에서도(= 다른 fail-closed 보다 먼저) 거부한다.""" + from app.core.errors import AppError + + step, _rec, _base = _lane_gen_env( + tmp_path, monkeypatch, + check_verdicts=[{"markers_ok": True, "details_ko": "정상"}], + leak_verdicts=[{"has_marker_leakage": False, "details_ko": ""}], + ) + with pytest.raises(AppError) as ei: + _run_lane(step, tmp_path=tmp_path, chain_on=False) + assert ei.value.code == "step.config.lane_mannequin_chain_off" + assert "마네킹" in ei.value.message + + +def test_lane_entry_persists_resolved_packs(tmp_path, monkeypatch): + """CP entry 에 resolved 팩·계약 영속 — 소비자 exact revalidate 입력. + 생성/reuse 두 경로 모두 같은 스탬프를 남겨야 한다.""" + from app.core.steps.shot_conti_light_step import ( + LANE_GEOMETRY_PACK_VERSION, + LANE_SKETCH_PACK_VERSION, + MANNEQUIN_CHAIN_CONTRACT_VERSION, + ) + from app.modules.pipeline.outdoor_marker_map import ( + resolve_prompt_version as geo_pack, + resolve_sketch_pack_version as sketch_pack, + ) + + step, _rec, _base = _lane_gen_env( + tmp_path, monkeypatch, + check_verdicts=[{"markers_ok": True, "details_ko": "정상"}] * 2, + leak_verdicts=[ + {"has_marker_leakage": False, "details_ko": ""}] * 2, + ) + fresh = _run_lane(step, tmp_path=tmp_path)["lane_contis"]["S3sh1"] + reused = _run_lane(step, tmp_path=tmp_path)["lane_contis"]["S3sh1"] + assert reused.get("reused") is True + for entry in (fresh, reused): + assert entry["lane_sketch_pack"] == sketch_pack( + LANE_SKETCH_PACK_VERSION) + assert entry["lane_geometry_pack"] == geo_pack( + LANE_GEOMETRY_PACK_VERSION) + assert entry["mannequin_chain_contract"] == ( + MANNEQUIN_CHAIN_CONTRACT_VERSION) + + +def test_config_hash_folds_lane_prev_flag(): + """2층: lane-prev 플래그가 hash 에 접혀야 플래그만 내린 CP 재사용이 + 막힌다. lane pipe OFF 는 lane 콘티 자체가 없어 스탬프 대상이 아니다 + (opt-in stamping 관례 — 무관 프로젝트 hash byte-identical).""" + step = _make_step() + with patch("app.core.config.settings.outdoor_lane_pipe_enabled", + True, create=True): + with ExitStack() as stack: + for p in _chain_patches(True): + stack.enter_context(p) + h_on = step._config_hash() + with ExitStack() as stack: + for p in _chain_patches(True): + stack.enter_context(p) + stack.enter_context(patch( + "app.core.config.settings." + "still_lane_prev_bgfirst_enabled", False, create=True)) + h_off = step._config_hash() + # 계약 버전 자체가 바뀌어도 CP 는 무효화된다 + with ExitStack() as stack: + for p in _chain_patches(True): + stack.enter_context(p) + stack.enter_context(patch( + "app.core.steps.shot_conti_light_step." + "MANNEQUIN_CHAIN_CONTRACT_VERSION", "999")) + h_contract = step._config_hash() + assert h_on != h_off + assert h_on != h_contract + + +def test_config_hash_mannequin_stamp_is_lane_opt_in(): + """lane pipe OFF 면 체인 플래그를 켜도 hash 불변 — 무관 프로젝트의 + shot_conti_light CP 전량 재렌더 금지.""" + step = _make_step() + with patch("app.core.config.settings.outdoor_lane_pipe_enabled", + False, create=True): + with ExitStack() as stack: + for p in _chain_patches(True): + stack.enter_context(p) + h_chain_on = step._config_hash() + with ExitStack() as stack: + for p in _chain_patches(True): + stack.enter_context(p) + stack.enter_context(patch( + "app.core.config.settings." + "still_lane_prev_bgfirst_enabled", False, create=True)) + h_chain_off = step._config_hash() + assert h_chain_on == h_chain_off diff --git a/backend/tests/unit/test_still_recipe_bgfirst.py b/backend/tests/unit/test_still_recipe_bgfirst.py index 6e36b80e..21f6c170 100644 --- a/backend/tests/unit/test_still_recipe_bgfirst.py +++ b/backend/tests/unit/test_still_recipe_bgfirst.py @@ -1,11 +1,12 @@ """still_recipe BGFIRST2 체인 헬퍼 결정론 테스트 (이식 ②③).""" +import json from pathlib import Path import pytest from app.modules.pipeline.still_recipe import ( BGFIRST_PROMPT_VERSION, bgfirst_eligible, build_bgfirst_bg_prompt, build_bgfirst_final_prompt, build_bgfirst_refs, @@ -363,10 +364,115 @@ def test_bgfirst_winner_lineage_matrix(): chain_won=False, authority_kind="groupbg", structure_seed_attached=False, ) == ["groupbg"] import pytest with pytest.raises(ValueError): bgfirst_winner_lineage( chain_won=False, authority_kind="???", structure_seed_attached=False, ) + + +# ── 마네킹 유출 3층 가드 (2026-07-26): 소비자 무조건 검사 ──────────── + + +def test_service_rejects_mannequin_cp_when_chain_flag_off(): + """소비자 검사는 lane_chain 분기 **밖**에서 무조건 돌아야 한다.""" + from app.core.errors import AppError + from app.services.still_recipe_service import ( + _require_mannequin_chain, + ) + from app.modules.pipeline.outdoor_marker_map import ( + resolve_sketch_pack_version, + ) + + entries = {"SAMPLE_FIXTURE_TAG": { + "lane": "map_marker", "status": "ok", + "lane_sketch_pack": resolve_sketch_pack_version("14")}} + # 체인 준비 안 됨 → fail-closed + with pytest.raises(AppError) as ei: + _require_mannequin_chain(entries, chain_ready=False) + assert "mannequin" in str(ei.value.code) + # 체인 준비됨 → 통과 + _require_mannequin_chain(entries, chain_ready=True) + # 구 v13 콘티는 마네킹 검사 대상이 아니다 (팩 스탬프 없는 구 CP 도) + _require_mannequin_chain( + {"SAMPLE_FIXTURE_TAG": { + "lane": "map_marker", "status": "ok", + "lane_sketch_pack": resolve_sketch_pack_version("13")}}, + chain_ready=False) + _require_mannequin_chain( + {"SAMPLE_FIXTURE_TAG": {"lane": "map_marker", "status": "ok"}}, + chain_ready=False) + # 비 lane 프로젝트(빈 CP)·비 dict entry = no-op + _require_mannequin_chain({}, chain_ready=False) + _require_mannequin_chain({"SAMPLE_FIXTURE_TAG": None}, + chain_ready=False) + + +def test_service_mannequin_check_runs_before_lane_chain_branch(): + """구멍의 본체 — 검사 호출이 `lane_chain` 계산보다 **앞**이어야 한다. + + 분기 안에서 검사하면 플래그를 내린 순간 lane_chain=False 라 검사 + 자체가 실행되지 않는다(= v14 CP 조용한 재사용). 소스 순서를 잠근다. + """ + import inspect + + from app.services import still_recipe_service as svc + + src = inspect.getsource(svc.run_still_recipe_generation) + # 문 시작(줄머리)으로 앵커 — 다른 이름의 부분일치 통과 방지 + call_at = src.index("\n _require_mannequin_chain(") + branch_at = src.index("\n lane_chain = ") + assert call_at < branch_at + + +def _write_cp(root: Path, step_id: str, data: dict) -> None: + cp = (root / "SAMPLE_FIXTURE_PROJECT" / "checkpoints" / "episodes" + / "SAMPLE_FIXTURE_EPISODE" / step_id) + cp.mkdir(parents=True, exist_ok=True) + (cp / "manifest.json").write_text( + json.dumps({"status": "completed", "data": data}), + encoding="utf-8") + + +def test_run_still_recipe_raises_on_mannequin_cp_with_chain_off( + tmp_path, monkeypatch): + """런타임 증명 — 세 플래그 OFF(= lane_chain 이 False 로 계산되는 바로 + 그 상태)에서 v14 CP 를 소비하면 분기에 닿기 전에 raise 한다.""" + from app.core.config import settings + from app.core.errors import AppError + from app.modules.pipeline.outdoor_marker_map import ( + resolve_sketch_pack_version, + ) + from app.services.still_recipe_service import ( + run_still_recipe_generation, + ) + + for off in ("still_bgfirst_enabled", "still_bgfirst_full_enabled", + "still_lane_prev_bgfirst_enabled"): + monkeypatch.setattr(settings, off, False, raising=False) + monkeypatch.setattr(settings, "projects_dir", str(tmp_path)) + + _write_cp(tmp_path, "shot_ref_classify", {"shots": {}, "scenes": {}}) + _write_cp(tmp_path, "shot_continuity", {"shots": {}}) + _write_cp(tmp_path, "shot_conti_light", { + "contis": {}, + "lane_conti": {"SAMPLE_FIXTURE_TAG": { + "lane": "map_marker", "status": "ok", + "image_path": str(tmp_path / "SAMPLE_FIXTURE_sketch.png"), + "lane_sketch_pack": resolve_sketch_pack_version("14"), + "mannequin_chain_contract": "1"}}, + }) + + with pytest.raises(AppError) as ei: + run_still_recipe_generation( + db=None, project_id="SAMPLE_FIXTURE_PROJECT", + episode_id="SAMPLE_FIXTURE_EPISODE", + stills=[], stills_orm=[], entity_lookup={}, ref_image_map={}, + reference_svc=None, scene_ref_image_map={}, + scene_ref_asset_id_map={}, staging_map={}, scene_cp=None, + persistence_svc=None, progress=None, project_config=None, + scene_dir=tmp_path, already_done_stills=set(), + ) + assert ei.value.code == "still_recipe.lane_mannequin_chain_off"