# G3.1 Evidence/Inference 4-Field Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** scene_consistency 와 scene_detail LLM 출력에 evidence/inference 4 필드 (`source_facts` / `visual_inferences` / `creative_decisions` / `confidence`) 를 도입하여 hallucination 이 canon 으로 굳는 문제를 진단 가능하게 만든다.

**Architecture:** schema strict validation (LLM 출력 측) + post-parse contract validator + lazy backfill adapter (옛 cp 측) 3 층 방어. PNG 재생성 0 / DB schema 변화 0 / checkpoint 파일 mutation 0. 새 prompt 버전 (scene_consistency v6, scene_detail v14) 으로 옛 v5/v13 디렉토리 보존.

**Tech Stack:** Python 3 / FastAPI / pytest / JSON Schema / Gemini Pro / GPT-5.5 (LLM strict structured output via response_schema).

**Spec:** `docs/superpowers/specs/2026-05-03-g3.1-evidence-inference-design.md`

**Commit 정책:** 프로젝트 패턴 = G3.1 전체 = 1 commit. 각 Task 안의 commit step 없음. **Phase 5 의 Task 24** 가 dual review (Codex + Claude) → fix loop → APPROVED 후 단일 commit + push. Group 1/2 (G1.1, G1.2, G1.3, G2.1, G2.2 = 각 1 commit) 패턴 계승.

---

## File Structure

### Create (8 files)
- `prompts/_base/scene_consistency/6.202605031033/system.md` — v6 system prompt (v5 복사 + Evidence disclosure 섹션 추가)
- `prompts/_base/scene_consistency/6.202605031033/schema.json` — v6 schema (item required + 4 필드)
- `prompts/_base/scene_detail/14.202605031033/system.md` — v14 system prompt (v13 복사 + Evidence disclosure 섹션 추가)
- `prompts/_base/scene_detail/14.202605031033/detail_schema.json` — v14 schema (item required + 4 필드)
- `backend/app/core/steps/_evidence_helpers.py` — adapter helper module (LEGACY const, _normalize_*, assert_fresh_llm_evidence)
- `backend/tests/unit/test_evidence_normalize.py` — adapter unit tests (~30 tests)
- `backend/tests/unit/test_scene_consistency_schema_v6.py` — v6 schema validation tests (~10)
- `backend/tests/unit/test_scene_detail_schema_v14.py` — v14 schema validation tests (~10)
- `backend/tests/integration/test_evidence_consumer_wiring.py` — wiring sentinel tests (~12-18)
- `backend/tests/test_prompt_versions.py` 확장 (있으면 modify) — prompt presence regression (5)

### Modify (7 files)
- `backend/app/core/steps/scene_consistency_step.py` — `_execute` (assert_fresh) + `verify_completion` (normalize) + existing_ok filter contract_violation 추가
- `backend/app/core/steps/detail_steps.py` — `SceneDetailStep._execute` + `_user_edited` 분기 + `verify_completion` 모두 normalize/assert
- `backend/app/core/steps/scene_context_loader.py:138-176` — `_load_fixed_elements` normalize 호출
- `backend/app/core/steps/shot_dependency_t2i_step.py:88-104` — fixed_elements iter 직전 normalize
- `backend/app/modules/pipeline/t2i_review.py:241,343` — t2i_variations iter 직전 normalize
- `backend/app/services/checkpoint_sync/scene_still_normalizer.py:101-141` — t2i_vars sync 직전 normalize
- `backend/app/services/scene_checkpoint_loaders.py:240-285` — load_shot_t2i_variations 반환 직전 normalize

---

## Phase 1 — Foundation (helper module + unit tests)

### Task 1: helper module 스켈레톤 + 상수 + 빈 함수 시그니처

**Files:**
- Create: `backend/app/core/steps/_evidence_helpers.py`
- Test: `backend/tests/unit/test_evidence_normalize.py` (스켈레톤만)

- [ ] **Step 1: 모듈 파일 생성 (상수 + import 만)**

```python
# backend/app/core/steps/_evidence_helpers.py
"""G3.1 evidence/inference 4-field helpers — single source.

LLM strict schema 가 강제 못하는 contract consistency 와 옛 checkpoint
backfill 을 처리. 두 step (scene_consistency, scene_detail) 이 공유.
"""
from __future__ import annotations

import logging
from typing import Any, Dict

from app.core.errors import AppError

logger = logging.getLogger(__name__)

LEGACY_CONFIDENCE = "legacy"  # adapter-only marker. LLM strict schema 가 거부.
VALID_LLM_CONFIDENCE = ("high", "medium", "low")
VALID_STORED_CONFIDENCE = VALID_LLM_CONFIDENCE + (LEGACY_CONFIDENCE,)
EVIDENCE_FIELDS = ("source_facts", "visual_inferences", "creative_decisions")


def _normalize_evidence_fields(item: Dict[str, Any], where: str = "") -> Dict[str, Any]:
    raise NotImplementedError


def _normalize_scene_consistency_result(
    scene_result: Dict[str, Any], where: str = ""
) -> Dict[str, Any]:
    raise NotImplementedError


def _normalize_scene_detail_result(
    scene_result: Dict[str, Any], where: str = ""
) -> Dict[str, Any]:
    raise NotImplementedError


def assert_fresh_llm_evidence(item: Dict[str, Any], step_name: str) -> None:
    raise NotImplementedError
```

- [ ] **Step 2: import 검증 — `python -c "from app.core.steps._evidence_helpers import LEGACY_CONFIDENCE, EVIDENCE_FIELDS"` 통과**

```bash
cd backend && python -c "from app.core.steps._evidence_helpers import LEGACY_CONFIDENCE, EVIDENCE_FIELDS, VALID_LLM_CONFIDENCE, VALID_STORED_CONFIDENCE; print(LEGACY_CONFIDENCE, EVIDENCE_FIELDS)"
```
Expected: `legacy ('source_facts', 'visual_inferences', 'creative_decisions')`

- [ ] **Step 3: test 파일 스켈레톤 생성**

```python
# backend/tests/unit/test_evidence_normalize.py
"""G3.1 _evidence_helpers unit tests."""
import pytest
from app.core.steps._evidence_helpers import (
    LEGACY_CONFIDENCE,
    EVIDENCE_FIELDS,
    VALID_LLM_CONFIDENCE,
    VALID_STORED_CONFIDENCE,
    _normalize_evidence_fields,
    _normalize_scene_consistency_result,
    _normalize_scene_detail_result,
    assert_fresh_llm_evidence,
)


class TestConstants:
    def test_legacy_confidence_value(self):
        assert LEGACY_CONFIDENCE == "legacy"

    def test_evidence_fields_3(self):
        assert EVIDENCE_FIELDS == (
            "source_facts", "visual_inferences", "creative_decisions"
        )

    def test_valid_llm_confidence_excludes_legacy(self):
        assert "legacy" not in VALID_LLM_CONFIDENCE
        assert set(VALID_LLM_CONFIDENCE) == {"high", "medium", "low"}

    def test_valid_stored_includes_legacy(self):
        assert "legacy" in VALID_STORED_CONFIDENCE
```

- [ ] **Step 4: 상수 test 만 통과 확인 (helper 함수는 NotImplementedError 던짐 — OK)**

Run: `cd backend && pytest tests/unit/test_evidence_normalize.py::TestConstants -v`
Expected: 4 passed.

---

### Task 2: `_normalize_evidence_fields` 구현 + tests (10 cases)

**Files:**
- Modify: `backend/app/core/steps/_evidence_helpers.py` — `_normalize_evidence_fields` 본문
- Modify: `backend/tests/unit/test_evidence_normalize.py` — TestNormalizeEvidenceFields class 추가

- [ ] **Step 1: 실패 테스트 작성 (10 cases)**

