# Review package: 473d3c1e..834e2f40 ## Commits 834e2f40 feat(lane): outdoor_marker_geometry 팩 v4 — 카메라 대면 관계 저작 계약+selector 배선 ## Files changed backend/app/core/steps/shot_conti_light_step.py | 6 +- backend/app/modules/pipeline/outdoor_marker_map.py | 32 ++++- backend/tests/pipeline/test_outdoor_marker_map.py | 142 ++++++++++++++++++++- .../4.202607270248/system.md | 76 +++++++++++ .../4.202607270248/user_template.md | 14 ++ 5 files changed, 262 insertions(+), 8 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 396969c2..10e1c5cd 100644 --- a/backend/app/core/steps/shot_conti_light_step.py +++ b/backend/app/core/steps/shot_conti_light_step.py @@ -27,21 +27,25 @@ logger = logging.getLogger(__name__) # v3 (2026-07-17 seed-bg): 배경 권위 typed 해석(bg_source/bg_path) — # 플레이트 producer 구조적 부재 그룹의 seed-bg 승격 (Codex 합의). SCHEMA_VERSION = 3 PROMPT_VERSION = "1" # Stage D (2026-07-15): 야외 lane 분기 팩 — outdoor_lane_pipe_enabled ON 시만 # v2 (2026-07-24 Codex 합의): per-marker landmark anchor 저작 # v3 (2026-07-25 사용자 지적①): 시야 쐐기(view_left/right)·피사체 # facing 저작 — v2 는 origin→look_target 선 하나뿐이라 "카메라가 어느 # 방향으로 무엇을 찍는지"와 "엔티티가 어디를 향하는지"가 데이터에 # 아예 없었다. -LANE_GEOMETRY_PACK_VERSION = "3" +# v4 (2026-07-26): 카메라 대면 관계 선언(camera_facing_relation/ +# _evidence) — SHOT TEXT 가 '뒷모습'이라 말해도 v3 는 facing 좌표만 +# 저작돼 어긋난 방향을 잡을 수단이 없었다(실측 S15sh5). 선언을 좌표와 +# 결정론 대조하고 인용은 SHOT BLOCK 원문과 대조한다. +LANE_GEOMETRY_PACK_VERSION = "4" # v4 (2026-07-16 R4·R5): 마커 굽기 전면 제거 — 클린 맵+ID-free 텍스트 # geometry (코드로 이미지 그리기 절대 금지 준수) # v5 (2026-07-24 사용자 강질책): 마커 맵 단계 복원 — i2i 로. 클린 맵→ # 이미지 모델이 CAM·시선·엔티티 마커 작화(marker_annotate)→VLM 검증 # (marker_check)→스케치 참조=마커 맵. 코드 드로잉은 계속 금지. # v6 (Codex 합의 ②): 배치 권위=anchor/검증 통과 마커 맵 — 좌표는 # coarse estimate (캔ary 3회 실측: i2i 수치 분율 정밀도 한계). # v7 (Codex 재리뷰 ①②): geometry-text 분기 편입(BLOCKING — v6 은 # legacy 분기로 하강)+staging_geometry_head 권위 정합. v6=캔ary 소비 # 불변. diff --git a/backend/app/modules/pipeline/outdoor_marker_map.py b/backend/app/modules/pipeline/outdoor_marker_map.py index fce77dd9..45807296 100644 --- a/backend/app/modules/pipeline/outdoor_marker_map.py +++ b/backend/app/modules/pipeline/outdoor_marker_map.py @@ -38,40 +38,49 @@ PROMPT_VERSION_MAP = { # look_target anchor 분리 required(교정 축 분리), 맵 가시 피처 # 한정·타 마커/off-map 앵커 금지·코드 없는 서술. 캔ary 3회 실측: # i2i 는 수치 분율을 정밀 추종 못함 — 배치 권위=anchor. "2": "2.202607241115", # v3 (2026-07-25 사용자 지적① — "카메라가 어떤 방향으로 엔티티를 # 찍고 있는지 안 나타나고 엔티티도 제대로 표시하지 않는다"): # 시야 쐐기(camera view_left/view_right + anchor)와 피사체 facing # (facing + facing_anchor_en) 저작 추가. v2 스키마는 origin→ # look_target 선 하나뿐이라 화각·향한 방향이 데이터에 없었다. "3": "3.202607251321", + # v4 (2026-07-26): SHOT TEXT 가 말한 카메라 대면 관계를 선언 필드로 + # 저작시키는 계약(camera_facing_relation/_evidence) 추가. v3 의 + # anchor·시야 쐐기·facing stem 은 그대로 승계 — 삽입은 (1) facing + # 문단 뒤 한 곳뿐이다. + "4": "4.202607270248", } # anchor 저작 계약이 실리는 geometry 팩 (스키마·validator 게이트) -_ANCHOR_GEOMETRY_PACKS = {"2", "3"} +_ANCHOR_GEOMETRY_PACKS = {"2", "3", "4"} # v3+: 시야 쐐기·facing 저작 계약이 실리는 geometry 팩 -_VIEW_GEOMETRY_PACKS = {"3"} +_VIEW_GEOMETRY_PACKS = {"3", "4"} # v4 (2026-07-26): SHOT TEXT 가 말한 카메라 대면 관계를 선언 필드로 올려 # 좌표와 결정론 대조한다. 프롬프트 문구·rationale 만으로는 틀린 facing 을 # validator 가 잡을 수 없다 — 실측(S15sh5): SHOT TEXT 가 '뒷모습'인데 # 저작 facing 의 카메라 축 성분이 dep=-0.211(부호가 오히려 카메라 쪽) # 이었고 _SCREEN_EPS 미달로 심도 문구가 침묵해 은폐됐다. CAMERA_FACING_RELATIONS = ( "toward_camera", "away_from_camera", "profile", "unspecified") _FACING_RELATION_GEOMETRY_PACKS = {"4"} # geometry 스키마·validator 계약 버전 (Codex ④): anchor required 화 등 # 구조 변화 시 bump — config hash·sidecar 지문 스탬프 대상. # 3 (2026-07-25): view wedge·facing required 화. +# ★v4(대면 관계) 는 의도적으로 bump 하지 않았다 — 누락이 아니다. 해석된 +# 팩명이 이미 lane 지문에 geometry_pack 으로 실려 v4 를 쓰는 샷은 그것만으로 +# 무효화된다. 반면 이 상수는 팩과 무관하게 **모든** lane 샷에 스탬프되므로 +# 올리면 구 팩에 머무는 프로젝트까지 얻는 것 없이 전량 재저작한다. MARKER_GEOMETRY_CONTRACT_VERSION = 3 # 렌더 스타일 상수 — 결정론의 일부 (변경 시 canary 재검증) _STYLE = { "entity_fill": (220, 50, 50, 230), "entity_outline": (255, 255, 255, 255), "camera_color": (40, 90, 220, 255), "cone_fill": (40, 90, 220, 60), "entity_radius_frac": 0.018, "line_width_frac": 0.004, @@ -544,51 +553,63 @@ def run_marker_geometry_shot( prompt_version: str = "1", call_structured_fn=None, project_config: Dict[str, Any] | None = None, opik_metadata: Dict[str, Any] | None = None, max_attempts: int = 3, ) -> Dict[str, Any]: """샷 1개 geometry 저작 — 무결성 위반 시 위반 힌트 재시도. 반환 {"geometry": <검증 통과 결과>, "attempts": n}. 소진 시 AppError(step.contract_violation.outdoor_marker_geometry). + + v4+ 대면 관계: camera_facing_evidence 인용 대조 원문 = 프롬프트에 + 실린 SHOT BLOCK **그대로**(build_shot_block — description + 등장 + 인물 + 연출 카메라/배경 메모). LLM 이 본 텍스트와 대조 원문이 + 갈라지면(예: description 만 대조) 카메라 메모 줄에서 인용한 근거가 + '원문에 없음'으로 거부돼 재시도가 소진된다 — fail-closed 계약이라 + 미공급도 같은 결과다. """ from app.core.errors import AppError if call_structured_fn is None: from app.modules.llm.llm_client import call_structured call_structured_fn = call_structured from app.modules.pipeline.multiroll_gemini import png_part from app.modules.pipeline.outdoor_shot_grounding import ( build_legend_block, build_shot_block, ) from app.modules.prompt_loader import load_prompt resolved = resolve_prompt_version(prompt_version) system = load_prompt(_MODULE, "system", version=resolved) template = load_prompt(_MODULE, "user_template", version=resolved) _anchored = prompt_version in _ANCHOR_GEOMETRY_PACKS _viewed = prompt_version in _VIEW_GEOMETRY_PACKS + _related = prompt_version in _FACING_RELATION_GEOMETRY_PACKS schema = build_marker_geometry_schema( - include_anchors=_anchored, include_view=_viewed) + include_anchors=_anchored, include_view=_viewed, + include_facing_relation=_related) + # 인용 대조 원문은 프롬프트에 실리는 것과 **같은 객체**여야 한다 — + # 별도 인자로 받거나 여기서 다시 조립하면 두 원문이 갈라진다. + shot_block = build_shot_block(shot) filled = template for key, val in { "legend_block": build_legend_block(spec), "zones_block": "\n".join( f"- {z}" for z in spec.get("zone_labels_en", []) or [] ), "segment_label": segment_label_en, - "shot_block": build_shot_block(shot), + "shot_block": shot_block, # 씬 원문 전문 — 절대 자르지 않는다 (CLAUDE.md 절대 규칙) "scene_text_block": scene_text, }.items(): filled = filled.replace("{" + key + "}", val) base_parts = [ {"type": "text", "text": "TOP-DOWN SITE PLAN of the filming property:"}, png_part(map_png), {"type": "text", "text": filled}, ] @@ -599,21 +620,22 @@ def run_marker_geometry_shot( while attempts < max_attempts: attempts += 1 result = call_structured_fn( _MODULE, system, parts, schema, project_config=project_config, schema_name=_MODULE, opik_metadata=opik_metadata, ) violations = validate_marker_geometry( result or {}, require_anchors=_anchored, - require_view=_viewed) + require_view=_viewed, require_facing_relation=_related, + shot_text=shot_block) if not violations: return {"geometry": result, "attempts": attempts} last = violations hint = "\n".join( ["", "", "[재시도 — 직전 응답이 아래 계약을 위반했습니다. 전부", " 고쳐서 전체 결과를 다시 출력하세요:]"] + [f" - {v}" for v in violations] ) parts = base_parts + [{"type": "text", "text": hint}] diff --git a/backend/tests/pipeline/test_outdoor_marker_map.py b/backend/tests/pipeline/test_outdoor_marker_map.py index ebe02025..a977614c 100644 --- a/backend/tests/pipeline/test_outdoor_marker_map.py +++ b/backend/tests/pipeline/test_outdoor_marker_map.py @@ -687,21 +687,24 @@ def test_v2_geometry_schema_versioning_contract(): """조건 ④: geometry v2 발행·계약 상수 — run 경로가 버전으로 스키마/ 검증을 게이트.""" from app.modules.pipeline.outdoor_marker_map import ( _ANCHOR_GEOMETRY_PACKS, MARKER_ANNOTATE_CONTRACT_VERSION, MARKER_GEOMETRY_CONTRACT_VERSION, resolve_prompt_version, ) assert resolve_prompt_version("2").startswith("2.") - assert _ANCHOR_GEOMETRY_PACKS == {"2", "3"} + assert _ANCHOR_GEOMETRY_PACKS == {"2", "3", "4"} + # v4(대면 관계)는 계약 상수를 올리지 않는다 — 팩명이 이미 지문에 + # 실려 v4 샷만 무효화되고, 이 상수는 팩 무관 전량 스탬프라 구 팩 + # 프로젝트까지 헛되이 재저작시킨다 (outdoor_marker_map 주석 참조). assert MARKER_GEOMETRY_CONTRACT_VERSION == 3 assert MARKER_ANNOTATE_CONTRACT_VERSION == 3 # ── v7 (Codex 재리뷰): 스케치 조립 분기·소비자 재검증 잠금 ────────── def test_v7_sketch_prompt_uses_anchor_geometry_branch(): """BLOCKING-1 회귀: 활성 anchor 팩의 스케치 조립=geometry-text 분기 — anchor/coarse 절 포함, legacy MAP SLOT MEANINGS 부재.""" @@ -875,21 +878,21 @@ def _geometry_view(): } def test_v3_schema_and_validator_require_view_and_facing(): from app.modules.pipeline.outdoor_marker_map import ( _VIEW_GEOMETRY_PACKS, build_marker_geometry_schema, resolve_prompt_version, ) - assert _VIEW_GEOMETRY_PACKS == {"3"} + assert _VIEW_GEOMETRY_PACKS == {"3", "4"} assert resolve_prompt_version("3").startswith("3.") sch = build_marker_geometry_schema( include_anchors=True, include_view=True) cam_req = sch["properties"]["camera"]["required"] assert {"view_left", "view_right", "view_left_anchor_en", "view_right_anchor_en"} <= set(cam_req) pl_req = sch["properties"]["entity_placements"]["items"]["required"] assert {"facing", "facing_anchor_en"} <= set(pl_req) # v2 스키마(=include_view False)는 신규 필드를 요구하지 않는다 old = build_marker_geometry_schema(include_anchors=True) @@ -1182,10 +1185,145 @@ def test_facing_relation_skipped_when_camera_invalid(): "camera 가 객체 아님"] # 대면 관계 쪽에서 카메라를 다시 문제 삼지 않는다 assert not any("카메라 대면 관계를 검증할 수 없음" in v for v in out) assert not any("카메라 축 성분" in v for v in out) # camera 키 자체가 없어도 동일 (예외 아님) g2 = _fixture_geometry(relation="away_from_camera", evidence="뒷모습") g2.pop("camera") out2 = validate_marker_geometry(g2, require_facing_relation=True) assert out2 and all(isinstance(v, str) for v in out2) assert not any("카메라 축 성분" in v for v in out2) + + +# ── v4 geometry 팩 (2026-07-26): 대면 관계 저작 계약 발행·selector 배선 ── + + +def test_geometry_pack_v4_gates_facing_relation(): + from app.modules.pipeline.outdoor_marker_map import ( + _ANCHOR_GEOMETRY_PACKS, + _FACING_RELATION_GEOMETRY_PACKS, + _VIEW_GEOMETRY_PACKS, + resolve_prompt_version, + ) + + # v4 는 v3 의 anchor·view 계약을 승계하고 대면 관계를 추가한다 + assert "4" in _ANCHOR_GEOMETRY_PACKS + assert "4" in _VIEW_GEOMETRY_PACKS + assert _FACING_RELATION_GEOMETRY_PACKS == {"4"} + # v4 팩 디렉터리가 실재해야 selector 가 해석된다 + assert resolve_prompt_version("4").startswith("4.") + + +def test_geometry_pack_v4_system_declares_relation_contract(): + from app.modules.pipeline.outdoor_marker_map import ( + _MODULE, + resolve_prompt_version, + ) + from app.modules.prompt_loader import load_prompt + + sys_txt = load_prompt( + _MODULE, "system", version=resolve_prompt_version("4")) + for token in ("camera_facing_relation", "camera_facing_evidence", + "away_from_camera", "toward_camera", "profile", + "unspecified"): + assert token in sys_txt + # 발명 인용 금지 계약이 명문화돼 있어야 한다 + assert "checked against the shot text" in sys_txt + # v3 팩은 불변 — 신규 계약이 구 팩에 새지 않는다 + old_txt = load_prompt( + _MODULE, "system", version=resolve_prompt_version("3")) + assert "camera_facing_relation" not in old_txt + + +def test_run_v4_uses_shot_block_as_evidence_source(): + """★대면 관계 인용 대조 원문 = LLM 이 실제로 본 SHOT BLOCK 전체. + + build_shot_block 은 description 외에 '연출 카메라 메모' 줄도 보여준다 + — 그 줄에서 인용한 근거를 description 하고만 대조하면 원문에 없다며 + 거부돼 전 샷이 재시도 루프를 소진한다(fail-closed 계약의 함정, + test_facing_relation_authoring_path_needs_shot_text 와 짝). + """ + from app.modules.pipeline.outdoor_marker_map import ( + run_marker_geometry_shot, + ) + + # 인용 문구는 description 이 아니라 카메라 메모 줄에만 존재한다 + quote = "뒤에서 따라가는 와이드" + shot = {"scene_index": 3, "shot_index": 1, + "description": "인물이 길을 따라 멀어진다", + "camera_direction": quote} + g = _fixture_geometry(relation="away_from_camera", evidence=quote, + facing=(0.70, 0.20)) + seen = {} + + def fake(step, system, user, schema, **kw): + seen["schema"] = schema + seen["calls"] = seen.get("calls", 0) + 1 + return g + + kwargs = _run_kwargs(fake) + kwargs["shot"] = shot + out = run_marker_geometry_shot(**kwargs, prompt_version="4") + assert out["attempts"] == 1 and seen["calls"] == 1 + # 스키마 게이트도 함께 열려야 한다 (팩만 올리고 include 누락 방지) + pl = seen["schema"]["properties"]["entity_placements"]["items"] + assert {"camera_facing_relation", "camera_facing_evidence"} <= set( + pl["required"]) + + +def test_run_v4_rejects_relation_contradicted_by_coordinates(): + """v4 저작 경로가 실제로 validator 를 게이트한다 — 좌표와 어긋난 + 선언은 재시도 소진 후 계약 위반.""" + from app.core.errors import AppError + from app.modules.pipeline.outdoor_marker_map import ( + run_marker_geometry_shot, + ) + + quote = "뒷모습" + shot = {"scene_index": 3, "shot_index": 1, + "description": f"멀어지는 인물의 {quote}"} + # 선언은 away_from_camera 인데 facing 은 카메라 쪽(dep=-0.211) + bad = _fixture_geometry(relation="away_from_camera", evidence=quote, + facing=(0.88, 0.52)) + + def fake(step, system, user, schema, **kw): + return bad + + kwargs = _run_kwargs(fake) + kwargs["shot"] = shot + with pytest.raises(AppError) as ei: + run_marker_geometry_shot(**kwargs, prompt_version="4", + max_attempts=2) + assert ei.value.code == "step.contract_violation.outdoor_marker_geometry" + assert "관계를 만족하지 않는다" in ei.value.message + + +def test_run_v3_unchanged_by_v4_gate(): + """구 팩 소비는 byte-identical — relation 필드 없는 geometry 통과.""" + from app.modules.pipeline.outdoor_marker_map import ( + run_marker_geometry_shot, + ) + + seen = {} + + def fake(step, system, user, schema, **kw): + seen["schema"] = schema + return _geometry_view() + + out = run_marker_geometry_shot(**_run_kwargs(fake), prompt_version="3") + assert out["attempts"] == 1 + pl = seen["schema"]["properties"]["entity_placements"]["items"] + assert "camera_facing_relation" not in pl["properties"] + + +def test_lane_step_selector_raised_to_v4(): + """팩 발행=selector 배선까지가 한 단위 — 상수를 올리지 않으면 + v4 계약이 사문화된다(canon v3·lane_plan v4 실측 교훈).""" + from app.core.steps.shot_conti_light_step import ( + LANE_GEOMETRY_PACK_VERSION, + ) + from app.modules.pipeline.outdoor_marker_map import ( + _FACING_RELATION_GEOMETRY_PACKS, + ) + + assert LANE_GEOMETRY_PACK_VERSION == "4" + assert LANE_GEOMETRY_PACK_VERSION in _FACING_RELATION_GEOMETRY_PACKS diff --git a/prompts/_base/outdoor_marker_geometry/4.202607270248/system.md b/prompts/_base/outdoor_marker_geometry/4.202607270248/system.md new file mode 100644 index 00000000..2fef25b0 --- /dev/null +++ b/prompts/_base/outdoor_marker_geometry/4.202607270248/system.md @@ -0,0 +1,76 @@ +You are a film pre-production spatial planner working on a TOP-DOWN site +plan image (attached). Coordinate system: x runs left(0.0) to right(1.0), +y runs top(0.0) to bottom(1.0), both normalized to the image. + +For ONE shot, author the staging geometry as JSON: + +1. entity_placements: where each person/moving subject of THIS shot + stands on the map at THIS moment. Use slots E1, E2, ... in order, + one per subject. subject_en is a short GENERIC English description + ("running young woman", "parked bicycle") — NEVER a proper name. + Place subjects consistently with the scene text: distances and + directions between them are the whole point of this map. + For each placement also author anchor_en: ONE short English + sentence that places this subject relative to a fixed feature, + zone or boundary ACTUALLY VISIBLE on the attached map (for + example "on the paved apron just left of the shelter front"). + Never invent off-map terrain, never anchor to another subject or + to the camera, and never use legend codes or slot codes — name + the feature descriptively. The anchor and the x/y coordinates + must co-describe the SAME spot. + Finally author which way this subject is turned at this moment: + facing = the point on the map their body faces, and + facing_anchor_en = ONE short English sentence naming that spot + relative to a fixed visible feature, written exactly like + anchor_en. Someone running away faces where they are heading; + someone calling after another person faces that person's spot; + someone waiting faces whatever they watch. The facing point is + never the subject's own position — it always lies clearly away + from it. + Then declare how this subject faces the CAMERA you author in (2), + as camera_facing_relation: + away_from_camera - the camera sees this subject from behind + toward_camera - the camera sees this subject from the front + profile - the subject is side-on to the camera + unspecified - the shot text does not say + If the shot text states this for a subject (seen from behind, back + turned, facing the lens, in profile, ...), you MUST declare the + matching relation and place the facing point so the geometry agrees + with it: for away_from_camera the facing point lies FARTHER from the + camera origin than the subject does; for toward_camera it lies + CLOSER; for profile it lies roughly sideways, neither farther nor + nearer. A declared relation that the coordinates contradict is + rejected and you will be asked to fix it. + With any relation other than unspecified, also author + camera_facing_evidence: the exact words from the shot text that say + it, quoted verbatim in the original language. Do not paraphrase and + do not invent a quote - it is checked against the shot text. When + the relation is unspecified, camera_facing_evidence must be an empty + string. +2. camera: origin = where the camera physically stands on the map, + look_target = the point it looks at. The vector origin→look_target + is the shooting direction. Respect the shot text's framing (a wide + shot from behind means the camera is far behind the subjects). + Author origin_anchor_en and look_target_anchor_en the same way as + anchor_en — each ONE sentence relative to a visible fixed feature, + zone or boundary (the look target may be a permanent landmark or + the landmark-relative spot where a subject stands, described + generically). Each anchor co-describes the same spot as its + coordinates. + Also author how WIDE this shot sees from that position: + view_left = the point on the map cut by the LEFT edge of the + frame, view_right = the point cut by its RIGHT edge, both seen + from origin, so that origin→view_left and origin→view_right bound + a wedge with the look target inside it. Author + view_left_anchor_en and view_right_anchor_en in the same anchor + style. Choose the width from the shot text's own framing: a tight + shot on one subject opens a narrow wedge, an establishing wide + shot a broad one. Everything this shot must show has to fall + inside that wedge, and whatever falls outside it is out of frame — + so check each subject you placed against it before you answer. +3. rationale_ko: 한국어로 배치 근거를 간단히 — 감사·설명 용도이며 + 배치 권위는 각 anchor 문장에 있다. + +Anchor positions to the mapped elements in the legend (by their +descriptive names). Do not invent terrain that is not on the map. +Output strictly in the given JSON schema. diff --git a/prompts/_base/outdoor_marker_geometry/4.202607270248/user_template.md b/prompts/_base/outdoor_marker_geometry/4.202607270248/user_template.md new file mode 100644 index 00000000..f030bbb7 --- /dev/null +++ b/prompts/_base/outdoor_marker_geometry/4.202607270248/user_template.md @@ -0,0 +1,14 @@ +PLACE SPEC (mapped elements — anchor your coordinates to these): +{legend_block} + +ZONES: +{zones_block} + +PLACE SEGMENT for this shot (spatial-consistency focus): +{segment_label} + +THE SHOT (stage exactly this moment): +{shot_block} + +FULL SCENE TEXT (authoritative context — do not stage other moments): +{scene_text_block}