# Review package: 9364e810..473d3c1e ## Commits 473d3c1e fix(review): 대면 관계 검증 리뷰 지적 6건 — flag-off 오염 차단·비중첩 밴드 보강 88b03f41 feat(lane): geometry 카메라 대면 관계 — 선언 필드+비중첩 3분할 검증+인용 provenance ## Files changed backend/app/modules/pipeline/outdoor_lane_plan.py | 12 +- backend/app/modules/pipeline/outdoor_marker_map.py | 173 ++++++++++++++++- backend/tests/pipeline/test_outdoor_marker_map.py | 216 +++++++++++++++++++++ 3 files changed, 396 insertions(+), 5 deletions(-) ## Diff diff --git a/backend/app/modules/pipeline/outdoor_lane_plan.py b/backend/app/modules/pipeline/outdoor_lane_plan.py index 71ed7e93..692576ab 100644 --- a/backend/app/modules/pipeline/outdoor_lane_plan.py +++ b/backend/app/modules/pipeline/outdoor_lane_plan.py @@ -110,26 +110,34 @@ def build_lane_schema( } required = ["site"] + required return { "type": "object", "properties": props, "required": required, "additionalProperties": False, } -def _ws_normalize(text: str) -> str: +def ws_normalize(text: str) -> str: """연속 whitespace(개행 포함)→단일 공백. 원문 줄바꿈을 LLM 이 공백으로 - 인용하는 정상 케이스(5회차 실측 2건) 허용 — 의미 판단 없음.""" + 인용하는 정상 케이스(5회차 실측 2건) 허용 — 의미 판단 없음. + + 2026-07-26: outdoor_marker_map 의 camera_facing evidence provenance 도 + 같은 정규화를 쓴다 — 사설 이름 교차 import 를 피해 공개로 승격. + """ return re.sub(r"\s+", " ", text).strip() +# 기존 호출부·테스트 호환 별칭 (동일 객체) +_ws_normalize = ws_normalize + + def _check_evidence( label: str, ev: Any, scene_texts: Dict[int, str] ) -> List[str]: """evidence 무결성 — 공급 씬 범위 + literal 인용 실재 (Codex BLOCKING). substring 으로 의미를 판정하는 것이 아니라, LLM 이 제시한 literal 인용이 실제 원문에 존재하는지(증거 진위)만 확인하는 결정론 게이트. """ if not isinstance(ev, dict) or not (ev.get("quote_ko") or "").strip(): return [f"{label} evidence(씬 인용) 누락"] diff --git a/backend/app/modules/pipeline/outdoor_marker_map.py b/backend/app/modules/pipeline/outdoor_marker_map.py index 52568cbb..fce77dd9 100644 --- a/backend/app/modules/pipeline/outdoor_marker_map.py +++ b/backend/app/modules/pipeline/outdoor_marker_map.py @@ -3,21 +3,21 @@ LLM 은 normalized(0..1) geometry JSON 만 저작한다(합의 결정 2). 코드는 스키마 잠금·fail-closed 검증·PIL 결정론 합성만 담당 — base map PNG 는 immutable SOT(복사본에만 그림), 이미지 안에 고유명·장소명 0(슬롯 코드 E1../CAM 만). """ from __future__ import annotations import io import math import re -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional MAX_SLOTS = 6 _ALLOWED_SLOTS = frozenset(f"E{i}" for i in range(1, MAX_SLOTS + 1)) _MIN_CAMERA_DIST = 0.02 # v3 (2026-07-25 사용자 지적①): facing/시야 쐐기 degenerate 가드. # ★화각 값 자체가 아니다 — 화각은 샷별 LLM 저작(view_left/right)이고, # 여기 상수는 "방향 벡터가 성립하지 않는 입력"만 거르는 무결성 경계 # (구 PIL 콘의 고정 56° 상수 승계 금지 교훈, GEOMETRY_TEXT_VERSION v2). _MIN_FACING_DIST = 0.02 _MIN_VIEW_SPAN_RAD = math.radians(4.0) @@ -46,20 +46,29 @@ PROMPT_VERSION_MAP = { # look_target 선 하나뿐이라 화각·향한 방향이 데이터에 없었다. "3": "3.202607251321", } # anchor 저작 계약이 실리는 geometry 팩 (스키마·validator 게이트) _ANCHOR_GEOMETRY_PACKS = {"2", "3"} # v3+: 시야 쐐기·facing 저작 계약이 실리는 geometry 팩 _VIEW_GEOMETRY_PACKS = {"3"} +# 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 화. 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), @@ -71,29 +80,31 @@ _STYLE = { def resolve_prompt_version(version: str) -> str: if version not in PROMPT_VERSION_MAP: raise ValueError(f"outdoor_marker_geometry 프롬프트 버전 없음: {version}") return PROMPT_VERSION_MAP[version] def build_marker_geometry_schema( max_slots: int = MAX_SLOTS, *, include_anchors: bool = False, - include_view: bool = False, + include_view: bool = False, include_facing_relation: bool = False, ) -> Dict[str, Any]: """normalized geometry 스키마 — slot enum·0..1 bounds 잠금. include_anchors (v2+, Codex ①): per-marker landmark anchor 를 required 로 강제. False=기존 byte-identical. include_view (v3+, 2026-07-25 사용자 지적①): 시야 쐐기(camera view_left/view_right)와 피사체 facing 을 required 로 강제 — 화각·향한 방향이 저작되지 않으면 마커 맵이 그릴 데이터가 없다. + include_facing_relation (v4+): 카메라 대면 관계 선언 필드 2개를 + required 로 강제. False=기존 byte-identical. """ point = { "type": "object", "properties": { "x": {"type": "number", "minimum": 0, "maximum": 1}, "y": {"type": "number", "minimum": 0, "maximum": 1}, }, "required": ["x", "y"], "additionalProperties": False, } @@ -124,20 +135,28 @@ def build_marker_geometry_schema( "type": "string", "minLength": 8} placement_req = placement_req + ["facing", "facing_anchor_en"] cam_props["view_left"] = point cam_props["view_right"] = point cam_props["view_left_anchor_en"] = {"type": "string", "minLength": 8} cam_props["view_right_anchor_en"] = {"type": "string", "minLength": 8} cam_req = cam_req + [ "view_left", "view_right", "view_left_anchor_en", "view_right_anchor_en", ] + if include_facing_relation: + # 관계는 enum, 근거는 SHOT TEXT 원문 인용 — unspecified 는 빈 + # 문자열이어야 한다(validator 가 양방향 검증). + placement_props["camera_facing_relation"] = { + "enum": list(CAMERA_FACING_RELATIONS)} + placement_props["camera_facing_evidence"] = {"type": "string"} + placement_req = placement_req + [ + "camera_facing_relation", "camera_facing_evidence"] placement = { "type": "object", "properties": placement_props, "required": placement_req, "additionalProperties": False, } return { "type": "object", "properties": { "entity_placements": { @@ -275,37 +294,50 @@ def _check_view_wedge( "샷이 담는 피사체는 프레임 안에 있어야 한다. 카메라를 " "피사체 맞은편으로 옮기거나(origin), 시선/시야 경계를 " "피사체 쪽으로 다시 잡아라" ) except (KeyError, TypeError): pass def validate_marker_geometry( geometry: Any, *, require_anchors: bool = False, - require_view: bool = False, + require_view: bool = False, require_facing_relation: bool = False, + shot_text: str = "", ) -> List[str]: """결정론 무결성 검증 — 위반 리스트 반환 (fail-closed 재시도용). 스키마(call_structured strict)가 정상 경로를 막더라도, render 등 public 소비자가 이 validator 를 직접 신뢰하므로 자체 완결이어야 한다 (Codex Stage B N3): root/placement 타입, MAX_SLOTS 상한, slot enum, bool 좌표 배제까지 여기서 검증. require_anchors (v2+, Codex ①③): per-marker anchor(entity anchor_en, camera origin/look_target anchor)를 필수로 검증. False 여도 anchor 필드가 존재하면 형식(문자열·제어문자·구조 코드) 은 검증한다 — 오염된 선택 필드 통과 차단. require_view (v3+): 시야 쐐기(camera view_left/view_right)와 피사체 facing 을 필수로 검증. False 여도 존재하면 형식·기하 (쐐기 개폐·look_target 포함·facing 자기겹침)는 검증한다. + + require_facing_relation (v4+): per-placement 카메라 대면 관계 선언과 + 좌표의 정합 + 근거 인용 provenance 를 필수로 검증. False 여도 어느 + placement 든 camera_facing_relation 이 존재하면 enum·evidence 형식· + unspecified 규칙·좌표 정합은 검증한다 — 오염된 선택 필드 통과 차단 + (위 두 게이트와 동일 관례). + + shot_text 는 그 샷의 SHOT TEXT 원문(인용 대조용). 인용 대조는 + require_facing_relation=True 이거나 shot_text 가 실제로 공급된 + 경우에만 수행한다 — 둘 다 아니면(기본 플래그 소비자) 대조 자체가 + 성립하지 않으므로 건너뛴다. require_facing_relation=True 인데 + shot_text 미공급이면 모든 명시 relation 이 거부된다(fail-closed). """ if not isinstance(geometry, dict): return ["geometry 가 객체 아님"] violations: List[str] = [] placements = geometry.get("entity_placements") if not isinstance(placements, list): violations.append("entity_placements 가 배열 아님") placements = [] if not placements: violations.append("entity_placements 비어 있음") @@ -401,20 +433,47 @@ def validate_marker_geometry( dist = math.hypot( cam["look_target"]["x"] - cam["origin"]["x"], cam["look_target"]["y"] - cam["origin"]["y"], ) if dist < _MIN_CAMERA_DIST: violations.append( f"camera 방향 벡터 미성립 — origin≈look_target " f"(dist={dist:.4f} < {_MIN_CAMERA_DIST})") except (KeyError, TypeError): pass # 좌표 결측은 위 range 검사가 이미 보고 + # v4: 카메라 대면 관계 — 카메라 dict 와 검증 끝난 placements 를 모두 + # 확보한 뒤여야 하므로 반환 직전에 per-placement 로 돈다. + # 리뷰 지적 Important-2: anchor/view 게이트와 같은 관례로 — + # require 가 False 여도 relation 이 **존재하면** 검증한다(오염된 선택 + # 필드 통과 차단). 그렇지 않으면 v4 팩 출시 후 기본 플래그 소비자 + # (build_geometry_text_lines·render_marker_map 등)가 좌표와 어긋난 + # relation 을 그대로 통과시킨다 — 이 기능이 잡으려던 바로 그 실패다. + if require_facing_relation or any( + isinstance(pl, dict) and pl.get("camera_facing_relation") is not None + for pl in placements + ): + # 인용 대조만 조건부: 기본 플래그 소비자는 SHOT TEXT 를 갖지 + # 않아 대조가 성립하지 않는다(전량 거부 방지). 저작 경로 + # (require=True)는 shot_text 미공급 시에도 fail-closed 유지. + _provenance = require_facing_relation or bool( + (shot_text or "").strip()) + # Minor-4: cam 은 위에서 이미 정규화(비-dict → {} + 위반 기록)됐다. + # 재조회하면 같은 사실이 재시도 힌트에 두 번 실리므로 그 로컬을 + # 재사용하고, 카메라가 없으면 여기서는 조용히 건너뛴다. + if cam: + for idx, pl in enumerate(placements): + if not isinstance(pl, dict): + continue + label = f"placement[{idx}]({pl.get('slot') or '?'})" + violations.extend(_check_facing_relation( + cam, pl, label, shot_text, + check_provenance=_provenance)) return violations def render_marker_map(base_png: bytes, geometry: Dict[str, Any]) -> bytes: """(DEPRECATED — 프로덕션 호출 금지) base map 위 마커 PIL 합성. 2026-07-16 사용자 확정: 코드(PIL 등)로 이미지에 그리는 행위 절대 금지 — 마커·콘·텍스트 오버레이 전부. 시각 요소는 항상 이미지 모델 몫. geometry 는 build_geometry_text_lines(ID-free 텍스트 직렬화)로만 소비한다. 함수는 모듈 삭제 금지 원칙에 따라 보존만. @@ -787,20 +846,25 @@ def _facing_text(pl: Dict[str, Any]) -> str: return "" # ── 카메라(화면) 기준 변환 — v5 (2026-07-25 사용자 지적: 콘티에서 # 외치는 인물이 달아나는 인물 반대쪽을 봄) ───────────────────────── # 원인: geometry 는 **맵 좌표계**로 facing 을 말하는데("facing x=0.64, # y=0.46") 이미지 모델이 그것을 카메라 뷰의 좌/우로 변환하지 못한다. # 코드가 순수 기하로 화면 축에 투영해 좌/우·깊이를 병기한다(좌표 변환 # 이지 의미 판단이 아니다). 실측 대조: v8(facing 없음)=정상 작화, # v11(맵 좌표 facing 병기)=반전 — 정보 추가가 오히려 방향을 꼬았다. +# ★v4(2026-07-26)부터 이 값은 산문 임계값만이 아니다 — +# _check_facing_relation 의 away/toward/profile accept·reject 경계까지 +# 정의하는 validator 계약이다. 바꾸면 세 구간이 함께 움직여 기존 통과 +# geometry 가 거부(또는 그 반대)로 뒤집히므로 _check_facing_relation 과 +# 그 구간 테스트를 반드시 재검증할 것. _SCREEN_EPS = 0.25 def _screen_basis(cam: Dict[str, Any]): """(forward, right) 단위벡터 — 맵은 y 가 아래로 증가하므로 카메라가 북(위)을 보면 forward=(0,-1), right=(+1,0)=동쪽=화면 오른쪽.""" try: ox, oy = cam["origin"]["x"], cam["origin"]["y"] dx = cam["look_target"]["x"] - ox dy = cam["look_target"]["y"] - oy @@ -827,20 +891,123 @@ def _screen_side_text(cam: Dict[str, Any], pl: Dict[str, Any]) -> str: if depth <= 0: return "" # 카메라 뒤 — 쐐기 검사가 이미 보고 lateral = (rel[0] * right[0] + rel[1] * right[1]) / depth if lateral > 0.18: return "in the RIGHT part of the frame" if lateral < -0.18: return "in the LEFT part of the frame" return "near the CENTER of the frame" +def facing_camera_depth( + cam: Dict[str, Any], pl: Dict[str, Any], +) -> Optional[float]: + """피사체 facing 의 카메라 축 성분 (-1..+1). + + 양수 = 카메라에서 멀어지는 쪽(카메라는 등을 본다), + 음수 = 카메라 쪽으로 도는 쪽(카메라는 앞을 본다), + 0 근처 = 카메라 축에 직각(측면). + + 좌표가 없거나 facing 이 자기 위치와 같으면 None — 순수 벡터 연산이며 + 의미 판단은 하지 않는다. + """ + basis = _screen_basis(cam) + if basis is None or not isinstance(pl.get("facing"), dict): + return None + fwd, _right = basis + try: + vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"]) + except (KeyError, TypeError): + return None + norm = math.hypot(*vec) + if norm < 1e-9: + return None + return (vec[0] / norm) * fwd[0] + (vec[1] / norm) * fwd[1] + + +def _check_facing_relation( + cam: Dict[str, Any], pl: Dict[str, Any], label: str, shot_text: str, + *, check_provenance: bool = True, +) -> List[str]: + """선언된 대면 관계 ↔ 좌표 정합 + 근거 provenance (v4). + + 구간은 하나의 named epsilon(_SCREEN_EPS)으로 **완전 비중첩**이다: + away_from_camera : dep > +eps + toward_camera : dep < -eps + profile : abs(dep) <= eps + 겹치게 정의하면(예: away 를 dep>0 으로) eps=0.25·dep=0.1 이 away 와 + profile 양쪽을 통과해 보증이 무너진다. + + evidence 는 필드 존재로 통과시키지 않는다 — 명시 relation 이면 SHOT + TEXT 원문에 실재하는 인용이어야 하고, unspecified 면 비어 있어야 + 한다. substring 으로 의미를 판정하는 것이 아니라 LLM 이 제시한 인용의 + 진위만 확인하는 결정론 게이트다(outdoor_lane_plan._check_evidence 와 + 동형). + + check_provenance=False (리뷰 지적 Important-2): 인용 대조 **만** + 건너뛴다 — enum·evidence 타입·unspecified 규칙·좌표 정합은 항상 + 검증한다. 저작 경로가 아닌 소비자(build_geometry_text_lines 등)는 + SHOT TEXT 를 갖고 있지 않아 대조 자체가 성립하지 않는데, 그 이유로 + 좌표 정합 검사까지 통째로 꺼 두면 오염 relation 이 그대로 통과한다. + """ + from app.modules.pipeline.outdoor_lane_plan import ws_normalize + + rel = pl.get("camera_facing_relation") + if rel not in CAMERA_FACING_RELATIONS: + return [f"{label} camera_facing_relation 이 계약 값 아님: {rel!r}"] + ev_raw = pl.get("camera_facing_evidence") + if not isinstance(ev_raw, str): + return [f"{label} camera_facing_evidence 가 문자열 아님"] + ev = ws_normalize(ev_raw) + out: List[str] = [] + if rel == "unspecified": + if ev: + out.append( + f"{label} camera_facing_relation=unspecified 인데 " + "camera_facing_evidence 가 비어 있지 않음 — 근거만 있고 " + "선언이 없는 상태 금지" + ) + return out + if check_provenance: + if not ev: + out.append( + f"{label} camera_facing_relation={rel} 인데 " + "camera_facing_evidence(SHOT TEXT 원문 인용) 누락" + ) + elif ev not in ws_normalize(shot_text or ""): + out.append( + f"{label} camera_facing_evidence 인용이 SHOT TEXT 원문에 없음 " + f"— 원문 그대로 인용할 것: {ev!r}" + ) + dep = facing_camera_depth(cam, pl) + if dep is None: + out.append( + f"{label} facing 으로 카메라 축 성분을 계산할 수 없음 " + "(facing/좌표 결손 또는 자기 위치와 동일)" + ) + return out + ok = { + "away_from_camera": dep > _SCREEN_EPS, + "toward_camera": dep < -_SCREEN_EPS, + "profile": abs(dep) <= _SCREEN_EPS, + }[rel] + if not ok: + out.append( + f"{label} camera_facing_relation={rel} 인데 facing 좌표의 " + f"카메라 축 성분이 {dep:.3f} — 관계를 만족하지 않는다 " + f"(away>{_SCREEN_EPS}, toward<-{_SCREEN_EPS}, " + f"profile |dep|<={_SCREEN_EPS}). SHOT TEXT 가 말한 대면 " + "관계대로 facing 점을 옮길 것" + ) + return out + + def _facing_screen_text(cam: Dict[str, Any], pl: Dict[str, Any]) -> str: """피사체가 향한 쪽을 **화면 기준**으로 (순수 투영).""" basis = _screen_basis(cam) if basis is None or not isinstance(pl.get("facing"), dict): return "" fwd, right = basis try: vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"]) except (KeyError, TypeError): return "" diff --git a/backend/tests/pipeline/test_outdoor_marker_map.py b/backend/tests/pipeline/test_outdoor_marker_map.py index dcc27f30..ebe02025 100644 --- a/backend/tests/pipeline/test_outdoor_marker_map.py +++ b/backend/tests/pipeline/test_outdoor_marker_map.py @@ -966,10 +966,226 @@ def test_v9_annotate_and_check_carry_wedge_and_facing(): assert {"9", "10", "11", "12"} <= _VIEW_SKETCH_PACKS p = build_marker_annotate_prompt( geometry=_geometry_view(), prompt_version="12") assert "E1 (woman calling): circle" in p # 슬롯+설명 라벨 assert "LEFT edge cuts through" in p # 쐐기 경계 assert "facing toward the apron right of the shelter" in p check = "\n".join(build_marker_check_user_lines( _geometry_view(), require_anchors=True, require_view=True)) assert "view wedge" in check and "facing" in check + + +SAMPLE_FIXTURE_SHOT_TEXT = ( + "한쪽 발이 공중에 뜬 달리기 자세인 인물 B의 뒷모습을 배경으로, " + "두 손을 입가에 모으고 입을 크게 벌린 인물 A의 상반신." +) + + +def _fixture_geometry(relation="unspecified", evidence="", + facing=(0.88, 0.52)): + """카메라가 (0.64,0.72)에서 (0.56,0.575)를 보는 기준 배치. + + fwd 는 위-왼쪽을 향한다 — facing 을 바꿔 dep 부호를 조절한다. + """ + return { + "camera": { + "origin": {"x": 0.64, "y": 0.72}, + "look_target": {"x": 0.56, "y": 0.575}, + "origin_anchor_en": "On the roadway in front of the site.", + "look_target_anchor_en": "Inside the site by the sign.", + "view_left": {"x": 0.45, "y": 0.51}, + "view_right": {"x": 0.83, "y": 0.56}, + "view_left_anchor_en": "Inside the site left of the shelter.", + "view_right_anchor_en": "Inside the site at its right edge.", + }, + "entity_placements": [{ + "slot": "E1", + "subject_en": "running figure with one foot airborne", + "x": 0.78, "y": 0.55, + "anchor_en": "Inside the right side of the site.", + "facing": {"x": facing[0], "y": facing[1]}, + "facing_anchor_en": "Farther right inside the site.", + "camera_facing_relation": relation, + "camera_facing_evidence": evidence, + }], + "rationale_ko": "픽스처 배치.", + } + + +def test_facing_relation_schema_is_pack_gated(): + from app.modules.pipeline.outdoor_marker_map import ( + CAMERA_FACING_RELATIONS, + _FACING_RELATION_GEOMETRY_PACKS, + build_marker_geometry_schema, + ) + + assert _FACING_RELATION_GEOMETRY_PACKS == {"4"} + off = build_marker_geometry_schema( + include_anchors=True, include_view=True) + pl_off = off["properties"]["entity_placements"]["items"] + assert "camera_facing_relation" not in pl_off["properties"] + + on = build_marker_geometry_schema( + include_anchors=True, include_view=True, + include_facing_relation=True) + pl_on = on["properties"]["entity_placements"]["items"] + assert pl_on["properties"]["camera_facing_relation"]["enum"] == list( + CAMERA_FACING_RELATIONS) + assert pl_on["properties"]["camera_facing_evidence"] == { + "type": "string"} + for key in ("camera_facing_relation", "camera_facing_evidence"): + assert key in pl_on["required"] + + +def test_facing_camera_depth_sign(): + from app.modules.pipeline.outdoor_marker_map import facing_camera_depth + + g = _fixture_geometry() + cam, pl = g["camera"], g["entity_placements"][0] + dep = facing_camera_depth(cam, pl) + # facing 이 오른쪽-위: 카메라 축 성분이 살짝 카메라 쪽(음수). + # ★정확값으로 고정 — 구간(-eps0'(away) 과 'abs(dep)<=eps'(profile) 를 + 동시에 만족하던 구간 — away 는 반드시 거부돼야 한다. facing + (0.86,0.49) 픽스처가 정확히 이 밴드를 만든다. + """ + from app.modules.pipeline.outdoor_marker_map import ( + _SCREEN_EPS, + validate_marker_geometry, + ) + + def _v(relation, facing): + # facing 좌표 4종으로 네 구간을 덮는다 — dep = +0.961(>+eps) / + # +0.139(0 과 +eps 사이) / -0.211(-eps 와 0 사이) / -0.899(<-eps). + # ★+0.139 구간이 없으면 away 를 'dep>0' 으로 잘못 구현해도 나머지 + # 단언이 전부 통과한다(구 픽스처 3종의 실측 결함). + g = _fixture_geometry(relation=relation, evidence="뒷모습", + facing=facing) + return validate_marker_geometry( + g, require_anchors=True, require_view=True, + require_facing_relation=True, + shot_text=SAMPLE_FIXTURE_SHOT_TEXT) + + assert _SCREEN_EPS == 0.25 + # (0.70, 0.20) → facing 이 카메라 반대쪽으로 크게: dep > +eps + assert _v("away_from_camera", (0.70, 0.20)) == [] + assert _v("profile", (0.70, 0.20)) != [] + # (0.86, 0.49) → dep 가 0 과 +eps 사이: profile 만 통과, away 는 거부 + assert _v("profile", (0.86, 0.49)) == [] + assert _v("away_from_camera", (0.86, 0.49)) != [] + # (0.88, 0.52) → dep 가 0 근처: profile 만 통과 + assert _v("profile", (0.88, 0.52)) == [] + assert _v("away_from_camera", (0.88, 0.52)) != [] + assert _v("toward_camera", (0.88, 0.52)) != [] + # (0.80, 0.95) → facing 이 카메라 쪽으로 크게: dep < -eps + assert _v("toward_camera", (0.80, 0.95)) == [] + assert _v("profile", (0.80, 0.95)) != [] + + +def test_facing_relation_evidence_provenance_both_ways(): + from app.modules.pipeline.outdoor_marker_map import ( + validate_marker_geometry, + ) + + def _v(relation, evidence, facing=(0.70, 0.20)): + return validate_marker_geometry( + _fixture_geometry(relation=relation, evidence=evidence, + facing=facing), + require_anchors=True, require_view=True, + require_facing_relation=True, + shot_text=SAMPLE_FIXTURE_SHOT_TEXT) + + # 명시 relation + 원문에 실재하는 인용 = 통과 + assert _v("away_from_camera", "뒷모습") == [] + # 명시 relation + evidence 누락 = 거부 + assert any("evidence" in v for v in _v("away_from_camera", "")) + # 명시 relation + 원문에 없는 인용(발명) = 거부 + assert any("원문에 없음" in v + for v in _v("away_from_camera", "정면을 향해 선다")) + # unspecified + evidence 존재 = 거부 (근거 없는 선언 세탁 차단) + assert any("unspecified" in v for v in _v("unspecified", "뒷모습")) + # unspecified + 빈 evidence = 통과 (기하 검사 대상 아님) + assert _v("unspecified", "", facing=(0.88, 0.52)) == [] + + +def test_facing_relation_authoring_path_needs_shot_text(): + """리뷰 지적 Minor-6(a): require_facing_relation=True + shot_text="" + = 명시 relation 전량 거부(fail-closed). + + 다음 스텝이 저작 경로를 배선하면서 SHOT TEXT 공급을 빠뜨리면 좌표가 + 옳아도 재시도 루프를 소진한다 — 그 함정을 여기서 고정한다.""" + from app.modules.pipeline.outdoor_marker_map import ( + validate_marker_geometry, + ) + + # 좌표·인용 모두 정상인데 shot_text 만 미공급 + g = _fixture_geometry(relation="away_from_camera", evidence="뒷모습", + facing=(0.70, 0.20)) + out = validate_marker_geometry( + g, require_anchors=True, require_view=True, + require_facing_relation=True, shot_text="") + assert any("원문에 없음" in v for v in out) + # unspecified 는 인용 대조 대상이 아니므로 영향 없음 + assert validate_marker_geometry( + _fixture_geometry(facing=(0.88, 0.52)), + require_anchors=True, require_view=True, + require_facing_relation=True, shot_text="") == [] + + +def test_facing_relation_checked_even_when_flag_off(): + """리뷰 지적 Important-2: require=False·shot_text 없음이어도 존재하는 + relation 은 좌표와 대조한다(오염 선택 필드 통과 차단, anchor/view + 게이트와 동일 관례). 단 인용 대조는 성립하지 않으므로 침묵한다.""" + from app.modules.pipeline.outdoor_marker_map import ( + validate_marker_geometry, + ) + + # (a) 선언은 away_from_camera 인데 좌표는 dep=-0.211 (카메라 쪽) + contaminated = _fixture_geometry( + relation="away_from_camera", evidence="", facing=(0.88, 0.52)) + out = validate_marker_geometry(contaminated) + assert any("관계를 만족하지 않는다" in v for v in out), out + # (b) 같은 호출이 인용에 대해서는 불평하지 않는다 (SHOT TEXT 부재) + assert not any("evidence" in v or "원문에 없음" in v for v in out), out + # 좌표와 맞는 선언은 flag 없이도 통과 — 기존 소비자 무해 + assert validate_marker_geometry( + _fixture_geometry(relation="toward_camera", evidence="", + facing=(0.80, 0.95))) == [] + # relation 필드가 아예 없는 geometry = 기존 동작 byte-identical + assert validate_marker_geometry(_geometry()) == [] + assert validate_marker_geometry(_geometry_view()) == [] + + +def test_facing_relation_skipped_when_camera_invalid(): + """리뷰 지적 Minor-4/6(b): 카메라가 없거나 dict 가 아니면 대면 관계 + 검증을 건너뛴다 — camera 위반은 위 검사가 이미 보고하므로 같은 + 사실이 재시도 힌트에 두 번 실리지 않는다.""" + from app.modules.pipeline.outdoor_marker_map import ( + validate_marker_geometry, + ) + + g = _fixture_geometry(relation="away_from_camera", evidence="뒷모습") + g["camera"] = None + out = validate_marker_geometry( + g, require_facing_relation=True, + shot_text=SAMPLE_FIXTURE_SHOT_TEXT) + # 카메라 위반은 정확히 한 번 + assert [v for v in out if "camera 가 객체 아님" in v] == [ + "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)