```python
class TestNormalizeEvidenceFields:
    def test_all_fields_present_high_confidence_noop(self):
        item = {
            "source_facts": ["fact 1"],
            "visual_inferences": ["inf 1"],
            "creative_decisions": ["dec 1"],
            "confidence": "high",
        }
        result = _normalize_evidence_fields(item)
        assert result is item  # in-place
        assert result["confidence"] == "high"  # 보존
        assert result["source_facts"] == ["fact 1"]

    def test_all_fields_missing_becomes_legacy(self):
        item = {"description": "x"}
        _normalize_evidence_fields(item)
        assert item["source_facts"] == []
        assert item["visual_inferences"] == []
        assert item["creative_decisions"] == []
        assert item["confidence"] == LEGACY_CONFIDENCE

    def test_partial_only_source_facts_becomes_legacy(self):
        item = {"source_facts": ["fact"]}
        _normalize_evidence_fields(item)
        assert item["visual_inferences"] == []
        assert item["creative_decisions"] == []
        assert item["confidence"] == LEGACY_CONFIDENCE  # 부분 → 강제

    def test_partial_with_real_confidence_still_legacy(self):
        # confidence 만 valid + 다른 필드 누락 → 부분 → legacy
        item = {"confidence": "high"}
        _normalize_evidence_fields(item)
        assert item["confidence"] == LEGACY_CONFIDENCE

    def test_invalid_confidence_string_becomes_legacy(self):
        item = {
            "source_facts": [],
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "unknown",
        }
        _normalize_evidence_fields(item)
        assert item["confidence"] == LEGACY_CONFIDENCE

    def test_non_list_source_facts_replaced(self):
        item = {
            "source_facts": "not a list",
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "high",
        }
        _normalize_evidence_fields(item)
        assert item["source_facts"] == []
        assert item["confidence"] == LEGACY_CONFIDENCE  # 부분 강제

    def test_none_source_facts_replaced(self):
        item = {
            "source_facts": None,
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "medium",
        }
        _normalize_evidence_fields(item)
        assert item["source_facts"] == []

    def test_non_dict_input_returned_unchanged(self):
        for inp in [None, [], "string", 42]:
            assert _normalize_evidence_fields(inp) == inp

    def test_legacy_confidence_already_set_preserved(self):
        item = {
            "source_facts": [],
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "legacy",
        }
        _normalize_evidence_fields(item)
        assert item["confidence"] == "legacy"

    def test_where_param_logs_marker(self, caplog):
        import logging
        caplog.set_level(logging.DEBUG)
        item = {}
        _normalize_evidence_fields(item, where="test_caller")
        assert any("marker=legacy" in r.message and "test_caller" in r.message
                   for r in caplog.records)
```

- [ ] **Step 2: test 실행 — 모두 fail 확인 (NotImplementedError)**

Run: `cd backend && pytest tests/unit/test_evidence_normalize.py::TestNormalizeEvidenceFields -v`
Expected: 10 failed (NotImplementedError).

- [ ] **Step 3: `_normalize_evidence_fields` 구현**

```python
def _normalize_evidence_fields(item: Dict[str, Any], where: str = "") -> Dict[str, Any]:
    """G3.1 lazy backfill — 4 evidence 필드 누락 시 default 주입.

    **옛 checkpoint 전용**. 새 LLM 응답에는 호출 금지 — strict schema 가 4 필드 강제.
    이 함수가 빈자리 채우면 strict 우회 silent backfill 회귀 발생 (CRITICAL #2).

    Args:
        item: fixed_element 또는 t2i_variation dict
        where: 호출자 식별 (logging marker)

    Mutation in-place. file 안 건드림.
    """
    if not isinstance(item, dict):
        return item

    backfilled = False
    for field in EVIDENCE_FIELDS:
        if field not in item or not isinstance(item[field], list):
            item[field] = []
            backfilled = True

    if "confidence" not in item or item["confidence"] not in VALID_STORED_CONFIDENCE:
        item["confidence"] = LEGACY_CONFIDENCE
        backfilled = True
    elif backfilled:
        item["confidence"] = LEGACY_CONFIDENCE

    if backfilled and where:
        logger.debug("evidence backfill marker=legacy where=%s", where)

    return item
```

- [ ] **Step 4: test 통과 확인**

Run: `cd backend && pytest tests/unit/test_evidence_normalize.py::TestNormalizeEvidenceFields -v`
Expected: 10 passed.

---

### Task 3: `_normalize_scene_consistency_result` + tests (5 cases)

**Files:**
- Modify: `backend/app/core/steps/_evidence_helpers.py`
- Modify: `backend/tests/unit/test_evidence_normalize.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
class TestNormalizeSceneConsistency:
    def test_normalize_each_fixed_element(self):
        scene = {"scene_index": 1, "fixed_elements": [{}, {"confidence": "high"}]}
        _normalize_scene_consistency_result(scene)
        # 첫 element: 모두 누락 → legacy
        assert scene["fixed_elements"][0]["confidence"] == LEGACY_CONFIDENCE
        # 둘째 element: confidence 만 → 부분 legacy
        assert scene["fixed_elements"][1]["confidence"] == LEGACY_CONFIDENCE

    def test_empty_fixed_elements_noop(self):
        scene = {"scene_index": 1, "fixed_elements": []}
        result = _normalize_scene_consistency_result(scene)
        assert result["fixed_elements"] == []

    def test_missing_fixed_elements_key_noop(self):
        scene = {"scene_index": 1, "status": "blocked_no_selection"}
        result = _normalize_scene_consistency_result(scene)
        assert "fixed_elements" not in result

    def test_malformed_fixed_elements_dict_noop(self):
        # PROBLEM #3: list 가 아니면 silent 통과 X
        scene = {"scene_index": 1, "fixed_elements": {"not": "a list"}}
        result = _normalize_scene_consistency_result(scene)
        # 비-list 는 normalize noop — caller status guard 책임
        assert result["fixed_elements"] == {"not": "a list"}

    def test_non_dict_input_returned_unchanged(self):
        assert _normalize_scene_consistency_result(None) is None
        assert _normalize_scene_consistency_result([]) == []
```

- [ ] **Step 2: 실행 — fail 확인**

Run: `pytest tests/unit/test_evidence_normalize.py::TestNormalizeSceneConsistency -v`

- [ ] **Step 3: 구현**

```python
def _normalize_scene_consistency_result(
    scene_result: Dict[str, Any], where: str = ""
) -> Dict[str, Any]:
    """fixed_elements 각 element normalize. type guard."""
    if not isinstance(scene_result, dict):
        return scene_result
    fixed = scene_result.get("fixed_elements")
    if not isinstance(fixed, list):
        return scene_result
    for fe in fixed:
        _normalize_evidence_fields(fe, where=where)
    return scene_result
```

- [ ] **Step 4: 통과 확인 — 5 passed**

---

### Task 4: `_normalize_scene_detail_result` + tests (5 cases)

**Files:**
- Modify: `backend/app/core/steps/_evidence_helpers.py`
- Modify: `backend/tests/unit/test_evidence_normalize.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
class TestNormalizeSceneDetail:
    def test_normalize_each_t2i_variation(self):
        scene = {
            "scene_index": 1,
            "t2i_variations": [
                {"variant_label": "var_1"},
                {"variant_label": "var_2", "confidence": "high"},
            ],
        }
        _normalize_scene_detail_result(scene)
        assert scene["t2i_variations"][0]["confidence"] == LEGACY_CONFIDENCE
        assert scene["t2i_variations"][1]["confidence"] == LEGACY_CONFIDENCE

    def test_empty_t2i_variations_noop(self):
        scene = {"scene_index": 1, "t2i_variations": []}
        result = _normalize_scene_detail_result(scene)
        assert result["t2i_variations"] == []

    def test_missing_t2i_variations_key_noop(self):
        scene = {"scene_index": 1, "scene_type": "normal"}
        result = _normalize_scene_detail_result(scene)
        assert "t2i_variations" not in result

    def test_malformed_t2i_variations_string_noop(self):
        # PROBLEM #3
        scene = {"scene_index": 1, "t2i_variations": "not a list"}
        result = _normalize_scene_detail_result(scene)
        assert result["t2i_variations"] == "not a list"

    def test_non_dict_input_returned_unchanged(self):
        assert _normalize_scene_detail_result(None) is None
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: 구현 (대칭)**

```python
def _normalize_scene_detail_result(
    scene_result: Dict[str, Any], where: str = ""
) -> Dict[str, Any]:
    """t2i_variations 각 variation normalize. type guard."""
    if not isinstance(scene_result, dict):
        return scene_result
    vars_ = scene_result.get("t2i_variations")
    if not isinstance(vars_, list):
        return scene_result
    for var in vars_:
        _normalize_evidence_fields(var, where=where)
    return scene_result
```

- [ ] **Step 4: 통과 확인 — 5 passed**

---

### Task 5: `assert_fresh_llm_evidence` + tests (8 cases)

**Files:**
- Modify: `backend/app/core/steps/_evidence_helpers.py`
- Modify: `backend/tests/unit/test_evidence_normalize.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
from app.core.errors import AppError

