# Review package: b07692bf..5df61906 ## Commits 5df61906 fix(review): lane_conti_only 리뷰 지적 6건 — 진입 assert·서비스 잠금 ## Files changed backend/app/services/still_recipe_service.py | 44 ++++++++++----- backend/tests/unit/test_still_recipe_bgfirst.py | 74 ++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 15 deletions(-) ## Diff diff --git a/backend/app/services/still_recipe_service.py b/backend/app/services/still_recipe_service.py index 0ce1bddb..8acc13ca 100644 --- a/backend/app/services/still_recipe_service.py +++ b/backend/app/services/still_recipe_service.py @@ -744,23 +744,25 @@ def run_still_recipe_generation( still_id: str, conti_path: Path, plate_path: Optional[Path], bg_prompt: str, conti_asset_id: Optional[str], plate_asset_id_override: Optional[str] = None, seed_path: Optional[Path] = None, seed_asset_id: Optional[str] = None, authority_kind: str = "plate", ) -> Tuple[Path, str]: - """BGFIRST2 Step1 — 플레이트를 콘티 카메라로 재투영한 인물 0 빈 - 배경 (gpt-image-2, 참조=[콘티, 플레이트] 순서 고정 — 프롬프트의 - FIRST/SECOND 지칭과 동조). + """BGFIRST2 Step1 — 콘티 카메라 기준의 인물 0 빈 배경 + (gpt-image-2). **참조 목록은 authority_kind 에 달렸다**: 위치 권위 + 사진을 쓰는 모드는 그 사진을 콘티 카메라로 재투영하므로 참조= + [콘티, 플레이트] 순서 고정(프롬프트의 FIRST/SECOND 지칭과 동조), + LANE_CONTI_ONLY 는 참조=[콘티] 1장뿐이다(아래 2026-07-26 항). 재개=records `{tag}::bgfirst_bg` 지문(프롬프트+참조 내용+엔진·팩· 계약) 일치 + **비어 있지 않은 파일**(Codex 리뷰 6) 시 재사용, mismatch=stale 아카이브 후 재생성(conti 관례). moderation= sanitizer 1회 재시도(effective_prompt 로 provenance 병록). 반환=(bg_path, bg_asset_id) — 등록(아래 _register)은 재사용 경로에서도 매번 idempotent upsert 라 file+row+fingerprint 가 함께 검증된다(Codex 리뷰 2). fix③④ full 확장: plate_path=위치 권위(플레이트/seed-bg/groupbg @@ -2296,40 +2298,50 @@ def run_still_recipe_generation( _authority_path = plate _authority_aid: Optional[str] = None _chain_seed_path: Optional[Path] = None _chain_seed_aid: Optional[str] = None _groupbg_key: Optional[str] = None if bgfirst_full_on: if structure_seed_path is not None: # complex 샷 — STRUCTURE LOOK 을 Step1 3번째 참조로 _chain_seed_path = structure_seed_path _chain_seed_aid = structure_seed_asset_id - if plate is None and seed_bg_path is not None: - _authority_kind = "seed_bg" - _authority_path = seed_bg_path - _authority_aid = seed_bg_asset_id - elif plate is None and lane_chain: + if lane_chain: # 2026-07-26 사용자 확정: lane 콘티는 마커 맵 # 1장만 보고 마네킹으로 그려진다. 배경은 그 콘티 # 자체를 i2i 로 편집해 입히고 외부 사진은 쓰지 # 않는다(사전 배경 플레이트 금지 — 미리 만든 빈 # 배경과 콘티 구도가 어긋나 구조물이 겹쳐 보이던 # 실측). 장소 사실은 텍스트 권위(§4.3). - if seed_bg_path is not None: + # + # 설계 §(2) 진입 assert — 이 판정은 plate/seed_bg + # 분기보다 **앞**이어야 한다. `plate is None` 을 + # 분기 **조건**으로 쓰면, plate_map 이 lane 샷에 + # 플레이트를 물린 순간(:1657 은 체인 ON 이면 그 + # 조회를 되살린다) 조건이 조용히 빗나가 권위가 + # "plate" 로 떨어지고 Step1 참조가 [콘티, 외부 + # 플레이트] 가 된다 — 이 흐름이 없애려던 구도 + # 불일치 그 자체다. 공급되면 죽인다(fail-closed). + if plate is not None or seed_bg_path is not None: raise ValueError( - f"{LANE_CONTI_ONLY} 샷에 seed_bg 가 함께 " - "공급됨 — 단일 권위 계약 위반 " - "(fail-closed)" + f"{LANE_CONTI_ONLY} 샷에 플레이트/seed_bg " + f"가 공급됨 (plate={plate}, " + f"seed_bg={seed_bg_path}) — 사전 배경 " + "플레이트 금지 계약 위반 (fail-closed)" ) _authority_kind = LANE_CONTI_ONLY _authority_path = None _authority_aid = None + elif plate is None and seed_bg_path is not None: + _authority_kind = "seed_bg" + _authority_path = seed_bg_path + _authority_aid = seed_bg_asset_id elif ( plate is None and prev_sel is not None and lane_prev_chain_on ): # 2026-07-25 사용자 확정: prev 지휘 샷 = 직전 # 스틸이 배경 권위 — 콘티 카메라로 **재투영**해 # 구도차를 흡수한다(참조만으로는 장소가 재현되지 # 않던 실측 교정, S15sh5). _authority_kind = "prev" _authority_path = prev_sel @@ -2454,23 +2466,27 @@ def run_still_recipe_generation( # 2026-07-25: prev 지휘 샷은 무콘티 후보를 만들 수 # 없다 — LOCATION 권위 슬롯에 인물이 찍힌 prev # 스틸을 넣으면 그 인물이 복제된다 # (build_ab_branch_refs 도 prev 호출 금지 계약). # B=현행 prev 경로 그대로 두어 2택1 이 "체인 vs # 현행" 비교가 되게 한다. refs_b = refs else: _, refs_b = build_ab_branch_refs( plate=( + # "canon_master"(구 lane 권위)는 이 튜플에서 + # 뺀다 — 유일한 배정 지점이 LANE_CONTI_ONLY + # 로 대체돼 서비스에서 다시 나올 수 없다. + # (bgfirst_winner_lineage 의 allowlist 는 + # 구 record 해석용으로 그대로 둔다.) _authority_path - if _authority_kind in ( - "plate", "groupbg", "canon_master") + if _authority_kind in ("plate", "groupbg") else None ), conti=conti, char_refs=char_refs, prop_refs=prop_refs, structure_seed=structure_seed_path, seed_bg=( seed_bg_path if _authority_kind == "seed_bg" else None ), prompt_version=_pack, diff --git a/backend/tests/unit/test_still_recipe_bgfirst.py b/backend/tests/unit/test_still_recipe_bgfirst.py index 0432877f..104ff535 100644 --- a/backend/tests/unit/test_still_recipe_bgfirst.py +++ b/backend/tests/unit/test_still_recipe_bgfirst.py @@ -596,27 +596,99 @@ def test_lane_conti_only_final_lineage_has_no_conti_edge(): assert bgfirst_winner_lineage( chain_won=True, authority_kind="plate", structure_seed_attached=False) == ["conti", "bgfirst_bg"] # lane_conti_only + 무콘티 승은 도달 불가 조합 → fail-closed with pytest.raises(ValueError): bgfirst_winner_lineage( chain_won=False, authority_kind=LANE_CONTI_ONLY, structure_seed_attached=False, conti_attached=False) -def test_bgfirst_refs_omit_sketch_slot_when_conti_is_none(tmp_path): +def test_bgfirst_refs_omits_sketch_slot_when_conti_is_none(tmp_path): """lane 체인 Step2 는 콘티 슬롯을 생략한다 — 배경본이 이미 배치· 장소를 담고 있어 중복이고 선 그림 참조는 스케치 선 잔류 위험이다. 비-lane 호출(콘티 실재)의 참조 순서·라벨은 불변.""" bg = tmp_path / "SAMPLE_FIXTURE_bg.png" conti = tmp_path / "SAMPLE_FIXTURE_conti.png" char = [("SAMPLE_FIXTURE_A", tmp_path / "SAMPLE_FIXTURE_a.png")] with_conti = build_bgfirst_refs( bg=bg, conti=conti, char_refs=char, prop_refs=[]) without = build_bgfirst_refs( bg=bg, conti=None, char_refs=char, prop_refs=[]) assert len(with_conti) - len(without) == 1 assert [p for _lb, p in without] == [bg, char[0][1]] assert [lb for lb, _p in with_conti][0] == [ lb for lb, _p in without][0] + + +def _service_src() -> str: + import inspect + + from app.services import still_recipe_service as svc + + return inspect.getsource(svc.run_still_recipe_generation) + + +def test_lane_chain_authority_is_entry_assert_not_branch_condition(): + """진입 assert 의 본체 — lane 판정이 plate/seed_bg 분기보다 **앞**이고, + 플레이트가 공급되면 조용히 plate 권위로 떨어지는 대신 죽는다. + + `plate is None` 을 분기 **조건**으로 두면, plate_map 이 lane 샷에 + 플레이트를 물린 순간(서비스는 체인 ON 이면 그 조회를 되살린다) 조건이 + 빗나가 권위가 "plate" 로 떨어지고 Step1 참조가 [콘티, 외부 플레이트] + 가 된다 — 이 흐름이 없애려던 구도 불일치 그 자체다. 이 상태를 실물 + 샷으로 재현하려면 LLM·이미지 생성 전 구간이 필요해, 서비스 본문의 + 분기 구조를 AST 로 잠근다(문자열 매칭보다 강한 구조 계약). + """ + import ast + + tree = ast.parse(_service_src()) + # 권위 해석 블록 = `if bgfirst_full_on:` 중 lane 판정을 품은 것 + # (같은 조건의 다른 블록이 여럿이라 조건만으로는 특정되지 않는다) + heads = [ + st + for n in ast.walk(tree) + if isinstance(n, ast.If) and ast.unparse(n.test) == "bgfirst_full_on" + for st in n.body + if isinstance(st, ast.If) and "lane_chain" in ast.unparse(st.test) + ] + assert len(heads) == 1, "lane 권위 판정 지점은 하나여야 한다" + head = heads[0] + # ① 조건이 정확히 `lane_chain` — `plate is None and lane_chain` 금지 + assert ast.unparse(head.test) == "lane_chain" + + # ② 사슬의 나머지 어디에도 lane_chain 이 없다 = lane 샷이 plate/ + # seed_bg/prev/groupbg 분기로 흘러갈 경로 자체가 없다 + node = head + while (len(node.orelse) == 1 + and isinstance(node.orelse[0], ast.If)): + node = node.orelse[0] + assert "lane_chain" not in ast.unparse(node.test) + + # ③ 분기 첫 문장이 공급 거부 가드 — 플레이트/seed_bg 가 오면 raise + guard = head.body[0] + assert isinstance(guard, ast.If) + assert ast.unparse(guard.test) == ( + "plate is not None or seed_bg_path is not None") + assert isinstance(guard.body[0], ast.Raise) + + +def test_chain_only_decided_before_b_candidate_assembly(): + """B 조립 앞 판정이 lane 샷의 생사 — 순서를 소스로 잠근다. + + 아래로 되돌리면 LANE_CONTI_ONLY 는 plate·seed_bg 가 모두 None 이라 + build_ab_branch_refs 의 "A/B 는 LOCATION 권위 필수" ValueError 로 lane + 샷이 전량 죽는데, 순수 함수 테스트만으로는 아무것도 붉어지지 않는다. + """ + src = _service_src() + assert (src.index("\n _chain_only = lane_chain") + < src.index("_, refs_b = build_ab_branch_refs(")) + + +def test_service_threads_lane_lineage_and_ref_drop(): + """lane 체인의 Step2 콘티 제거·direct edge 정정은 기본값을 되돌려도 + 테스트가 초록이라 되돌리기 쉽다 — 배선 자체를 소스로 잠근다.""" + src = _service_src() + assert "conti_attached=not lane_chain" in src + assert "conti=None if lane_chain else conti" in src