# scene_detail 프롬프트 최적화 — 되돌리는 법

> 2026-08-25 착수. 이 문서 하나만 보면 **어느 판으로든 한 번에 돌아갈 수 있다.**

## 왜 돌아가기가 쉬운가 — 구조상 보장되는 것

프롬프트는 **덮어쓰지 않는다.** 판마다 `prompts/_base/scene_detail/<major>.<YYYYMMDDHHmm>/`
디렉토리를 새로 만들고 옛 판은 그대로 둔다. `prompt_loader` 가 **숫자가 가장 큰
디렉토리**를 자동으로 집으므로, 되돌리기 = **버전 네 곳을 옛 값으로 되돌리는 것**
(또는 새 판 디렉토리를 치우는 것)뿐이다. 옛 프롬프트 원문은 언제나 디스크에 있다.

## ★버전은 네 곳이다

한 곳이라도 빠지면 `config_hash` 가 안 바뀌어 재실행이 「변경 없음」으로 건너뛰거나,
정렬 시험이 빨간불이 된다.

| # | 자리 | 형식 |
|---|---|---|
| 1 | `prompts/_base/scene_detail/<디렉토리>/` | `46.202608250329` |
| 2 | `backend/app/core/steps/detail_steps.py` → `SCENE_DETAIL_PROMPT_VERSION` | `"46.202608250329"` |
| 3 | `backend/app/core/version_registry.py` → `MODULE_VERSIONS["scene_detail_composer"]` | `"1.46.0"` |
| 4 | `backend/app/core/version_registry.py` → `_MODULE_INFO["scene_detail_composer"]["prompt_dependency"]` | `"scene_detail/v46"` |

확인: `cd backend && .venv/bin/python -m pytest tests/prompts tests/test_prompt_versions.py -q`
→ `test_scene_detail_version_registry_aligned` · `test_scene_detail_prompt_version_constant_matches_latest` 가 초록이어야 한다.

★실제로 당한 적 있다 — 지난 판이 1·2 만 올려 registry 가 v42 에 멈춰 v43·v44 를
놓쳤고 시험 7건이 빨간불이었다.

## shot_validator — 버전 자리가 다르다 (상수 없음)

`shot_validator` 는 `SCENE_DETAIL_PROMPT_VERSION` 같은 **상수가 없다.**
`shot_validator_step.py:138-165` 가 **프롬프트 내용을 sha 로 떠서** config_hash
에 싣기 때문에, **디렉토리만 새로 만들면 재실행이 걸린다.** 그래도
`version_registry` 두 자리는 맞춰야 정렬 시험이 초록이다.

| # | 자리 | 형식 |
|---|---|---|
| 1 | `prompts/_base/shot_validator/<디렉토리>/` | `8.202608250348` |
| 2 | `version_registry.py` → `MODULE_VERSIONS["shot_validator"]` | `"1.8.0"` |
| 3 | `version_registry.py` → `_MODULE_INFO[...]["prompt_dependency"]` | `"shot_validator/v8"` |

★**재실행 범위가 크다.** `get_all_downstream_recursive("shot_validator")`
= **71 스텝**(실측). 사용자 force 는 `step_runner.py:1202` `_execute_force`
가 cleanup + downstream invalidate cascade 를 하므로, **force 한 번에 하류
71개가 통째로 무효화된다.** 프로덕션 판올림 전에는 v8 을 shadow 로 돌려
consumer 가 보는 shot payload(description·characters·character_ids)가 실제로
달라지는 에피소드만 골라라 — field 단위 무효화가 없으므로 하나라도 다르면
그 에피소드는 통째로 다시 돌려야 한다. 배경 도면이 이미 잘못된 상태로
만들어졌으면 `background_prompt` 텍스트 → `background_render` 이미지 →
그 배경을 쓴 `scene_image_pipeline` 까지 따라간다.

## 판 이력 (최신이 위)