class TestAssertFreshLLMEvidence:
    def test_high_confidence_with_facts_passes(self):
        item = {
            "source_facts": ["fact"],
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "high",
        }
        # raise X
        assert_fresh_llm_evidence(item, "scene_consistency")

    def test_low_confidence_empty_facts_passes(self):
        item = {
            "source_facts": [],
            "visual_inferences": ["inf"],
            "creative_decisions": [],
            "confidence": "low",
        }
        assert_fresh_llm_evidence(item, "scene_consistency")

    def test_legacy_confidence_rejected(self):
        item = {
            "source_facts": ["x"],
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "legacy",
        }
        with pytest.raises(AppError) as exc:
            assert_fresh_llm_evidence(item, "scene_consistency")
        assert exc.value.code == "step.contract_violation"
        assert "legacy" in exc.value.message

    def test_empty_source_facts_high_confidence_rejected(self):
        item = {
            "source_facts": [],
            "visual_inferences": ["inf"],
            "creative_decisions": [],
            "confidence": "high",
        }
        with pytest.raises(AppError) as exc:
            assert_fresh_llm_evidence(item, "scene_consistency")
        assert exc.value.code == "step.contract_violation"
        assert "source_facts" in exc.value.message

    def test_empty_source_facts_medium_confidence_rejected(self):
        item = {
            "source_facts": [],
            "visual_inferences": ["inf"],
            "creative_decisions": [],
            "confidence": "medium",
        }
        with pytest.raises(AppError):
            assert_fresh_llm_evidence(item, "scene_detail")

    def test_all_lists_empty_low_confidence_rejected(self):
        item = {
            "source_facts": [],
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "high",  # low 가 아니면 violation (둘 다 검증)
        }
        with pytest.raises(AppError) as exc:
            assert_fresh_llm_evidence(item, "scene_detail")
        assert exc.value.code == "step.contract_violation"

    def test_all_lists_empty_low_passes(self):
        # 4 lists 비어도 confidence=low 면 정직한 신호 OK
        item = {
            "source_facts": [],
            "visual_inferences": [],
            "creative_decisions": [],
            "confidence": "low",
        }
        assert_fresh_llm_evidence(item, "scene_consistency")

    def test_step_name_in_error_message(self):
        item = {"source_facts": [], "visual_inferences": [],
                "creative_decisions": [], "confidence": "high"}
        with pytest.raises(AppError) as exc:
            assert_fresh_llm_evidence(item, "MY_STEP_NAME")
        assert "MY_STEP_NAME" in exc.value.message
```

- [ ] **Step 2: 실행 — fail (NotImplementedError)**

- [ ] **Step 3: 구현**

```python
def assert_fresh_llm_evidence(item: Dict[str, Any], step_name: str) -> None:
    """post-parse contract validator (IMPROVEMENT #2).

    LLM strict schema = 4 필드 존재만 강제. contract consistency 추가 검증:
      - confidence == "legacy" → reject (LLM 출력 금지)
      - source_facts == [] AND confidence != "low" → violation
      - 4 lists 모두 [] AND confidence != "low" → violation

    AppError(code="step.contract_violation") raise → step_runner retry path.
    **새 LLM 응답에만 호출**. 옛 cp 는 normalize 가 처리.
    """
    confidence = item.get("confidence")
    if confidence == LEGACY_CONFIDENCE:
        raise AppError(
            code="step.contract_violation",
            message=f"{step_name}: LLM 출력에 confidence='legacy' 금지 (adapter-only)",
            status_code=502,
        )
    sf = item.get("source_facts") or []
    vi = item.get("visual_inferences") or []
    cd = item.get("creative_decisions") or []
    if not sf and confidence != "low":
        raise AppError(
            code="step.contract_violation",
            message=f"{step_name}: source_facts=[] 인데 confidence={confidence} (low 여야 함)",
            status_code=502,
        )
    if not (sf or vi or cd) and confidence != "low":
        raise AppError(
            code="step.contract_violation",
            message=f"{step_name}: 4 lists 모두 비어있음 — confidence={confidence}",
            status_code=502,
        )
```

- [ ] **Step 4: 통과 확인 — 8 passed**

- [ ] **Step 5: Phase 1 전체 통과 확인**

Run: `cd backend && pytest tests/unit/test_evidence_normalize.py -v`
Expected: 32 passed (4 const + 10 normalize + 5 sc + 5 sd + 8 assert).

---

## Phase 2 — Schemas + System Prompts

### Task 6: scene_consistency v6 디렉토리 + schema.json + system.md 작성

**Files:**
- Create: `prompts/_base/scene_consistency/6.202605031033/schema.json`
- Create: `prompts/_base/scene_consistency/6.202605031033/system.md`

- [ ] **Step 1: 디렉토리 생성**

```bash
mkdir -p prompts/_base/scene_consistency/6.202605031033
```

- [ ] **Step 2: v5 schema 복사 + 4 필드 추가 + required 갱신**

```bash
cp prompts/_base/scene_consistency/5.202605021400/schema.json \
   prompts/_base/scene_consistency/6.202605031033/schema.json
```

그리고 fixed_elements item properties 에 4 필드 추가, required 배열 4 필드 모두 추가:

```json
{
  "type": "object",
  "properties": {
    "scene_index": {"type": "integer", "description": "씬 번호"},
    "analysis_summary": {"type": "string", "description": "교차 샷 일관성 분석 요약 (1-2문장, 한국어)"},
    "fixed_elements": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "element_id": {"type": "string", "description": "고유 ID (snake_case, e.g. dead_woman_by_door)"},
          "element_type": {"type": "string", "enum": ["character_state", "environment_state", "persistent_prop"]},
          "character_name": {"type": "string", "description": "character_state일 때 인물 이름. 그 외 빈 문자열."},
          "description": {"type": "string", "description": "고정 시각 묘사 (영어, 2-4문장, 보통명사만, 엔티티 ID 금지)"},
          "applies_to_shots": {
            "type": "array",
            "items": {"type": "integer"},
            "minItems": 2,
            "description": "이 요소가 동일하게 나타나야 할 샷 인덱스 목록 (반드시 2개 이상). 같은 인물의 전신형과 확대형은 서로 겹치지 않도록 분리."
          },
          "source_facts": {
            "type": "array",
            "items": {"type": "string"},
            "description": "원문/씬/샷에서 인용·요약한 사실. 영어 또는 원어. hallucination 방지 텍스트 근거. 사실이 없으면 빈 배열 (그러면 confidence=low 필수)."
          },
          "visual_inferences": {
            "type": "array",
            "items": {"type": "string"},
            "description": "원문에 없으나 시각화 위해 추론한 항목. source_facts 와 명확 분리."
          },
          "creative_decisions": {
            "type": "array",
            "items": {"type": "string"},
            "description": "framing/composition/atmosphere 등 작가적 선택. 사실/추론 외 모든 결정."
          },
          "confidence": {
            "type": "string",
            "enum": ["high", "medium", "low"],
            "description": "high=facts 위주 / medium=balanced / low=대부분 inference. LLM 출력은 이 3개만 — 절대 'legacy' 출력 금지 ('legacy' 는 옛 cp adapter 전용)."
          }
        },
        "required": [
          "element_id", "element_type", "character_name", "description", "applies_to_shots",
          "source_facts", "visual_inferences", "creative_decisions", "confidence"
        ],
        "additionalProperties": false
      }
    }
  },
  "required": ["scene_index", "analysis_summary", "fixed_elements"],
  "additionalProperties": false
}
```

- [ ] **Step 3: v5 system.md 복사**

```bash
cp prompts/_base/scene_consistency/5.202605021400/system.md \
   prompts/_base/scene_consistency/6.202605031033/system.md
```

- [ ] **Step 4: system.md 끝에 Evidence disclosure 섹션 추가**

system.md 끝에 추가:

```markdown

## Evidence and inference disclosure (per fixed_element)

각 fixed_element 는 4 traceability 필드를 반드시 포함한다. canon 으로 굳기 전
추론 지점을 드러내는 게 목적.

- source_facts: scene_text 또는 shot description 에서 인용·요약한 사실. 영어
  또는 원어. 사실이 없으면 빈 배열 — 그러면 confidence 는 low 여야 한다.
