# Short ID 체계 구현 플랜

> **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:** 모든 엔티티에 short_id(C01/L01/P01/O01)를 부여하고, LLM 통신에서 이름 매칭을 완전히 제거한다.

**Architecture:** entity_canon.short_id 컬럼 추가 → 엔티티 생성 시 자동 발급 → 모든 분석 단계에서 short_id + enum 사용 → 이미지 서비스에서 short_id 파싱 → API/UI에서 short_id ↔ 이름 변환

**Tech Stack:** Python/FastAPI, PostgreSQL, LiteLLM, React/TypeScript

**Spec:** `docs/superpowers/specs/2026-03-23-short-id-design.md`

**커밋 규칙:** 모든 커밋 메시지에 `short-id:` 접두어 사용

**리뷰 규칙:** 각 Task 완료 시 Claude + Codex 교차 리뷰

---

## Phase 1: DB + 발급 로직 (선행 — 나머지 모두 의존)

### Task 1: DB 마이그레이션 — entity_canon.short_id 컬럼

**Files:**
- Modify: `backend/app/models/project.py:27-39`
- Modify: `backend/app/core/database.py:33-65`

- [ ] **Step 1: 모델에 short_id 컬럼 추가**

`backend/app/models/project.py` EntityCanon 클래스:
```python
short_id = Column(Text)  # C01, L03, O05 등
```

- [ ] **Step 2: 마이그레이션 SQL 추가**

`backend/app/core/database.py` `init_db()` migrations 배열:
```python
"ALTER TABLE entity_canon ADD COLUMN IF NOT EXISTS short_id TEXT",
"CREATE UNIQUE INDEX IF NOT EXISTS uq_entity_canon_short_id ON entity_canon(project_id, short_id) WHERE short_id IS NOT NULL",
```

- [ ] **Step 3: 기존 데이터 마이그레이션 SQL**

```python
"""
WITH ranked AS (
    SELECT id, entity_type,
        ROW_NUMBER() OVER (PARTITION BY project_id, entity_type ORDER BY created_at) as rn
    FROM entity_canon
    WHERE short_id IS NULL
)
UPDATE entity_canon SET short_id =
    CASE ranked.entity_type
        WHEN 'character' THEN 'C' || LPAD(ranked.rn::text, 2, '0')
        WHEN 'location' THEN 'L' || LPAD(ranked.rn::text, 2, '0')
        WHEN 'prop' THEN 'P' || LPAD(ranked.rn::text, 2, '0')
        WHEN 'outlook' THEN 'O' || LPAD(ranked.rn::text, 2, '0')
    END
FROM ranked WHERE entity_canon.id = ranked.id
""",
```

- [ ] **Step 4: 서버 재시작 후 기존 데이터 확인**

```bash
cd backend && source .venv/bin/activate
python3 -c "
from app.core.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
rows = db.execute(text('SELECT short_id, name, entity_type FROM entity_canon WHERE short_id IS NOT NULL LIMIT 10')).fetchall()
for r in rows: print(f'{r[0]:6s} {r[2]:10s} {r[1]}')
db.close()
"
```

- [ ] **Step 5: 교차 코드 리뷰 + 커밋**

```bash
git add backend/app/models/project.py backend/app/core/database.py
git commit -m "short-id: DB 마이그레이션 — entity_canon.short_id 컬럼 + 기존 데이터 발급"
```

---

### Task 2: short_id 자동 발급 함수

**Files:**
- Create: `backend/app/modules/short_id.py`

- [ ] **Step 1: short_id 발급 모듈 생성**