| 판 | 커밋 | system.md | 무엇을 했나 | 되돌릴 때 주의 |
|---|---|---|---|---|
| v46 `46.202608250329` | (측정 중) | 28,674자 | 비대 원인 ②③ — ID 규칙 다섯 절을 한 절 6단계로 통합(7,190→4,960자) + 예시 세 벌을 policy 3행 대응표로 | 절 5개가 1개로 합쳐졌다. 되돌리면 `## 엔티티 참조`·`## 인물 복장 묘사`·`## visible_entities 엄격 규칙`·`## 인물 ID 사용` 이 되살아난다 |
| v45 `45.202608250305` | `d4c7e15c` | 30,908자 | 비대 원인 ① — 카드가 값과 사용법을 둘 다 담은 산문 5절 제거(−5,853자) | 절 헤딩은 그대로. 안전한 복귀 지점 |
| v44 `44.202608250248` | `90eeefe8` | 36,761자 | 광원 상태 4행 대응표 | **품질 기준선의 원점** — 6회 실측 데이터가 있는 유일한 판 |
| v43 `43.202608250202` | `10588afd` `91a21b61` | 36,675자 | framing_scale 낱말 누출 대응표 + 발명 금지 빈틈 | |
| v42 `42.202608121750` | | 51,745자(팩 전체) | 시선·카메라 인지 기본값 + identity 참조 역할 한정 | |

## 되돌리는 절차

### A. 한 판 뒤로 (예: v46 → v45)

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1
# 1) 새 판 디렉토리를 치운다 (지우지 말고 옮긴다)
mkdir -p prompts/_base/scene_detail/_rolled_back
mv prompts/_base/scene_detail/46.202608250329 prompts/_base/scene_detail/_rolled_back/
# 2) 버전 네 곳 중 2~4 를 옛 값으로 (git 으로 되돌리는 것이 가장 안전)
git checkout <그 판의 커밋> -- backend/app/core/steps/detail_steps.py \
                                backend/app/core/version_registry.py
# 3) 확인
cd backend && .venv/bin/python -m pytest tests/prompts tests/test_prompt_versions.py -q
# 4) 재기동 (아래 D)
```

★`_rolled_back/` 안으로 옮기는 이유 = `prompt_loader` 는
`prompts/_base/scene_detail/` **바로 아래**만 훑는다. 지우지 않고 치우면 원문이 남는다.

### B. 커밋 단위로 통째 되돌리기

```bash
git revert --no-edit <커밋>     # 이력 보존 (권장)
# 또는 특정 파일만
git checkout <커밋>^ -- <파일>
```

### C. 옛 판과 지금 판을 나란히 보기

```bash
diff -u prompts/_base/scene_detail/45.202608250305/system.md \
        prompts/_base/scene_detail/46.202608250329/system.md
```

### C-2. ★판을 올리거나 되돌린 뒤 `resume` 은 **거부한다**

프롬프트를 바꾸면 그 스텝의 `config_hash` 가 달라져서, `run-all?mode=resume`
이 그 자리에서 **fail-fast** 한다(실측 로그):

```
Step shot_validator error: shot_validator contract drift detected —
config_hash mismatch (step-local): cp=8b97706…, current=7f4e18f….
force 재실행 또는 수동 진단 필요.
```

올바른 동작이다 — 옛 지문으로 만든 산출을 새 프롬프트의 것인 양 쓰지 않는다.
**바뀐 스텝을 먼저 `mode=force` 로 한 번 열고**, 그다음 run-all 을 건다.

```bash
# 1) 바뀐 스텝만 force (하류가 함께 무효화된다)
curl -b /tmp/ck.txt -X POST ".../steps/<바뀐스텝>?mode=force"
# 2) 완료를 기다린 뒤 전체
curl -b /tmp/ck.txt -X POST ".../steps/run-all?mode=resume&category=all&approve_image_generation=true&image_call_cap=300"
```

★`category=analysis` 만으로는 안 될 수 있다 — 하류 무효화가 이미지 쪽
선행 스텝(`floor_plan_render` · `outdoor_place_canon`)까지 걸리면
`dispatch.deps_incomplete` 로 막힌다. 그때는 `category=all`.

### D. 재기동 (되돌린 뒤 **반드시**)

상수는 모듈 전역이라 **재기동 전에는 옛 판이 계속 나간다.**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend
kill $(pgrep -f "uvicorn app.main:app" | head -1); sleep 5
nohup ./.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 >> uvicorn.log 2>&1 &
sleep 25
curl -s http://localhost:8000/api/v1/health          # status=ok · db=ok
.venv/bin/python -c "from app.modules.prompt_loader import _list_module_versions, load_prompt; \
  print(_list_module_versions('scene_detail')[0], len(load_prompt('scene_detail','system')))"
```