- visual_inferences: 원문에 없으나 시각화 위해 추론한 항목.
- creative_decisions: framing/composition/atmosphere 작가적 선택.
- confidence: "high" | "medium" | "low" — 신뢰도. **절대 "legacy" 출력 금지**
  (legacy 는 옛 checkpoint 식별자, LLM 영역 아님).
  - high: source_facts 위주, inference 보조
  - medium: facts + inference balanced
  - low: 대부분 inference, source_facts 빈약

원칙:
- source_facts 에 inference 섞지 말 것 — 텍스트 근거만.
- 같은 항목을 두 필드에 중복 기재 금지.
- 빈 배열 은 fail 이 아니다 — 정직한 신호 (confidence 로 표현).

예시:
```json
{
  "element_id": "body_full_pose",
  "element_type": "character_state",
  "character_name": "C02_woman",
  "description": "an East Asian woman lying motionless on her left side, left arm bent near the face, eyes closed",
  "applies_to_shots": [1, 3, 4],
  "source_facts": [
    "S12 action: 'A figure lies motionless on the floor.'",
    "scene heading: NIGHT, small interior room"
  ],
  "visual_inferences": [
    "left-side recumbent pose (text only specifies motionless, not orientation)"
  ],
  "creative_decisions": [
    "framing class 'full' to anchor body silhouette across selected shots"
  ],
  "confidence": "high"
}
```
```

- [ ] **Step 5: schema 로딩 검증**

```bash
cd backend && python -c "
from app.modules.prompt_loader import load_schema
schema = load_schema('scene_consistency', 'schema')
required = schema['properties']['fixed_elements']['items']['required']
assert 'source_facts' in required, required
assert 'confidence' in required, required
enum = schema['properties']['fixed_elements']['items']['properties']['confidence']['enum']
assert enum == ['high', 'medium', 'low'], enum
print('OK', enum)
"
```
Expected: `OK ['high', 'medium', 'low']`

---

### Task 7: scene_consistency v6 schema validation tests

**Files:**
- Create: `backend/tests/unit/test_scene_consistency_schema_v6.py`

- [ ] **Step 1: schema validation tests 작성 (10 cases)**

```python
"""scene_consistency v6 schema validation."""
import json
from pathlib import Path
import pytest
import jsonschema

PROMPT_DIR = Path("prompts/_base/scene_consistency/6.202605031033")
SCHEMA = json.loads((PROMPT_DIR / "schema.json").read_text())


def _valid_element():
    return {
        "element_id": "body_full_pose",
        "element_type": "character_state",
        "character_name": "C02",
        "description": "two-line description",
        "applies_to_shots": [1, 3],
        "source_facts": ["S12 action: motionless"],
        "visual_inferences": ["left-side"],
        "creative_decisions": ["framing full"],
        "confidence": "high",
    }


def _valid_scene():
    return {
        "scene_index": 12,
        "analysis_summary": "summary",
        "fixed_elements": [_valid_element()],
    }


class TestV6Schema:
    def test_valid_scene_passes(self):
        jsonschema.validate(_valid_scene(), SCHEMA)

    def test_missing_source_facts_fails(self):
        scene = _valid_scene()
        del scene["fixed_elements"][0]["source_facts"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_missing_visual_inferences_fails(self):
        scene = _valid_scene()
        del scene["fixed_elements"][0]["visual_inferences"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_missing_creative_decisions_fails(self):
        scene = _valid_scene()
        del scene["fixed_elements"][0]["creative_decisions"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_missing_confidence_fails(self):
        scene = _valid_scene()
        del scene["fixed_elements"][0]["confidence"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_legacy_confidence_rejected(self):
        scene = _valid_scene()
        scene["fixed_elements"][0]["confidence"] = "legacy"
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_unknown_confidence_rejected(self):
        scene = _valid_scene()
        scene["fixed_elements"][0]["confidence"] = "unknown"
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_all_three_enum_values_valid(self):
        for value in ["high", "medium", "low"]:
            scene = _valid_scene()
            scene["fixed_elements"][0]["confidence"] = value
            jsonschema.validate(scene, SCHEMA)

    def test_empty_lists_pass(self):
        scene = _valid_scene()
        scene["fixed_elements"][0]["source_facts"] = []
        scene["fixed_elements"][0]["visual_inferences"] = []
        scene["fixed_elements"][0]["creative_decisions"] = []
        scene["fixed_elements"][0]["confidence"] = "low"
        jsonschema.validate(scene, SCHEMA)

    def test_extra_field_rejected_by_additionalProperties_false(self):
        scene = _valid_scene()
        scene["fixed_elements"][0]["extra_field"] = "X"
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)
```

- [ ] **Step 2: 실행 — 모두 PASS (이미 schema 작성됨)**

Run: `cd backend && pytest tests/unit/test_scene_consistency_schema_v6.py -v`
Expected: 10 passed.

---

### Task 8: scene_detail v14 디렉토리 + detail_schema.json + system.md 작성

**Files:**
- Create: `prompts/_base/scene_detail/14.202605031033/detail_schema.json`
- Create: `prompts/_base/scene_detail/14.202605031033/system.md`

- [ ] **Step 1: 디렉토리 생성**

```bash
mkdir -p prompts/_base/scene_detail/14.202605031033
```

- [ ] **Step 2: v13 detail_schema 복사 + 4 필드 추가 + required 갱신**

```bash
cp prompts/_base/scene_detail/13.202605022141/detail_schema.json \
   prompts/_base/scene_detail/14.202605031033/detail_schema.json
```

그리고 t2i_variations item 의 properties + required 갱신:

```json
{
  "type": "object",
  "properties": {
    "scene_index": {"type": "integer"},
    "heading": {"type": "string"},
    "beat_title": {"type": "string", "description": "이 씬의 핵심을 한마디로"},
    "representative_moment": {"type": "string", "description": "스틸 이미지로 만들 대표 순간 (한 문장). 인물은 short_id(C01 등)로 참조."},
    "t2i_variations": {
      "type": "array",
      "description": "서로 다른 구성의 T2I 프롬프트들",
      "items": {
        "type": "object",
        "properties": {
          "variant_label": {"type": "string", "description": "var_1, var_2 등"},
          "camera_effect": {"type": "string", "description": "카메라 구도 + 색감 (필수)"},
          "t2i_prompt": {"type": "string", "description": "T2I 프롬프트. (정책 상세는 system prompt 참조)"},
          "outfit_assignments": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "character_id": {"type": "string"},
                "outlook_id": {"type": "string"}
              },
              "required": ["character_id", "outlook_id"],
              "additionalProperties": false
            }
          },
          "source_facts": {
            "type": "array",
            "items": {"type": "string"},
            "description": "scene_text 원문 + shot_extract description 만 (text-grounded). fixed_element 는 visual_inferences 에. 빈 배열이면 confidence=low 필수."
          },
          "visual_inferences": {
            "type": "array",
            "items": {"type": "string"},
            "description": "fixed_element 인용 + LLM 자체 추론. fixed_element 도 이미 canonized LLM 출력이라 source 가 아님."
          },
          "creative_decisions": {
            "type": "array",
            "items": {"type": "string"},
            "description": "camera_effect / framing 선택 등 작가적 결정."
          },
          "confidence": {
            "type": "string",
            "enum": ["high", "medium", "low"],
            "description": "LLM 출력은 high/medium/low 만 — 절대 'legacy' 금지."
          }
        },
        "required": [
          "variant_label", "camera_effect", "t2i_prompt", "outfit_assignments",
          "source_facts", "visual_inferences", "creative_decisions", "confidence"
        ],
        "additionalProperties": false
      }
    },
    "scene_type": {
      "type": "string",
      "enum": ["normal", "montage", "flashback", "dream", "voiceover", "transition"]
    },
    "dependent_scene_index": {"type": "integer"},
    "dependency_reason": {"type": "string"}
  },
  "required": [
    "scene_index", "heading", "beat_title", "representative_moment",
    "t2i_variations", "scene_type",
    "dependent_scene_index", "dependency_reason"
  ],
  "additionalProperties": false
}
```

- [ ] **Step 3: v13 system.md 복사**

```bash
cp prompts/_base/scene_detail/13.202605022141/system.md \
   prompts/_base/scene_detail/14.202605031033/system.md
```

- [ ] **Step 4: system.md 끝에 Evidence disclosure 섹션 추가**

```markdown

## Evidence and inference disclosure (per t2i_variation)