```python
"""short_id 발급 — 엔티티 타입별 접두어 + 프로젝트 내 순번."""

from sqlalchemy import text
from sqlalchemy.orm import Session

_PREFIX_MAP = {
    "character": "C",
    "location": "L",
    "prop": "P",
    "outlook": "O",
}


def generate_short_id(db: Session, project_id: str, entity_type: str) -> str:
    """프로젝트 내 해당 타입의 다음 short_id 발급."""
    prefix = _PREFIX_MAP.get(entity_type)
    if not prefix:
        raise ValueError(f"Unknown entity_type for short_id: {entity_type}")

    row = db.execute(text(
        "SELECT short_id FROM entity_canon "
        "WHERE project_id = :pid AND short_id LIKE :pattern "
        "ORDER BY short_id DESC LIMIT 1"
    ), {"pid": project_id, "pattern": f"{prefix}%"}).fetchone()

    if row and row[0]:
        num = int(row[0][len(prefix):]) + 1
    else:
        num = 1

    return f"{prefix}{num:02d}"


def bulk_generate_short_ids(db: Session, project_id: str, entities: list) -> dict:
    """여러 엔티티에 short_id 일괄 발급. Returns {uuid: short_id}."""
    # 타입별 현재 최대 번호 조회
    counters = {}
    for prefix_letter in _PREFIX_MAP.values():
        row = db.execute(text(
            "SELECT MAX(CAST(SUBSTRING(short_id FROM :start) AS INTEGER)) "
            "FROM entity_canon WHERE project_id = :pid AND short_id LIKE :pattern"
        ), {"pid": project_id, "pattern": f"{prefix_letter}%", "start": len(prefix_letter) + 1}).fetchone()
        counters[prefix_letter] = (row[0] or 0) if row else 0

    result = {}
    for ent in entities:
        etype = ent.get("entity_type") or ent.get("type", "")
        # type이 복수형일 수 있음 (characters → character)
        if etype.endswith("s") and etype not in _PREFIX_MAP:
            etype = etype.rstrip("s")
        prefix = _PREFIX_MAP.get(etype)
        if not prefix:
            continue
        counters[prefix] += 1
        eid = ent.get("id") or ent.get("uuid", "")
        result[eid] = f"{prefix}{counters[prefix]:02d}"

    return result


def build_short_id_map(db: Session, project_id: str, episode_id: str = None) -> dict:
    """DB에서 short_id ↔ UUID 매핑 조회. Returns {short_id: uuid}."""
    query = "SELECT id, short_id FROM entity_canon WHERE project_id = :pid AND short_id IS NOT NULL"
    params = {"pid": project_id}
    if episode_id:
        query = (
            "SELECT ec.id, ec.short_id FROM entity_canon ec "
            "JOIN entity_episode_link eel ON ec.id = eel.canon_id "
            "WHERE ec.project_id = :pid AND eel.episode_id = :eid AND ec.short_id IS NOT NULL"
        )
        params["eid"] = episode_id

    rows = db.execute(text(query), params).fetchall()
    return {r[1]: r[0] for r in rows}


def build_short_id_info(db: Session, project_id: str, episode_id: str = None) -> dict:
    """DB에서 short_id → {uuid, name, type, description} 매핑 조회."""
    query = (
        "SELECT ec.id, ec.short_id, ec.name, ec.entity_type, ec.description "
        "FROM entity_canon ec "
        "JOIN entity_episode_link eel ON ec.id = eel.canon_id "
        "WHERE ec.project_id = :pid AND eel.episode_id = :eid AND ec.short_id IS NOT NULL"
    )
    rows = db.execute(text(query), {"pid": project_id, "eid": episode_id}).fetchall()
    return {
        r[1]: {"uuid": r[0], "name": r[2], "type": r[3], "description": (r[4] or "")[:80]}
        for r in rows
    }
```

- [ ] **Step 2: 교차 코드 리뷰 + 커밋**

```bash
git add backend/app/modules/short_id.py
git commit -m "short-id: short_id 발급 모듈 — generate/bulk/build_map"
```

---

### Task 3: _sync_checkpoints_to_db에 short_id 발급 통합

**Files:**
- Modify: `backend/app/api/v1/steps.py:315-451` (_sync_checkpoints_to_db)

- [ ] **Step 1: 엔티티 생성 시 short_id 발급**

`_sync_checkpoints_to_db` 내부, 엔티티 add 시:
```python
from app.modules.short_id import generate_short_id
# ...
eid = str(uuid.uuid4())
short = generate_short_id(db, project_id, singular)
db.add(EntityCanon(
    id=eid, project_id=project_id, short_id=short,
    name=ent["name"], entity_type=singular, ...
))
```