★**끝점에서 확인한다** — 「상수를 되돌린 것」과 「도는 프로세스가 그 프롬프트를
보내는 것」은 다르다. 확실한 증거는 Opik 에 남은 system 메시지의 sha 다
(`scratchpad/` 의 hash_sys 계열 도구).

## ★새 판을 만들어 두고 **아직 안 쓰이게** 하는 법

`prompt_loader` 는 `prompts/_base/<모듈>/` **바로 아래**의 숫자 큰 디렉토리를
집는다. 그러니 새 판을 만들되 아직 태우고 싶지 않으면 **한 층 안으로 넣는다**:

```bash
mkdir -p prompts/_base/scene_detail/_staged
mv prompts/_base/scene_detail/47.202608250422 prompts/_base/scene_detail/_staged/
# 확인 — 로더가 여전히 옛 판을 집어야 한다
cd backend && .venv/bin/python -c "from app.modules.prompt_loader import _list_module_versions as v; print(v('scene_detail')[0])"
```

쓸 때가 되면 도로 꺼내고 버전 네 곳을 올린다. 주행 중에 새 판을 만들어야
할 때 이렇게 하면 **도는 판과 상수가 어긋나는 사고**를 막을 수 있다
(실제로 E2E 중에 v47 을 만들어야 해서 이 방법을 썼다).

## 무엇이 옳은지 판정하는 자 — 측정 도구

| 도구 | 무엇을 재나 | 비용 |
|---|---|---|
도구는 `backend/tools/prompt_measure/` 에 있다(README 참조).

| `measure_scene_detail.py` | 산출 품질 — **입력 근거를 대조한 뒤에** 위반으로 센다 | 유료(`--run N`) / 무료(재해석) |
| `id_axes.py` | 재시도 발생·복합 ID 누락·목록 밖 ID·미허용 아웃룩 짝 | 무료 |
| `edition_hash.py` | 나간 system 메시지를 sha 로 묶어 판을 가른다 | 무료 |
| `find_origin.py` | 어떤 문구를 **출력에 처음 쓴** span — 왜곡의 발원지 | 무료 |
| `probe_shot_validator.py` | 표에 없는 변화로 프롬프트를 태우는 unseen probe | 유료 |
| `ab_scene_detail.py` | 두 판을 ABBA 로 비교 — **실제 나간 payload 를 그대로** 쓰고 system 만 갈아 끼운다 | 유료 |

★`ab_scene_detail.py` 가 왜 payload 를 재사용하나 — `scene_detail` 의 user
메시지는 카드 JSON·엔티티·아웃룩·고정 요소·촬영 감독 블록이 겹겹이 조립된
것이라, 시험용으로 다시 짜면 **프로덕션보다 단순해져 있는 문제를 숨긴다.**

★**source-positive / source-negative 를 나눠 재라.** 입력에 그 낱말이 있는
컷에서 줄어야 「소비 쪽 수리가 통했다」이고, 없는 컷에 남는 것이 「진짜
발명」이다. 합쳐서 「R1 몇 건」이라고 하면 무엇이 고쳐졌는지 알 수 없다.
실측: 새 주행에서 R1 이 0/6 이었는데 그 6컷 입력에 등급 낱말이 0/6 이었다 —
샐 것이 없어서 0 이지 고쳐져서 0 이 아니었다.

★**한 번 재고 판정하지 마라.** 같은 프롬프트로도 답이 흔들린다.
★**기준선을 덜 잡지 마라.** v44 는 6회 돌았는데 3회만 기준선으로 잡았다가
없는 퇴행 2건을 만들어 냈다.

## Opik

로컬이 아니다 — `http://192.168.133.87:5173`, 프로젝트 `theroad-scene-lab`.
측정 스크립트는 `scratchpad/_opik_env.py` 의 `opik_target()` 을 쓴다
(`config.py` 의 `env_file=".env"` 가 상대 경로라 cwd 가 backend 가 아니면
설정이 통째로 비고 localhost 로 조용히 떨어진다).

## 검증용 최소 시나리오

프로젝트 `da049582-2c6d-492c-979d-f468d61bab6e` ·
에피소드 `fb7a883f-baac-4145-9131-732ce628d474` — 3씬 6샷.