각 t2i_variation 은 4 traceability 필드를 반드시 포함한다. **단, scene_consistency 와
source 정의가 다름 — fixed_element 는 source_fact 가 아니라 visual_inference 로
분류** (이미 canonized LLM 출력이라 사실이 아님).

- source_facts: **scene_text 원문 + shot_extract description** 만 (text-grounded).
- visual_inferences: fixed_element 인용 + LLM 자체 시각 추론.
- creative_decisions: camera_effect / framing / 색감 선택.
- confidence: "high" | "medium" | "low" — 절대 "legacy" 출력 금지.
  - high: source_facts 위주, inference 보조
  - medium: facts + inference balanced
  - low: 대부분 inference, source_facts 빈약

원칙:
- fixed_element 묘사를 source_facts 에 옮기지 말 것 — 그건 inference cascade.
- source_facts 에 prompt 작성 중 추가한 묘사 섞지 말 것.
- 빈 배열 은 fail 이 아니다 — confidence=low 로 정직하게 신호.

예시:
```json
{
  "variant_label": "var_1",
  "camera_effect": "eye-level doorway composition",
  "t2i_prompt": "Photorealistic cinematic still. C01O02, an East Asian woman in dark jacket, stands at the doorway looking inside ...",
  "outfit_assignments": [{"character_id": "C01", "outlook_id": "O02"}],
  "source_facts": [
    "S12 action: 'A figure lies motionless on the floor.'",
    "shot description: observer at doorway, looking in"
  ],
  "visual_inferences": [
    "fixed_element body_full_pose: left-side recumbent",
    "low light from corridor"
  ],
  "creative_decisions": [
    "wide doorway frame to anchor body and observer in same composition",
    "cool color temperature for night mood"
  ],
  "confidence": "high"
}
```
```

- [ ] **Step 5: schema 로딩 검증**

```bash
cd backend && python -c "
from app.modules.prompt_loader import load_schema
schema = load_schema('scene_detail', 'detail_schema')
required = schema['properties']['t2i_variations']['items']['required']
assert 'source_facts' in required
assert 'confidence' in required
enum = schema['properties']['t2i_variations']['items']['properties']['confidence']['enum']
assert enum == ['high', 'medium', 'low']
print('OK', enum)
"
```
Expected: `OK ['high', 'medium', 'low']`

---

### Task 9: scene_detail v14 schema validation tests

**Files:**
- Create: `backend/tests/unit/test_scene_detail_schema_v14.py`

- [ ] **Step 1: tests 작성 (10 cases — Task 7 과 동일 패턴, t2i_variation 대상)**

```python
"""scene_detail v14 schema validation."""
import json
from pathlib import Path
import pytest
import jsonschema

PROMPT_DIR = Path("prompts/_base/scene_detail/14.202605031033")
SCHEMA = json.loads((PROMPT_DIR / "detail_schema.json").read_text())


def _valid_variation():
    return {
        "variant_label": "var_1",
        "camera_effect": "eye-level wide",
        "t2i_prompt": "prompt text",
        "outfit_assignments": [{"character_id": "C01", "outlook_id": "O02"}],
        "source_facts": ["S12 action: text"],
        "visual_inferences": ["inf"],
        "creative_decisions": ["dec"],
        "confidence": "high",
    }


def _valid_scene():
    return {
        "scene_index": 12,
        "heading": "S12. interior - night",
        "beat_title": "discovery",
        "representative_moment": "moment",
        "t2i_variations": [_valid_variation()],
        "scene_type": "normal",
        "dependent_scene_index": -1,
        "dependency_reason": "",
    }