아웃룩도 동일하게 `generate_short_id(db, project_id, "outlook")` 적용.

- [ ] **Step 2: visible_entities_json에 short_id 포함**

씬 저장 시 `ve_item`에 short_id 추가:
```python
entity_short_map = {e.name: e.short_id for e in db.query(EntityCanon).filter(...).all()}
for ve_item in ve_raw:
    name = ve_item.get("entity_name", "")
    ve_item["short_id"] = entity_short_map.get(name, "")
```

- [ ] **Step 3: 테스트 — 새 프로젝트 생성 후 확인**

- [ ] **Step 4: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: _sync_checkpoints_to_db에 short_id 발급 통합"
```

---

## Phase 2: 분석 파이프라인 (병렬 가능)

> Task 4~8은 서로 독립적이므로 **병렬 실행 가능**

### Task 4: SceneDirectorStep — 타입별 접두어 (C/L/P)

**Files:**
- Modify: `backend/app/core/steps/analysis_steps.py:698-870` (SceneDirectorStep)

- [ ] **Step 1: 임시 E01 생성 → DB short_id 조회로 교체**

```python
from app.modules.short_id import build_short_id_info, build_short_id_map

# 기존 동적 생성 코드 제거
# short_to_uuid, uuid_to_short, entity_list_items 전부 교체

sid_info = build_short_id_info(self.db, self.project_id, self.episode_id)
short_to_uuid = {sid: info["uuid"] for sid, info in sid_info.items()}

# 엔티티 목록 (LLM용)
entity_list_items = []
for sid, info in sorted(sid_info.items()):
    if info["type"] in ("character", "location", "prop"):
        entity_list_items.append(
            _json.dumps({"id": sid, "name": info["name"], "type": info["type"],
                         "description": info["description"]}, ensure_ascii=False)
        )

# enum — DB short_id 목록
valid_ids = [sid for sid, info in sid_info.items() if info["type"] in ("character", "location", "prop")]
```

- [ ] **Step 2: 이름 fallback 유지 (안전망)**

기존 `_resolve_entity_id` + `name_to_uuid` 유지.

- [ ] **Step 3: 테스트 — scene_director force 실행 + 결과 확인**

- [ ] **Step 4: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: SceneDirectorStep — DB short_id 조회 (E01→C01/L01/P01)"
```

---

### Task 5: OutlookExtractionStep — character short_id

**Files:**
- Modify: `backend/app/core/steps/analysis_steps.py:427-460` (OutlookExtractionStep)
- Modify: `backend/app/modules/pipeline/outlook_extractor.py`
- Create: `prompts/_base/outlook_extractor/5.202603231xxx/extract_schema.json`
- Create: `prompts/_base/outlook_extractor/5.202603231xxx/extract_prompt.md`

- [ ] **Step 1: 캐릭터 이름 → short_id 전달**

OutlookExtractionStep에서:
```python
from app.modules.short_id import build_short_id_info
sid_info = build_short_id_info(self.db, self.project_id, self.episode_id)
char_short_ids = {sid: info for sid, info in sid_info.items() if info["type"] == "character"}
# 프롬프트에 short_id + name 테이블 전달
```

- [ ] **Step 2: 스키마에 character short_id enum 추가**

```json
"character_name": {"type": "string", "enum": ["C01", "C02", ...]}
```

- [ ] **Step 3: 프롬프트 새 버전 생성 (덮어쓰기 금지)**

- [ ] **Step 4: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: OutlookExtraction — character short_id + enum"
```

---

### Task 6: SceneVerifyStep — short_id 기반 검증

**Files:**
- Modify: `backend/app/core/steps/analysis_steps.py:579-618` (SceneVerifyStep)
- Modify: `backend/app/modules/pipeline/scene_validator.py`

- [ ] **Step 1: entity_name → short_id 전달**

- [ ] **Step 2: 검증 스키마에 short_id enum**

- [ ] **Step 3: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: SceneVerifyStep — short_id 기반 검증"
```

---

### Task 7: SceneDetailStep + scene_extractor_v2 — 가장 큰 변경

**Files:**
- Modify: `backend/app/core/steps/analysis_steps.py:473-578` (SceneDetailStep)
- Modify: `backend/app/modules/pipeline/scene_extractor_v2.py:560-892`
- Create: `prompts/_base/scene_extractor_v2/8.202603231xxx/scene_detail_schema.json`
- Create: `prompts/_base/scene_extractor_v2/8.202603231xxx/system.md`

- [ ] **Step 1: _build_scene_entity_block → short_id 마커**

```python
# Before: f"  - [[{c['name']}]+[아웃룩이름]]"
# After:  f"  - {c['short_id']}{outlook_short_id}"
# 예: "  - C01O02"
```

- [ ] **Step 2: visible_entities 스키마 → short_id + enum**

```json
"visible_entities": {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "short_id": {"type": "string", "enum": ["C01", "C02", ...]},
            "entity_type": {"type": "string", "enum": ["character", "location", "prop"]}
        }
    }
}
```

- [ ] **Step 3: t2i_prompt 마커 → C01O02 형태**

T2I 프롬프트에서 `[[name]+[outlook]]` 대신 `C01O02` 사용. 프롬프트 시스템에:
```
엔티티 참조 형식:
- 인물+아웃룩: C01O02 (인물 short_id + 아웃룩 short_id 연결)
- 소품: P01
- 배경: [L01: 시각적 설명]
```

- [ ] **Step 4: 후처리 — visible_entities 복원 로직 short_id 대응**

- [ ] **Step 5: 프롬프트 새 버전 생성**

- [ ] **Step 6: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: SceneDetail + scene_extractor_v2 — 마커+visible_entities+enum"
```

---

### Task 8: outlook_dedup + outlook_merger — short_id 대응

**Files:**
- Modify: `backend/app/modules/pipeline/outlook_dedup.py`
- Modify: `backend/app/modules/pipeline/outlook_merger.py`

- [ ] **Step 1: 마커 정규식 교체**

```python
# Before: r'\[\[([^\]]+)\]\+\[([^\]]+)\]\]'
# After:  r'(C\d{2,3})(O\d{2,3})'
```

- [ ] **Step 2: dedup 키를 short_id 기반으로**

- [ ] **Step 3: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: outlook_dedup + merger — 정규식 + dedup 키 교체"
```

---

## Phase 3: 이미지 서비스 + API + UI

### Task 9: image_service — _resolve_refs_for_prompt

**Files:**
- Modify: `backend/app/services/image_service.py:1424-1484`
- Modify: `backend/app/services/image_service.py:62-167` (_build_final_scene_prompt)

- [ ] **Step 1: `[[name]+[outlook]]` 정규식 → `C01O02` 파싱**

```python
# Before: re.finditer(r'\[\[([^\]]+)\]\+\[([^\]]+)\]\]', t2i_prompt)
# After:  re.finditer(r'(C\d{2,3})(O\d{2,3})', t2i_prompt)
```

- [ ] **Step 2: _build_final_scene_prompt에 매핑 테이블 생성**

```python
# 번역 프롬프트에 엔티티 설명 테이블 추가
entity_table = "\n".join(
    f"{sid} = {info['description']}" for sid, info in sid_info.items()
    if sid in used_sids
)
```

- [ ] **Step 3: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: image_service — C01O02 파싱 + 번역 매핑 테이블"
```

---

### Task 10: 기타 이미지 모듈

**Files:**
- Modify: `backend/app/modules/t2i_visual_converter.py`
- Modify: `backend/app/modules/entity_dependency.py`
- Modify: `backend/app/core/steps/image_steps.py:51-210` (_sync_analysis_to_db)
- Modify: `backend/app/services/analysis_service.py:393-442` (레거시 경로)

- [ ] **Step 1: t2i_visual_converter — entity_name → short_id**
- [ ] **Step 2: entity_dependency — visible_entities 파싱 호환**
- [ ] **Step 3: image_steps._sync_analysis_to_db — 공통 함수 위임 확인**
- [ ] **Step 4: analysis_service 레거시 경로 — short_id 발급**
- [ ] **Step 5: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: 이미지 모듈 — t2i_converter, entity_dep, image_steps"
```