class TestV14Schema:
    def test_valid_scene_passes(self):
        jsonschema.validate(_valid_scene(), SCHEMA)

    def test_missing_source_facts_fails(self):
        scene = _valid_scene()
        del scene["t2i_variations"][0]["source_facts"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_missing_visual_inferences_fails(self):
        scene = _valid_scene()
        del scene["t2i_variations"][0]["visual_inferences"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_missing_creative_decisions_fails(self):
        scene = _valid_scene()
        del scene["t2i_variations"][0]["creative_decisions"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_missing_confidence_fails(self):
        scene = _valid_scene()
        del scene["t2i_variations"][0]["confidence"]
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_legacy_confidence_rejected(self):
        scene = _valid_scene()
        scene["t2i_variations"][0]["confidence"] = "legacy"
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_unknown_confidence_rejected(self):
        scene = _valid_scene()
        scene["t2i_variations"][0]["confidence"] = "unknown"
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)

    def test_all_three_enum_values_valid(self):
        for value in ["high", "medium", "low"]:
            scene = _valid_scene()
            scene["t2i_variations"][0]["confidence"] = value
            jsonschema.validate(scene, SCHEMA)

    def test_empty_lists_pass(self):
        scene = _valid_scene()
        scene["t2i_variations"][0]["source_facts"] = []
        scene["t2i_variations"][0]["visual_inferences"] = []
        scene["t2i_variations"][0]["creative_decisions"] = []
        scene["t2i_variations"][0]["confidence"] = "low"
        jsonschema.validate(scene, SCHEMA)

    def test_extra_variation_field_rejected(self):
        scene = _valid_scene()
        scene["t2i_variations"][0]["extra"] = "X"
        with pytest.raises(jsonschema.ValidationError):
            jsonschema.validate(scene, SCHEMA)
```

- [ ] **Step 2: 실행 — 10 passed**

---

### Task 10: prompt presence regression tests

**Files:**
- Modify: `backend/tests/test_prompt_versions.py` (없으면 create)

- [ ] **Step 1: 파일 존재 확인**

```bash
ls backend/tests/test_prompt_versions.py 2>&1
```

없으면 create. 있으면 확장.

- [ ] **Step 2: 5 regression tests 추가**

```python
"""Prompt 버전 regression — Evidence/Inference 4-field G3.1."""
from pathlib import Path

V6_DIR = Path("prompts/_base/scene_consistency/6.202605031033")
V14_DIR = Path("prompts/_base/scene_detail/14.202605031033")


class TestEvidenceFieldsInPrompts:
    def test_scene_consistency_v6_system_has_4_fields(self):
        text = (V6_DIR / "system.md").read_text()
        for kw in ["source_facts", "visual_inferences", "creative_decisions", "confidence"]:
            assert kw in text, f"missing {kw}"

    def test_scene_consistency_v6_system_blocks_legacy_output(self):
        text = (V6_DIR / "system.md").read_text()
        assert "legacy" in text
        assert "출력 금지" in text or "출력하지" in text

    def test_scene_detail_v14_system_has_4_fields(self):
        text = (V14_DIR / "system.md").read_text()
        for kw in ["source_facts", "visual_inferences", "creative_decisions", "confidence"]:
            assert kw in text

    def test_scene_detail_v14_system_blocks_legacy_output(self):
        text = (V14_DIR / "system.md").read_text()
        assert "legacy" in text

    def test_scene_detail_v14_distinguishes_source_from_inference(self):
        # PROBLEM #2 fix: fixed_element 가 source_facts 가 아니라 visual_inferences
        text = (V14_DIR / "system.md").read_text()
        assert "fixed_element" in text and "visual_inferences" in text
```

- [ ] **Step 3: 실행 — 5 passed**

---

## Phase 3 — Producer Integration

### Task 11: scene_consistency_step._execute 에 assert_fresh_llm_evidence 호출

**Files:**
- Modify: `backend/app/core/steps/scene_consistency_step.py`

- [ ] **Step 1: 실패 테스트 작성 (`backend/tests/unit/test_scene_consistency_evidence.py` create)**

```python
"""scene_consistency_step._execute evidence integration."""
import pytest
from unittest.mock import MagicMock, patch
from app.core.errors import AppError


class TestSceneConsistencyExecuteAssertsFreshEvidence:
    def test_execute_calls_assert_fresh_llm_evidence_per_element(self):
        """LLM 응답 직후 각 element 마다 assert_fresh_llm_evidence 호출 검증."""
        from app.core.steps import scene_consistency_step as scs

        called_with = []
        def sentinel(item, step_name):
            called_with.append((item.get("element_id"), step_name))

        # _execute 안에서 import 된 helper 를 monkey patch
        with patch.object(scs, "assert_fresh_llm_evidence", side_effect=sentinel):
            # ... step instance + run with fixture LLM 응답 (2 elements)
            # (실제 fixture/factory 는 기존 테스트 패턴 follow)
            ...
        assert len(called_with) == 2
        assert all(name == "scene_consistency" for _, name in called_with)
```

(참고: 실제 fixture/factory 는 `backend/tests/unit/test_scene_consistency_*.py` 기존 패턴 follow.)

- [ ] **Step 2: 실행 — fail (호출 없음)**

- [ ] **Step 3: scene_consistency_step.py 수정**

상단 import 추가:
```python
from app.core.steps._evidence_helpers import (
    _normalize_scene_consistency_result,
    assert_fresh_llm_evidence,
)
```

`_execute` 안 LLM 응답 처리 부분 (대략 line 660-680 근처) 에 후처리 추가:

```python
# ── G3.1: post-parse contract validator ──
for element in result.get("fixed_elements", []) or []:
    assert_fresh_llm_evidence(element, "scene_consistency")
```

- [ ] **Step 4: 실행 — pass + 기존 23 scene_consistency tests 통과 확인**

Run: `pytest tests/unit/test_scene_consistency_*.py -v`
Expected: 모두 passed.

---

### Task 12: scene_consistency_step.verify_completion 에 normalize 호출

**Files:**
- Modify: `backend/app/core/steps/scene_consistency_step.py` — verify_completion 함수 (line 486-540 근처)

- [ ] **Step 1: 실패 테스트 작성**

```python
class TestSceneConsistencyVerifyCompletionNormalize:
    def test_verify_completion_normalizes_old_cp(self):
        """옛 cp (4 필드 없음) verify_completion 거치면 normalize 호출되어
        legacy 마킹 + verify pass."""
        from app.core.steps import scene_consistency_step as scs

        old_cp_data = {
            "scenes": [{
                "scene_index": 1,
                "status": "ok",
                "fixed_elements": [{
                    "element_id": "X",
                    "element_type": "character_state",
                    "character_name": "C01",
                    "description": "...",
                    "applies_to_shots": [1, 2],
                    # 4 필드 없음
                }],
            }],
        }

        # _normalize_scene_consistency_result 가 호출됐는지 sentinel
        called = []
        def sentinel(scene, where=""):
            called.append(where)
            return scene

        with patch.object(scs, "_normalize_scene_consistency_result", side_effect=sentinel):
            # SceneConsistencyStep instance + verify_completion call
            ...
        assert called  # 호출됐는지
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: verify_completion 안에서 cp 읽은 직후 normalize 호출 추가**

`verify_completion` 내부 cp 읽기 직후:

```python
# G3.1: 옛 cp 4 필드 누락 lazy backfill
for scene in cp_data.get("scenes", []):
    _normalize_scene_consistency_result(scene, where="scene_consistency.verify_completion")
```

- [ ] **Step 4: pass + 기존 회귀 0 확인**

---

### Task 13: SceneDetailStep._execute 에 assert_fresh_llm_evidence 호출

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — SceneDetailStep._execute (line 196-)

- [ ] **Step 1: 실패 테스트 작성** (`tests/unit/test_scene_detail_evidence.py` create)

```python
class TestSceneDetailExecuteAssertsFreshEvidence:
    def test_execute_calls_assert_fresh_per_variation(self):
        from app.core.steps import detail_steps as ds

        called = []
        def sentinel(item, step_name):
            called.append((item.get("variant_label"), step_name))

        with patch.object(ds, "assert_fresh_llm_evidence", side_effect=sentinel):
            # SceneDetailStep run with fixture (1 scene, 2 variations)
            ...
        assert len(called) == 2
        assert all(name == "scene_detail" for _, name in called)
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: detail_steps.py 수정**

상단 import:
```python
from app.core.steps._evidence_helpers import (
    _normalize_scene_detail_result,
    assert_fresh_llm_evidence,
)
```

SceneDetailStep._execute 안 LLM 응답 처리 부분 (line 904-1000 근처) 후처리:

```python
# G3.1: post-parse contract validator
for var in result.get("t2i_variations", []) or []:
    assert_fresh_llm_evidence(var, "scene_detail")
```

- [ ] **Step 4: pass**

---

### Task 14: SceneDetailStep `_user_edited` reuse 경로 normalize (PROBLEM #4 fix)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — line 285-297 (_user_edited 분기)

- [ ] **Step 1: 실패 테스트 작성**

```python
class TestSceneDetailUserEditedNormalize:
    def test_user_edited_old_v13_result_gets_normalized(self):
        """_user_edited 마킹된 옛 v13 cp 가 reuse 될 때 normalize 호출되어
        legacy 마킹된 결과가 새 cp 로 들어가는지 검증."""
        from app.core.steps import detail_steps as ds

        old_user_cp = {
            "scenes": [{
                "scene_index": 1,
                "_user_edited": True,
                "t2i_variations": [{
                    "variant_label": "var_1",
                    "camera_effect": "x",
                    "t2i_prompt": "y",
                    "outfit_assignments": [],
                    # 4 필드 없음 (옛 v13)
                }],
            }],
        }

        called = []
        def sentinel(scene, where=""):
            called.append(where)
            return scene

        with patch.object(ds, "_normalize_scene_detail_result", side_effect=sentinel):
            # _execute with mode that triggers _user_edited reuse path
            ...
        assert called  # 호출됐는지
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: detail_steps.py:285-297 영역에 normalize 호출 추가**

```python
# v4: 사용자 편집 보존 — _user_edited 마킹된 shot은 스킵 (현재 selection과 교차 필터)
for s in old_scene.get("t2i_variations", []):
    if s.get("_user_edited"):
        # G3.1 PROBLEM #4: _user_edited reuse 는 strict schema 우회 — 옛 cp 처럼 normalize
        ...

# 또는 reuse 결과 dict 전체에 대해
_normalize_scene_detail_result(reused_scene, where="scene_detail._user_edited")
```

(정확한 위치/패턴은 line 285-320 영역 코드 읽고 결정. _user_edited 가 true 인 element 가 새 cp 로 들어갈 때 그 element 에 normalize 호출.)

- [ ] **Step 4: pass + 기존 detail_steps 테스트 회귀 0**

---

### Task 15: SceneDetailStep.verify_completion 에 normalize 호출

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — SceneDetailStep.verify_completion (line 373-415 영역)

- [ ] **Step 1: 실패 테스트 작성** (Task 12 패턴 동일, t2i_variations 대상)

```python
class TestSceneDetailVerifyCompletionNormalize:
    def test_verify_completion_normalizes_old_cp(self):
        # SceneDetailStep verify_completion 진입 시 _normalize_scene_detail_result 호출
        ...
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: verify_completion 안 cp 읽기 직후 normalize 호출 추가**

```python
for scene in cp_data.get("scenes", []):
    _normalize_scene_detail_result(scene, where="scene_detail.verify_completion")
```

- [ ] **Step 4: pass**

---

### Task 16: existing_ok filter 에 contract_violation status retry 추가

**Files:**
- Modify: `backend/app/core/steps/scene_consistency_step.py` — existing_ok filter (G2.1 STATUS_* 영역)
- Modify: `backend/app/core/steps/detail_steps.py` — SceneDetailStep 같은 filter

- [ ] **Step 1: 실패 테스트 작성**

```python
class TestExistingOkContractViolationRetry:
    def test_status_contract_violation_triggers_retry(self):
        """existing_ok filter 가 status='contract_violation' 인 옛 cp 를 stale 로
        분류하여 retry 대상으로 처리하는지 검증."""
        from app.core.steps import scene_consistency_step as scs
        old_cp_with_violation = {
            "scenes": [{"scene_index": 1, "status": "contract_violation"}]
        }
        # filter 호출 → retry 결정 boolean 확인
        ...
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: scene_consistency_step.py 의 STATUS_* 상수 추가 + filter 수정**

```python
STATUS_CONTRACT_VIOLATION = "contract_violation"

# existing_ok filter (line 200-230 근처) — failed/blocked/violation 추가
if status in (
    STATUS_FAILED_ALL_TIERS,
    STATUS_BLOCKED_NO_SELECTION,
    STATUS_VALIDATOR_VIOLATIONS,
    STATUS_CONTRACT_VIOLATION,  # G3.1
):
    return needs_retry
```

(detail_steps.py 도 동일 패턴.)

- [ ] **Step 4: pass**

---

## Phase 4 — Consumer Wiring (4 places + sentinel tests)

### Task 17: scene_context_loader._load_fixed_elements 에 normalize 호출 + sentinel test

**Files:**
- Modify: `backend/app/core/steps/scene_context_loader.py:138-176`
- Create: `backend/tests/integration/test_evidence_consumer_wiring.py`

- [ ] **Step 1: sentinel test 작성**

```python
"""G3.1 consumer wiring sentinel tests — IMPROVEMENT #1 (G2.2 sham 회귀 방지)."""
from unittest.mock import patch


class TestSceneContextLoaderWiring:
    def test_load_fixed_elements_calls_normalize(self, monkeypatch):
        """_load_fixed_elements 가 _normalize_scene_consistency_result 호출 검증."""
        from app.core.steps import scene_context_loader as scl

        called = []
        def sentinel(scene, where=""):
            called.append(where)
            return scene

        monkeypatch.setattr(scl, "_normalize_scene_consistency_result", sentinel)

        # SceneContextLoader instance + _load_fixed_elements call with old cp
        ...
        assert called
        assert any("scene_context_loader" in w for w in called)
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: scene_context_loader.py 수정**

상단 import 추가:
```python
from app.core.steps._evidence_helpers import _normalize_scene_consistency_result
```

`_load_fixed_elements` 함수 (line 138) 의 cp 읽기 후 normalize 호출:

```python
cp = self.runner._load_prev_checkpoint("scene_consistency")
fixed_map: Dict[int, List[Dict[str, Any]]] = {}
skipped_unsafe = 0
if cp and cp.get("data", {}).get("scenes"):
    for sc in cp["data"]["scenes"]:
        # G3.1 lazy backfill — 옛 cp 4 필드 누락 normalize
        _normalize_scene_consistency_result(sc, where="scene_context_loader._load_fixed_elements")
        si = sc.get("scene_index")
        if not is_scene_result_consumer_safe(sc):
            ...
```

- [ ] **Step 4: pass**

---

### Task 18: shot_dependency_t2i_step normalize + sentinel test

**Files:**
- Modify: `backend/app/core/steps/shot_dependency_t2i_step.py:88`
- Modify: `backend/tests/integration/test_evidence_consumer_wiring.py`

- [ ] **Step 1: sentinel test 추가**

```python
class TestShotDependencyT2iWiring:
    def test_calls_normalize_scene_consistency(self, monkeypatch):
        from app.core.steps import shot_dependency_t2i_step as sdt
        called = []
        monkeypatch.setattr(
            sdt, "_normalize_scene_consistency_result",
            lambda s, where="": called.append(where) or s
        )
        # shot_dependency_t2i_step run with old cp
        ...
        assert any("shot_dependency_t2i" in w for w in called)
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: shot_dependency_t2i_step.py:88 수정 (fixed_elements iter 직전 normalize)**

```python
from app.core.steps._evidence_helpers import _normalize_scene_consistency_result

# line 88 영역
for sc in scene_consistency_cp.get("data", {}).get("scenes", []):
    _normalize_scene_consistency_result(sc, where="shot_dependency_t2i")
    for fe in sc.get("fixed_elements", []):
        ...
```

- [ ] **Step 4: pass**

---

### Task 19: t2i_review normalize + sentinel test (2 places — line 241 + 343)

**Files:**
- Modify: `backend/app/modules/pipeline/t2i_review.py:241,343`
- Modify: `backend/tests/integration/test_evidence_consumer_wiring.py`

- [ ] **Step 1: sentinel test 추가 (2 호출 지점 모두)**

```python
class TestT2iReviewWiring:
    def test_calls_normalize_at_both_iter_points(self, monkeypatch):
        from app.modules.pipeline import t2i_review
        called = []
        monkeypatch.setattr(
            t2i_review, "_normalize_scene_detail_result",
            lambda s, where="": called.append(where) or s
        )
        # t2i_review run with both code paths (line 241, 343)
        ...
        assert called.count(any("t2i_review" in w for w in called)) >= 2
        # 또는 호출 수가 2 이상
        assert len([w for w in called if "t2i_review" in w]) >= 1
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: t2i_review.py 수정 (line 241, 343 두 지점)**

```python
from app.core.steps._evidence_helpers import _normalize_scene_detail_result

# line 241 영역
for s in scene_detail_cp.get("data", {}).get("scenes", []):
    _normalize_scene_detail_result(s, where="t2i_review.iter1")
    for vi, v in enumerate(s.get("t2i_variations", [])):
        ...

# line 343 영역 — 같은 패턴
_normalize_scene_detail_result(s, where="t2i_review.iter2")
vars_list = s.get("t2i_variations", [])
```

- [ ] **Step 4: pass**

---

### Task 20: scene_still_normalizer normalize + sentinel test

**Files:**
- Modify: `backend/app/services/checkpoint_sync/scene_still_normalizer.py:101-141`
- Modify: `backend/tests/integration/test_evidence_consumer_wiring.py`

- [ ] **Step 1: sentinel test 추가**

```python
class TestSceneStillNormalizerWiring:
    def test_calls_normalize_before_db_sync(self, monkeypatch):
        from app.services.checkpoint_sync import scene_still_normalizer
        called = []
        monkeypatch.setattr(
            scene_still_normalizer, "_normalize_scene_detail_result",
            lambda s, where="": called.append(where) or s
        )
        # sync run with old cp
        ...
        assert any("scene_still_normalizer" in w for w in called)
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: scene_still_normalizer.py:101 영역 수정**

```python
from app.core.steps._evidence_helpers import _normalize_scene_detail_result

# line 101 영역
for s in scene_detail_data.get("scenes", []):
    _normalize_scene_detail_result(s, where="scene_still_normalizer")
    t2i_vars = s.get("t2i_variations", [])
    ...
```

- [ ] **Step 4: pass**

---

### Task 21: scene_checkpoint_loaders.load_shot_t2i_variations normalize + sentinel test

**Files:**
- Modify: `backend/app/services/scene_checkpoint_loaders.py:240-285`
- Modify: `backend/tests/integration/test_evidence_consumer_wiring.py`

- [ ] **Step 1: sentinel test 추가**

```python
class TestSceneCheckpointLoadersWiring:
    def test_load_shot_t2i_variations_normalizes_before_return(self, monkeypatch):
        from app.services import scene_checkpoint_loaders as scl
        called = []
        # _normalize_evidence_fields 직접 호출하는 패턴
        monkeypatch.setattr(
            scl, "_normalize_evidence_fields",
            lambda item, where="": called.append(where) or item
        )
        result = scl.load_shot_t2i_variations(...)
        assert called
        assert any("scene_checkpoint_loaders" in w for w in called)
```

- [ ] **Step 2: 실행 — fail**

- [ ] **Step 3: scene_checkpoint_loaders.py:240-285 수정**

`load_shot_t2i_variations` 함수가 list[dict] 반환하므로, 반환 직전 각 variation 에 _normalize_evidence_fields 호출:

```python
from app.core.steps._evidence_helpers import _normalize_evidence_fields

def load_shot_t2i_variations(...):
    # 기존 로직 ...
    t2i_variations = json.loads(camera_json or "{}").get("t2i_variations", [])
    if t2i_variations:
        for var in t2i_variations:
            _normalize_evidence_fields(var, where="scene_checkpoint_loaders.camera_json")
        return t2i_variations
    # fallback path
    for sc in ...:
        result_vars = sc.get("t2i_variations", [])
        for var in result_vars:
            _normalize_evidence_fields(var, where="scene_checkpoint_loaders.fallback")
        return result_vars
```

- [ ] **Step 4: pass**

---

## Phase 5 — Final Verification + Dual Review + Commit

### Task 22: 전체 회귀 테스트 통과 확인

- [ ] **Step 1: focused regression 실행**

```bash
cd backend && pytest tests/unit/test_evidence_normalize.py \
  tests/unit/test_scene_consistency_schema_v6.py \
  tests/unit/test_scene_detail_schema_v14.py \
  tests/integration/test_evidence_consumer_wiring.py \
  tests/test_prompt_versions.py \
  tests/unit/test_scene_consistency_evidence.py \
  tests/unit/test_scene_detail_evidence.py \
  -v
```
Expected: 80+ passed (Phase 1: 32 + Phase 2: 25 + Phase 3: 8 + Phase 4: 5 + 추가).

- [ ] **Step 2: 기존 G1/G2 회귀 없는지 확인 (665 baseline)**

```bash
cd backend && pytest tests/unit/test_scene_consistency*.py tests/unit/test_*_step*.py -v 2>&1 | tail -20
```
Expected: 0 fail. (이전 G2.2 = 665 passed 유지 + 본 G3.1 신규 ~80 = 총 ~745 passed)

- [ ] **Step 3: 전체 backend 테스트 한 번 — 의도치 않은 회귀 검출**

```bash
cd backend && pytest -x -q 2>&1 | tail -5
```
Expected: 0 fail.

---

### Task 23: Codex + Claude 듀얼 리뷰 (박은 정책)

- [ ] **Step 1: git diff 로 변경 사항 정리**

```bash
git status
git diff --stat
```

- [ ] **Step 2: Codex 리뷰 호출**

```bash
codex exec "다음 G3.1 evidence/inference 구현을 리뷰. CRITICAL/PROBLEMS/IMPROVEMENTS 분류. 칭찬 빼고 결함만.

Spec: docs/superpowers/specs/2026-05-03-g3.1-evidence-inference-design.md
Plan: docs/superpowers/plans/2026-05-03-g3.1-evidence-inference-implementation.md

변경 파일: <git diff --stat 결과>

핵심 검증:
- LLM 응답 = assert_fresh_llm_evidence (post-parse) / 옛 cp = _normalize_* (lazy backfill) 분리
- consumer wiring 4곳 (t2i_review/scene_still_normalizer/scene_checkpoint_loaders/shot_dependency_t2i)
- enum 3개 only (legacy 코드 const)
- contract_violation status retry
- malformed container type guard
- _user_edited 경로 normalize"
```

- [ ] **Step 3: Claude code-reviewer agent 호출 (parallel)**

Agent feature-dev:code-reviewer 에 동일 요청 dispatch — 독립 결과.

- [ ] **Step 4: 두 결과 회수 → BLOCKING 모두 fix**

iter loop 패턴 (G2.2 = 5 iter 사례). APPROVED 두 쪽 모두 받을 때까지 반복.

- [ ] **Step 5: 회귀 다시 통과 확인**

```bash
cd backend && pytest -x -q 2>&1 | tail -5
```

---

### Task 24: 단일 commit + push

- [ ] **Step 1: git add — 신규/수정 파일 명시**

```bash
git add prompts/_base/scene_consistency/6.202605031033/ \
        prompts/_base/scene_detail/14.202605031033/ \
        backend/app/core/steps/_evidence_helpers.py \
        backend/app/core/steps/scene_consistency_step.py \
        backend/app/core/steps/detail_steps.py \
        backend/app/core/steps/scene_context_loader.py \
        backend/app/core/steps/shot_dependency_t2i_step.py \
        backend/app/modules/pipeline/t2i_review.py \
        backend/app/services/checkpoint_sync/scene_still_normalizer.py \
        backend/app/services/scene_checkpoint_loaders.py \
        backend/tests/unit/test_evidence_normalize.py \
        backend/tests/unit/test_scene_consistency_schema_v6.py \
        backend/tests/unit/test_scene_detail_schema_v14.py \
        backend/tests/unit/test_scene_consistency_evidence.py \
        backend/tests/unit/test_scene_detail_evidence.py \
        backend/tests/integration/test_evidence_consumer_wiring.py \
        backend/tests/test_prompt_versions.py \
        docs/superpowers/specs/2026-05-03-g3.1-evidence-inference-design.md \
        docs/superpowers/plans/2026-05-03-g3.1-evidence-inference-implementation.md
```

- [ ] **Step 2: 단일 commit**

```bash
git commit -m "$(cat <<'EOF'
feat(pipeline): G3.1 evidence/inference 4-field — schema strict + lazy backfill

scene_consistency v6 / scene_detail v14 — fixed_element/t2i_variation 마다
4 traceability 필드 (source_facts / visual_inferences / creative_decisions /
confidence) required.

3-층 방어:
- LLM strict response_schema (4 필드 누락 reject, enum 3개 only)
- assert_fresh_llm_evidence post-parse contract validator (legacy 출력 금지,
  source_facts=[] AND confidence!=low reject 등)
- _normalize_* lazy backfill (옛 cp 만, in-place mutation, file 안 건드림)

consumer wiring 4곳: scene_context_loader, shot_dependency_t2i,
t2i_review, scene_still_normalizer, scene_checkpoint_loaders.
sentinel test 로 G2.2 sham 회귀 방지.

PROBLEM #4 fix: SceneDetailStep _user_edited reuse 도 normalize.
existing_ok filter contract_violation status retry 추가.

Spec: docs/superpowers/specs/2026-05-03-g3.1-evidence-inference-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 3: 통과 확인**

```bash
git status
git log --oneline -3
```
Expected: clean working tree, 새 commit HEAD.

- [ ] **Step 4: push**

```bash
git push origin main
```

- [ ] **Step 5: 메모리 업데이트**

`/Users/manta/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/` 에 `session_20260503_g3.1_complete.md` 추가:
- commit hash
- 신규 파일 / 수정 파일 / 테스트 개수
- 다음 세션 진입점 = G3.2 (#7 bg/fg ownership) brainstorm

`MEMORY.md` index 에 한 줄 추가.

---

## Spec coverage 자가 점검

| spec section | implementing task | 검증 |
|---|---|---|
| 3.2 schema 4 필드 (scene_consistency) | Task 6 | Task 7 v6 schema tests 10개 |
| 3.2 schema 4 필드 (scene_detail) | Task 8 | Task 9 v14 schema tests 10개 |
| 3.3 enum 3개 only | Task 6, 8 | Task 7, 9 enum 검증 / Task 10 prompt regression |
| 4.2 system.md Evidence 섹션 (sc) | Task 6 | Task 10 prompt presence test |
| 4.2 system.md Evidence 섹션 (sd) — source 정의 차이 | Task 8 | Task 10 PROBLEM #2 fix test |
| 5.1 helper module — `_normalize_evidence_fields` | Task 1, 2 | Task 2 tests 10 |
| 5.1 helper — `_normalize_scene_consistency_result` | Task 3 | Task 3 tests 5 |
| 5.1 helper — `_normalize_scene_detail_result` | Task 4 | Task 4 tests 5 |
| 5.1 helper — `assert_fresh_llm_evidence` | Task 5 | Task 5 tests 8 |
| 5.2 호출 — SceneConsistencyStep._execute (assert) | Task 11 | Task 11 sentinel test |
| 5.2 호출 — SceneConsistencyStep.verify_completion (normalize) | Task 12 | Task 12 sentinel test |
| 5.2 호출 — SceneDetailStep._execute (assert) | Task 13 | Task 13 sentinel test |
| 5.2 호출 — SceneDetailStep _user_edited (normalize) — PROBLEM #4 | Task 14 | Task 14 sentinel test |
| 5.2 호출 — SceneDetailStep.verify_completion (normalize) | Task 15 | Task 15 sentinel test |
| 5.2 호출 — scene_context_loader (normalize) | Task 17 | Task 17 sentinel test |
| 5.2 호출 — shot_dependency_t2i_step (normalize) | Task 18 | Task 18 sentinel test |
| 5.2 호출 — t2i_review (normalize) | Task 19 | Task 19 sentinel test |
| 5.2 호출 — scene_still_normalizer (normalize) | Task 20 | Task 20 sentinel test |
| 5.2 호출 — scene_checkpoint_loaders (normalize) | Task 21 | Task 21 sentinel test |
| 5.3 mutation in-place + file 보존 | Task 2 (in-place test) + Task 24 (file diff 검증) | git diff 에 cp 변경 0 |
| 6.1 fail-fast 표 (legacy LLM reject) | Task 5 (assert_fresh) + Task 7 (schema legacy reject) | Task 5, 7 tests |
| 6.1 fail-fast (source_facts=[] AND confidence != low) | Task 5 | Task 5 contract test |
| 6.1 malformed container | Task 3, 4 (type guard) | Task 3, 4 tests |
| 6.3 contract_violation existing_ok retry | Task 16 | Task 16 test |
| 7.1 testing 분류 모두 (~85-100) | Task 1-21 | Task 22 회귀 통과 |
| 7.1a wiring sentinel 패턴 | Task 17-21 | 5 wiring sentinel tests |
| 8 out-of-scope 준수 (PNG 0, DB 0, refactor 0) | Task 24 | git diff 에 PNG / DB schema / refactor 0 확인 |

모든 spec 섹션 → task 매핑 확인. gap 0.