---

### Task 11: API 응답 — short_id 포함 + UI 변환

**Files:**
- Modify: `backend/app/api/v1/entities.py:39-50, 272-335, 347`
- Modify: `backend/app/schemas/entity.py`
- Modify: `frontend/src/components/shared/SceneVariationCard.tsx:206-212`
- Modify: `frontend/src/pages/EpisodeDetail.tsx:29-30, 535-543`
- Modify: `frontend/src/components/shared/ImageGalleryModal.tsx:114-133`
- Modify: `frontend/src/pages/Entities.tsx:476`

- [ ] **Step 1: EntityResponse에 short_id 필드**

```python
class EntityResponse(BaseModel):
    id: str
    short_id: Optional[str] = None
    name: str
    entity_type: str
    # ...
```

- [ ] **Step 2: API 응답 시 visible_entities에 name 포함**

```python
# short_id → name 변환 포함
ve_enriched = []
for ve in visible_entities:
    sid = ve.get("short_id", "")
    entity = entity_map.get(sid)
    ve_enriched.append({**ve, "name": entity.name if entity else ""})
```

- [ ] **Step 3: 프론트엔드 — C01O02 → [[이름]+[아웃룩]] 변환**

```typescript
// SceneVariationCard.tsx
function shortIdToMarker(text: string, entityMap: Record<string, Entity>): string {
    return text.replace(/(C\d{2,3})(O\d{2,3})/g, (_, cid, oid) => {
        const char = entityMap[cid]?.name || cid;
        const outfit = entityMap[oid]?.name || oid;
        return `[[${char}]+[${outfit}]]`;
    });
}
```

- [ ] **Step 4: 기존 [[]] 정규식 → short_id 파서 교체 (프론트엔드 3곳)**

- [ ] **Step 5: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: API short_id 응답 + 프론트엔드 C01O02↔[[이름]] 변환"
```

---

### Task 12: 프롬프트 파일 정리 + 레거시 정규식 제거

**Files:**
- Backend 정규식 6곳: analysis_service.py:405, entities.py:347, scene_validator.py:223, outlook_dedup.py:59, image_service.py:163, image_service.py:1439
- Modify: `backend/app/services/project_export_service.py:359` (export 호환)

- [ ] **Step 1: 백엔드 정규식 6곳 — [[]] → C01O02 교체**
- [ ] **Step 2: export_service — visible_entities_json에 name 포함 (호환)**
- [ ] **Step 3: 교차 코드 리뷰 + 커밋**

```bash
git commit -m "short-id: 레거시 [[]] 정규식 제거 + export 호환"
```

---

### Task 13: 전체 파이프라인 E2E 테스트

- [ ] **Step 1: 새 프로젝트 생성 + EP1 업로드**
- [ ] **Step 2: run-all analysis (12단계)**
- [ ] **Step 3: 검증 — 씬 5 (소울라이드), 씬 12, 씬 16 (코카)**
- [ ] **Step 4: visible_entities에 short_id 확인**
- [ ] **Step 5: t2i_prompt에 C01O02 형태 확인**
- [ ] **Step 6: UI에서 [[이름]+[아웃룩]] 표시 확인**
- [ ] **Step 7: 기존 프로젝트 데이터 열람 가능 확인 (마이그레이션 호환)**
- [ ] **Step 8: 최종 교차 코드 리뷰 + PR 업데이트**

---

## 병렬 실행 가이드

```
Phase 1: Task 1 → Task 2 → Task 3 (순차)
                         ↓
Phase 2: Task 4 ─┐
         Task 5 ─┤ (병렬 가능)
         Task 6 ─┤
         Task 7 ─┘ → Task 8 (Task 7 이후)
                         ↓
Phase 3: Task 9 → Task 10 → Task 11 → Task 12 → Task 13
```

Phase 2의 Task 4, 5, 6은 서로 독립적이므로 worktree 병렬 실행 가능.
Task 7(scene_detail)은 가장 큰 변경이므로 단독 실행 권장.
