# TheRoad Scene Lab — 기술 개발 기획서

> 시나리오 PDF → 요소 추출 → 씬 분석 → 이미지 생성 → 웹북 PDF 파이프라인의 전체 아키텍처

---

## 1. 시스템 아키텍처

```mermaid
graph TB
    subgraph Frontend["Frontend (React + TypeScript)"]
        UI[React SPA]
    end

    subgraph Backend["Backend (FastAPI + Python)"]
        API[API Layer<br/>FastAPI v1 Routes]
        SVC[Service Layer<br/>AnalysisService / ImageService / ExportService]
        MOD[Module Layer<br/>Pipeline + LLM Clients + Generators]
        JM[Job Manager<br/>submit_background_job]
        TR[Task Registry<br/>stale recovery]
    end

    subgraph LLM["LLM APIs"]
        GP[Gemini 3.1 Pro<br/>텍스트 분석]
        GL[Gemini 3.1 Flash Lite<br/>씬 분할]
        GI[Gemini 3.1 Flash Image<br/>T2I / I2I]
        GPT[GPT-5.5<br/>LVM 검증 / 선택 / 텍스트]
    end

    subgraph Storage["Storage"]
        PG[(PostgreSQL)]
        FS[File System<br/>projects/{id}/images/]
        AL[Alembic<br/>DB 마이그레이션]
    end

    UI <-->|REST API| API
    API --> SVC
    SVC --> MOD
    SVC --> JM
    JM --> TR
    MOD <-->|urllib REST| GP & GL & GI & GPT
    SVC <--> PG
    AL --> PG
    MOD --> FS
```

---

## 2. 기술 스택

| 계층 | 기술 | 용도 |
|------|------|------|
| Frontend | React 18 + TypeScript + Vite | SPA UI |
| Backend | Python 3.12 + FastAPI | REST API 서버 |
| Database | PostgreSQL | 프로젝트/요소/씬/이미지 메타데이터 |
| ORM | SQLAlchemy 2.0 | DB 접근 |
| 마이그레이션 | Alembic | DB 스키마 버전 관리 + 인덱스 |
| 로깅 | structlog 패턴 (JSONFormatter) | 구조화 JSON 로그 출력 |
| LLM (텍스트) | Gemini 3.1 Pro Preview | 요소 추출, 씬 분석, 아웃룩 추출 |
| LLM (경량) | Gemini 3.1 Flash Lite | 긴 씬 분할 판단 |
| LLM (이미지) | Gemini 3.1 Flash Image | T2I 생성, I2I 편집 |
| LLM (검증) | GPT-5.5 (Vision) | 이미지 검증, 비교 선택, 텍스트 합성 |
| HTTP Client | urllib (stdlib) | 모든 LLM API 호출 |
| PDF 파싱 | pypdf (`PdfReader`) | 시나리오 PDF → 텍스트 |
| PDF 검증 | pdftoppm (poppler) / sips (macOS) | 생성 PDF → PNG 변환 → GPT Vision 검증 |

---

## 3. LLM 모델 배치표

```mermaid
flowchart LR
    subgraph "Gemini 3.1 Pro"
        E[요소 추출 4턴]
        S[씬 상세 분석]
        O[아웃룩 추출]
        D[씬 연관 분석]
        SUM[요약 생성]
        TR[T2I 프롬프트 번역]
    end

    subgraph "Gemini Flash Lite"
        SP[긴 씬 분할]
    end

    subgraph "Gemini Flash Image"
        T2I[참조/씬 T2I 생성]
        I2I[각도/색감 편집]
    end

    subgraph "GPT-5.5"
        VAL[이미지 품질 검증]
        SEL[최적 이미지 선택]
        REV[요소 목록 리뷰]
        DET[요소 상세 배치 추출]
        WB[웹북 텍스트 생성]
        SAN[프롬프트 정화]
    end
```

| 모델 | 용도 | 호출 횟수 (EP1 기준, 총 ~412회) |
|------|------|------|
| Gemini 3.1 Pro | 요소 추출 + 씬 분석 + 아웃룩 + 프롬프트 번역 | ~80회 |
| Gemini Flash Lite | 씬 분할 | ~2회 |
| Gemini Flash Image | T2I/I2I 생성 | ~200회 |
| GPT-5.5 | 검증 + 선택 + 요소 상세 + 요소 리뷰 + 텍스트 | ~130회 |

---

## 4. 분석 파이프라인 상세

### 4.1 요소 추출 (entity_extractor_v2)

```mermaid
sequenceDiagram
    participant AS as AnalysisService
    participant GEM as Gemini Pro
    participant GPT as GPT-5.5

    AS->>GEM: Turn 0 — 스타일 분석 (시대, 장르, 톤)
    GEM-->>AS: style_rules_json

    AS->>GEM: Turn 1 — 요소 목록 추출 (이름, 유형, 등장 횟수)
    Note over AS: prior_entities 주입<br/>(이전 에피소드 요소: name+description[:200]+t2i_prompt[:100])
    GEM-->>AS: characters[], locations[], props[]

    AS->>GPT: Turn 1.5 — 요소 목록 리뷰 (중요도 검증)
    Note over GPT: importance="none" AND appearances<2 → 제거<br/>LLMCallLog에 기록됨
    GPT-->>AS: 승인 or 수정 제안 + low-importance 필터링

    AS->>GPT: Turn 1.7 — 요소 상세 일괄 추출 (OpenAI Responses API)
    Note over GPT: 시각 설명, visual_traits 등<br/>전 요소 배치 호출 (누락 시 재시도)
    GPT-->>AS: description, visual_traits per entity

    AS->>GEM: Turn 2+ — 요소별 T2I 프롬프트 생성 (병렬 10개)
    Note over GEM: GPT 상세 정보를 컨텍스트에 포함<br/>독립 GeminiTextClient × N
    GEM-->>AS: t2i_prompt per entity

    AS->>AS: DB 저장 (EntityCanon + EntityEpisodeLink)
```

> **참고**: v2 저장 경로(`_save_entities_v2`)는 EntityCanon upsert + EntityEpisodeLink 생성이 핵심이다.
> EntityAlias, RelationFact는 변형(variant) 요소 처리 시에만 생성되며, 기본 저장 경로에서는
> EntityCanon + EntityEpisodeLink만 직접 persist한다.

**Turn 1.5 GPT 리뷰 개선**:
- `importance="none"` AND `appearances < 2`인 요소를 자동 필터링 (low-importance 제거)
- 필터링된 요소 목록을 로그로 기록
- GPT 호출이 `log_llm_call()`을 통해 LLMCallLog에 기록됨

**멀티 에피소드 학습**:
- 이전 에피소드에서 추출된 EntityCanon을 `prior_entities`로 전달
- 각 prior entity에 `description[:200]` + `t2i_prompt[:100]` 포함
- Turn 1 프롬프트에 "기존 요소가 이번 에피소드에도 등장하면 이름을 동일하게 유지하세요" 지시

**체크포인트**: 턴마다 JSON 저장 → 중단 시 이어서 진행

### 4.2 씬 추출 (scene_extractor_v2)

```mermaid
flowchart TD
    A[시나리오 전문] --> B[정규식 세그먼테이션]
    B --> |"INT./EXT. 헤딩 기반"| C["~37개 기본 씬"]
    C --> D{600자 이상?}
    D -->|Yes| E[Gemini Lite: 논리적 2분할]
    D -->|No| F[그대로 유지]
    E --> F
    F --> G["~43~55개 최종 씬"]

    G --> H[씬 연관 분석<br/>Gemini Pro 1회]
    H --> I[아웃룩 추출<br/>Gemini Pro 1회]
    I --> J["병렬 씬 상세 분석<br/>ThreadPoolExecutor × 10"]

    J --> K[씬당 출력]
    K --> K1[beat_title]
    K --> K2[representative_moment]
    K --> K3["t2i_variations × 3<br/>(base + angle_1 + angle_2)"]
    K --> K4[visible_entities]
    K --> K5[dependent_scene_index]
    K --> K6["scene_type<br/>(normal|montage|flashback|<br/>dream|voiceover|transition)"]
```

> **실행 순서** (`analysis_service.py` 참조):
> 1. 정규식 세그먼테이션 (`_segment_by_heading`)
> 2. Gemini Lite 긴 씬 분할 (`_split_long_scenes`)
> 3. **씬 연관 분석** (`extract_scene_dependencies`) — 시각적 연관 씬 인덱스 추출
> 4. **아웃룩 추출** (`extract_all_outlooks`) — 인물별 의상 변화 추출 + DB 저장
> 5. 병렬 씬 상세 분석 (`extract_scenes_multiturn`) — outlook_assignments + scene_dependencies 주입

**scene_type 분류**: 씬 상세 분석 시 각 씬에 `scene_type`을 할당한다.
- `normal` — 기본 대사/액션 씬
- `montage` — 몽타주 (시간 경과 등)
- `flashback` — 회상 씬
- `dream` — 꿈 씬
- `voiceover` — 내레이션/보이스오버
- `transition` — 장면 전환

**V.O. 캐릭터 경고/필터링**: `outlook_assignments` 검증 시, `visible_entities`에 있지만 아웃룩 매핑에 없는 캐릭터는 V.O. 전용일 수 있으므로 경고 로그를 남긴다 (자동 제거는 아님).

**요소 설명 확장**: entity description 상한을 40자 → 150자로 확대하여 씬 분석 시 더 풍부한 컨텍스트를 제공한다 (`entity_summary_lines`에서 `c.get('description', '')[:150]`).

**v3 순차 경로 제거**: 기존 v3의 sequential 분석 경로는 dead code로 판정되어 정리되었다.

**병렬 처리**: 씬 분석은 독립적이므로 큐 기반 병렬 처리 (max 10 workers, 2초 stagger)

### 4.3 아웃룩 시스템

```mermaid
erDiagram
    EntityCanon ||--o{ CharacterOutlook : "character_id"
    EntityCanon ||--o{ CharacterOutlook : "outlook_id"

    EntityCanon {
        text id PK
        text entity_type "character|location|prop|outlook"
        text name
        text description
        text t2i_prompt
    }

    CharacterOutlook {
        text id PK
        text character_id FK
        text outlook_id FK
    }
```

- **규칙**: 같은 정장이라도 인물마다 다른 아웃룩 (영화 의상 담당자 접근)
- **예외**: 군복, 교복 등 제복만 공유 가능
- **상태 변화**: 더러워짐/젖음은 별도 아웃룩이 아님 (T2I에서 묘사)
- **T2I 마커**: `[[인물이름]+[아웃룩이름]]` 형식 필수

---

## 5. 이미지 생성 파이프라인

### 5.0 월드 가이드 생성

이미지 생성 전에 **WorldGuideGenerator** (GPT-5.5 기반)가 세계관 가이드를 생성한다.

- **입력**: 시나리오 전문, 요소 목록, 씬 목록
- **출력**: `WorldGuide` JSON — 시대/기술 수준/의상 가드레일/시각 주의사항 등
- **저장**: `world_guide` 테이블에 JSON으로 영속화
- **용도**: 참조 이미지 생성 시 시대적 정합성 검증, 씬 이미지 프롬프트에 세계관 컨텍스트 주입
- 이미 생성된 WorldGuide가 있으면 재사용 (중복 생성 방지)

**style_rules 필드**: WorldGuide JSON에 `style_rules` 객체가 포함된다.
- `must_maintain` — 이미지 생성 시 반드시 유지해야 할 시각 규칙 (3~5개)
- `must_avoid` — 이미지 생성 시 반드시 피해야 할 시각 요소 (3~5개)

**source_hash 버전 관리**: WorldGuide 생성 시 입력(fulltext + entities + stills count)의 MD5 해시를 `source_hash`로 저장한다. 동일 해시가 이미 존재하면 재생성을 건너뛰고 기존 가이드를 재사용한다.

### 5.1 참조 이미지 생성

```mermaid
flowchart TD
    A[엔티티 목록] --> B[의존성 정렬<br/>위상 정렬]
    B --> C["배치 생성<br/>(병렬 15개)"]

    C --> D[Gemini Flash Image<br/>T2I 생성]
    D --> E[GPT-5.5 Vision<br/>품질 검증]
    E --> F{severity?}
    F -->|ok/minor| G[저장]
    F -->|severe| H[재생성 1회]
    H --> I[GPT 비교 선택]
    I --> G

    G --> J[ImageAsset 저장<br/>is_primary=1]
```

### 5.2 씬 이미지 생성 (자동 배치)

```mermaid
flowchart TD
    A["씬 T2I 프롬프트<br/>(한국어 + 마커)"] --> B["Gemini Pro<br/>프롬프트 번역"]
    B --> |"영어 변환 +<br/>마커→참조번호 +<br/>고유명사 제거"| C[최종 영어 프롬프트]

    C --> D["참조 이미지 매칭<br/>[[인물]+[아웃룩]] → composite<br/>[[물체]] → ref image"]

    D --> E["Gemini Flash Image<br/>N개 T2I 변형 생성"]

    E --> F["GPT-5.5<br/>N개 중 최적 선택"]
    F --> G{validation_score < 40?}
    G -->|Yes| H["높은 점수 대안으로 교체<br/>(차이 > 20점 시)"]
    G -->|No| I[대표 이미지 저장<br/>is_primary=1]
    H --> I
```

> **자동 배치 흐름**: 씬당 N개(기본 3개) T2I 변형을 병렬 생성 → GPT LVM이 최적 1개 선택 → `is_primary=1`로 저장.
> I2I 변형(각도/색감 편집)은 자동 배치에 포함되지 않는다.

**멀티 시그널 랭킹**: GPT가 선택한 이미지의 `validation_score`가 40 미만이고 다른 변형이 존재하면, 가장 높은 점수의 대안과 비교하여 점수 차이가 20점 이상이면 GPT 선택을 오버라이드한다.

**T2I 변형 구성 다양성**: 씬 상세 분석의 JSON 스키마에 `t2i_variations` 배열이 포함되며, 각 변형에 `variant_label` (base/angle_1/angle_2)과 `camera_effect` (카메라/색감 효과 설명)를 지정하여 구성적 다양성을 확보한다.

### 5.3 I2I 변형 (수동 엔드포인트)

각도 변형(`edit_angle`)과 색감 변형(`edit_color`)은 **자동 배치와 별도의 수동 API 엔드포인트**로 제공된다.

- `POST /images/{image_id}/edit-angle` — Gemini Flash Image I2I로 카메라 각도 편집
- `POST /images/{image_id}/edit-color` — Gemini Flash Image I2I로 색감/분위기 편집
- `POST /scenes/{still_id}/recommend-variations` — GPT-5.5가 최적 각도/색감 변형 추천
- `POST /scenes/{still_id}/generate-variations` — 추천된 변형을 I2I로 일괄 생성

이들은 사용자가 개별 이미지를 선택한 후 수동으로 호출하며, 자동 배치 파이프라인(`generate_images`)에는 포함되지 않는다.

### 5.4 프롬프트 번역 파이프라인

```
입력:  "Photorealistic cinematic still. [[동녘]+[서바이벌군복]] crawls out of [폐창고: 무너진 벽과 연기]."
                                         ↓ Gemini Pro 번역 (GeminiTextClient, 기본 모델)
출력:  "Photorealistic cinematic still. The man in Reference image 1 crawls out of
        the warehouse shown in Reference image 3, with collapsed walls and smoke."
```

- 고유명사 → 참조 이미지 번호로 대체
- 얼굴/체형 묘사 금지 (참조 이미지가 처리)
- 의상 묘사만 남김
- **사용 모델**: 기본 GeminiTextClient (gemini-3.1-pro-preview) — Flash Image가 아님

---

## 6. API 키 라운드로빈

```mermaid
flowchart LR
    A[LLM 호출] --> B{키 풀}
    B --> K1[GEMINI_API_KEY]
    B --> K2[GEMINI_API_KEY1]
    B --> K3[GEMINI_API_KEY2]
    K1 & K2 & K3 --> C[API 호출]
    C --> D{429 에러?}
    D -->|Yes| E[다음 키로 재시도]
    D -->|No| F[결과 반환]
```

- `.env`에서 `GEMINI_API_KEY`, `GEMINI_API_KEY1`, ... 자동 수집
- 키 개수 동적 (하드코딩 없음)
- Thread-safe 카운터 (threading.Lock)
- 429 에러 시 자동으로 다음 키 사용

> **images.py 라우트 키 풀 지원**: `images.py`의 참조/씬 이미지 엔드포인트에서도
> `gemini_key_count() == 0` 검사를 사용하여 키 풀을 지원한다.
> `from app.modules.llm.gemini_key_pool import key_count as gemini_key_count`를 임포트하며,
> `settings.gemini_api_key`가 비어 있어도 풀에 키가 있으면 정상 동작한다.

---

## 7. LLM 호출 추적 시스템

```mermaid
erDiagram
    LLMCallLog {
        text id PK
        text project_id
        text episode_id
        text operation_type "entity_extraction|scene_analysis|image_gen|..."
        text step_name "turn0|turn1.5|scene_10|translate|..."
        text model_name
        text system_prompt
        text user_prompt
        text output_text
        text reference_image_ids "JSON array"
        int duration_ms
        int input_tokens
        int output_tokens
        text status "success|error"
        text error_message
        text created_at
    }
```

- GeminiTextClient, GeminiImageClient, OpenAIClient에 `llm_logger.log_llm_call()` 자동 로깅 내장
- 성공/실패 모두 기록
- 토큰 사용량 + 소요 시간 추적
- 이미지는 reference_image_ids로만 참조 (바이너리 저장 없음)
- `set_context()` 메서드로 프로젝트/에피소드/작업 컨텍스트 설정

**Turn 1.5 커버리지**: entity_extractor_v2의 GPT 리뷰 호출(Turn 1.5)이 `log_llm_call()`을 통해 LLMCallLog에 기록된다.

**프롬프트 자르기**: system_prompt, user_prompt, output_text 모두 `MAX_PROMPT_CHARS = 10000`(약 10K 글자)을 초과하면 `...[truncated]` 접미사와 함께 잘린다. 대용량 시나리오 전문이 반복 저장되어 DB가 비대해지는 것을 방지한다.

> **알려진 제한**: 다음 모듈은 직접 `urllib.request`로 OpenAI API를 호출하며 LLMCallLog를
> 우회한다:
> - `ref_image_pipeline.py` — GPT Vision 검증/비교 선택 (직접 urllib 호출)
> - `variation_recommender_v2.py` — GPT Vision 변형 추천 (직접 urllib 호출)
> - `pdf_validator.py` — GPT Vision PDF 검증 (`_call_vision_api` 직접 호출)
>
> 이 모듈들의 GPT 호출은 LLMCallLog에 기록되지 않으므로, 총 API 사용량 추적에 누락이 있다.

---

## 8. 데이터 모델 (전체)

```mermaid
erDiagram
    UserAccount ||--o{ Session : has
    UserAccount ||--o{ ProjectMember : joins
    UserAccount ||--o{ ActivityLog : acts

    ProjectRegistry ||--o{ ProjectMember : has
    ProjectRegistry ||--o{ Episode : contains
    ProjectRegistry ||--o{ EntityCanon : has
    ProjectRegistry ||--o{ ProjectSettings : has

    Episode ||--o{ SceneStill : has
    Episode ||--o{ EntityEpisodeLink : links
    Episode ||--o{ ScenePlan : plans

    EntityCanon ||--o{ EntityAlias : has
    EntityCanon ||--o{ CharacterOutlook : "char or outfit"
    EntityCanon ||--o{ EntityEpisodeLink : links
    EntityCanon ||--o{ RelationParticipant : participates

    RelationFact ||--o{ RelationParticipant : has

    SceneStill ||--o{ ImageAsset : "still_id"
    EntityCanon ||--o{ ImageAsset : "entity_id"

    ImageAsset ||--o{ GenerationTrace : traced_by

    Episode ||--o{ WebbookPackage : exports
    Episode ||--o{ WorldGuide : guides

    ProjectRegistry ||--o{ OperationLog : logs
    ProjectRegistry ||--o{ PipelineProgress : tracks
    ProjectRegistry ||--o{ LLMCallLog : traces

    UserAccount {
        text id PK
        text username UK
        text display_name
        text password_hash
        text role "admin | creator"
        int is_active
        text created_at
        text updated_at
    }

    Session {
        text id PK
        text user_id FK
        text created_at
        text expires_at
    }

    ProjectRegistry {
        text id PK
        text name
        text description
        text status "active | archived | deleted"
        text created_by FK
        text created_at
        text updated_at
    }

    ProjectMember {
        text id PK
        text project_id FK
        text user_id FK
        text role "owner | member"
        text added_by FK
        text created_at
    }

    ActivityLog {
        text id PK
        text actor_id FK
        text action
        text resource_type
        text resource_id
        text project_id
        text detail_json
        text ip_address
        text created_at
    }

    ScenePlan {
        text id PK
        text project_id FK
        text episode_id FK
        int split_threshold "default 600"
        text segments_json "JSON array"
        int total_scenes
        text status "pending|approved|rejected"
        text created_at
    }

    SceneStill {
        text scene_type "normal|montage|flashback|dream|voiceover|transition"
    }

    WorldGuide {
        text source_hash "MD5 of inputs"
    }
```

### 핵심 테이블

| 테이블 | 레코드 수 (EP1 기준) | 용도 |
|--------|---------------------|------|
| UserAccount | 2 (admin + creator) | 사용자 인증 |
| Session | ~1 | 세션 토큰 관리 (24h TTL) |
| ProjectRegistry | 1 | 프로젝트 메타데이터 |
| ProjectMember | ~2 | 프로젝트 역할 (owner/member) |
| ActivityLog | ~50+ | 사용자 행위 감사 로그 |
| EntityCanon | ~56 (16인물+9배경+7소품+24아웃룩) | 모든 요소의 정의 |
| CharacterOutlook | ~25 | 인물-의상 매핑 |
| ScenePlan | ~1 | 씬 분할 계획 (사용자 승인 전 미리보기) |
| SceneStill | ~43 | 씬 분석 결과 + T2I 프롬프트 + scene_type |
| ImageAsset | ~193 (64참조+129씬) | 생성 이미지 메타데이터 |
| WorldGuide | ~1 | 세계관 가이드 (source_hash로 캐시) |
| LLMCallLog | ~412 | 전체 LLM 호출 이력 |

### 추가된 컬럼

| 테이블 | 컬럼 | 타입 | 설명 |
|--------|------|------|------|
| SceneStill | `scene_type` | Text | `normal\|montage\|flashback\|dream\|voiceover\|transition` |
| WorldGuide | `source_hash` | Text | 입력 MD5 해시 — 동일하면 재생성 건너뜀 |

### 추가된 테이블

| 테이블 | 설명 |
|--------|------|
| ScenePlan | 씬 분할 계획 미리보기. `segments_json`에 분할 결과, `status`로 승인/거절 관리 |

---

## 9. 동시성 & 성능

```mermaid
flowchart TD
    subgraph "병렬 처리 전략"
        A["요소 상세 추출<br/>ThreadPool × 10"]
        B["씬 분석<br/>ThreadPool × 10<br/>(큐 기반, 2초 stagger)"]
        C["참조 이미지 생성<br/>ThreadPool × 15"]
        D["씬 이미지 생성<br/>ThreadPool × 15"]
    end

    subgraph "RPM 보호"
        E["2초 stagger<br/>(API 시작 간격)"]
        F["429 → 다음 키 재시도"]
        G["500/502/503 → 2*N초 대기"]
    end

    subgraph "작업 관리"
        H["job_manager<br/>submit_background_job"]
        I["task_registry<br/>활성 태스크 추적"]
        J["pipeline_cache<br/>content hash 기반"]
    end
```

| 작업 | 동시 수 | 소요 시간 |
|------|---------|-----------|
| 요소 추출 4턴 | 순차 (멀티턴) | ~4분 |
| 요소 상세 (13~16개) | 병렬 10 | ~1분 |
| 씬 분석 (43~55개) | 병렬 10 | ~5분 |
| 참조 이미지 (32개) | 병렬 15 | ~10분 |
| 씬 이미지 (43×3=129개) | 병렬 15 | ~25분 |

**job_manager 추상화**: 모든 백그라운드 작업은 `submit_background_job(job_key, target, args, description)`을 통해 실행된다. 내부적으로 threading 기반이며, 향후 Celery/ARQ로 교체 시 이 함수만 수정하면 된다. 이미 실행 중인 동일 `job_key`는 거부(False 반환)하여 중복 실행을 방지한다.

**pipeline_cache**: 파이프라인 단계별로 입력의 MD5 해시를 `projects/{id}/pipeline_cache.json`에 저장한다. 동일 입력이면 해당 단계를 건너뛴다. Atomic write (temp file → replace)로 동시성 안전을 보장한다.

**task_registry stale 복구**: 서버 시작 시 `recover_stale_progress(db_session)`이 `PipelineProgress` 테이블에서 `status="running"` 상태로 남은 레코드를 `status="error"` + `error_message="Server restarted during operation"`으로 복구한다.

---

## 10. 프롬프트 관리

```
prompts/_base/
├── entity_extractor_v2/
│   └── 2.202603181500/        ← 버전.YYYYMMDDHHmm
│       ├── system.md
│       ├── turn0_overview.md
│       ├── turn1_entity_list.md
│       └── ...
├── scene_extractor_v2/
│   └── 4.202603182300/
│       ├── system.md
│       ├── turn_scene_detail.md
│       └── scene_detail_schema.json
├── outlook_extractor/
│   └── 1.202603181600/
├── scene_dependency/
│   └── 1.202603190100/
├── scene_image/
│   └── 1.202603181600/
│       └── translate_prompt.md
├── ref_image_prompts/
│   └── 1.202603181600/
│       ├── character.md
│       ├── location.md
│       ├── prop.md
│       └── outlook.md
└── lvm_prompts/
    └── 2.202603181600/
        └── select_best_from_n.md
```

- **절대 덮어쓰기 금지**: 항상 새 버전 디렉토리 생성
- **버전 형식**: `major.YYYYMMDDHHmm`
- **자동 최신 로드**: `_get_latest_version_dir()` 함수가 정렬 후 최신 선택
- **외부화**: 대부분의 프롬프트는 파일로 외부화됨

**인라인 프롬프트 축소**: outlook_merger의 모델 참조가 `settings.gemini_text_model`로 외부화되어, 프롬프트 모델 선택이 설정 기반으로 통합되었다. outlook_extractor도 동일하게 `settings.gemini_text_model`을 사용한다.

> **알려진 제한**: 일부 인라인 시스템 프롬프트와 폴백 스키마가 코드에 남아 있다:
> - `entity_extractor_v2.py` Turn 1.7: GPT 호출 시 `system_prompt="시나리오 분석 전문가..."` 인라인
> - `world_guide_generator.py`: `WORLD_GUIDE_SCHEMA` 딕셔너리가 코드에 하드코딩
> - `pdf_validator.py`: `VALIDATION_SCHEMA` 딕셔너리가 코드에 하드코딩
>
> 점진적으로 외부 프롬프트 파일로 이전 예정.

---

## 11. 에러 처리 & 복원

| 상황 | 처리 방식 |
|------|-----------|
| LLM 타임아웃 | 최대 3회 재시도 (2*N초 대기) |
| 429 Rate Limit | 다음 API 키로 자동 전환 |
| Moderation Block | 프롬프트 정화 후 재시도 (최대 3회) |
| 이미지 품질 불량 | 재생성 1회 → GPT 비교 선택 |
| 분석 중단 | 체크포인트 저장 → resume 모드로 이어서 |
| DB 세션 충돌 | 스레드별 독립 세션 (메인 스레드에서만 DB 쓰기) |
| 고아 ImageAsset | 재분석 시 기존 씬 삭제 전에 연결된 ImageAsset 일괄 정리 |
| 에피소드 삭제 | SceneStill → EntityEpisodeLink → WorldGuide → WebbookPackage → ImageAsset → PipelineProgress 순서로 cascade 삭제 |
| Deferred fulltext 캐싱 | Episode.fulltext를 `deferred(Column(Text))`로 선언하여 목록 조회 시 대용량 텍스트 로딩 방지, 필요 시에만 로드 |

---

## 12. 보안 & 운영

| 항목 | 구현 |
|------|------|
| 인증 | 세션 기반 (24시간 TTL) |
| 쿠키 보안 | `httponly=True`, `samesite="lax"`, `secure`는 `request.url.scheme == "https"` 기반 동적 설정 |
| 글로벌 역할 | admin / creator (UserAccount.role) |
| 프로젝트 역할 | owner / member (ProjectMember.role) |
| API 키 | .env 파일 관리, 코드에 미포함 |
| 활동 로그 | 모든 사용자 행위 기록 (ActivityLog, IP 포함) |
| LLM 로그 | AI 호출 입출력 DB 저장 (일부 직접 urllib 호출 제외) |
| 프롬프트 | 고유명사 미포함 (범용 적용) |
| Health check | `GET /api/v1/health` — DB 연결, Gemini 키 수, OpenAI 키 존재 여부 반환 |
| Path traversal 방어 | 에피소드 삭제 시 `source.resolve()` + `project_dir` 접두어 검증, 커스텀 업로드 시 프로젝트 소유권 검증 |
| 프로젝트 소유권 검증 | 커스텀 이미지 업로드 시 entity/still이 해당 프로젝트에 속하는지 DB 조회로 확인 |

---

## 13. 프로젝트 JSON 내보내기 / 가져오기

`ProjectExportService` / `ProjectImportService`가 프로젝트 전체 데이터의 JSON 직렬화/복원을 처리한다.

### 내보내기 (`export_json`)
- ProjectRegistry, Episode, EntityCanon, EntityAlias, RelationFact, RelationParticipant, SceneStill, EntityEpisodeLink, ImageAsset, WorldGuide, WebbookPackage, GenerationTrace, OperationLog 전체를 JSON dict로 변환
- `export_assets_list()`로 프로젝트 디렉토리 하위 파일 목록도 반환 가능

### 가져오기 (`import_json`)
- 모든 레코드에 새 UUID 발급 + 내부 FK 참조 자동 리맵
- 가져오는 사용자를 owner로 자동 등록
- 프로젝트 디렉토리 구조 자동 생성 (`assets/screenplays`, `assets/references` 등)
- 이미지 파일은 메타데이터만 복원 (바이너리는 별도 복사 필요)

---

## 14. PDF 검증 파이프라인

생성된 웹북 PDF의 품질을 GPT Vision으로 자동 검증한다.

### 흐름
1. **PDF → PNG 변환**: `pdftoppm` (poppler) 우선, macOS에서는 `sips` 폴백
2. **GPT Vision 검증**: PNG 이미지를 GPT-5.5에 전송하여 레이아웃/텍스트/이미지 품질 평가
3. **결과 반환**: `text_readable`, `images_present`, `layout_correct`, `caption_visible`, `overall_quality` (1-10)

> **참고**: `pdf_validator.py`의 GPT Vision 호출은 직접 urllib로 수행되며 LLMCallLog를 우회한다 (7장 참조).

---

## 15. Alembic 마이그레이션

### 구성

- `backend/alembic.ini` — Alembic 설정 파일, `env.py`에서 앱 설정의 DB URL을 오버라이드
- `backend/alembic/versions/` — 마이그레이션 스크립트 디렉토리
- 기존 `database.py`의 `init_db()`에서 raw SQL로 적용하던 인덱스를 Alembic 버전 관리로 정규화

### 첫 번째 마이그레이션 (001_add_indexes)

쿼리 성능을 위한 10개 인덱스:

| 인덱스 | 테이블 | 컬럼 |
|--------|--------|------|
| `idx_scene_still_project_episode` | scene_still | project_id, episode_id |
| `idx_image_asset_entity_type` | image_asset | entity_id, asset_type |
| `idx_image_asset_still` | image_asset | still_id |
| `idx_image_asset_project_episode` | image_asset | project_id, episode_id, asset_type |
| `idx_entity_episode_link_canon` | entity_episode_link | canon_id, episode_id |
| `idx_pipeline_progress_project` | pipeline_progress | project_id, episode_id, operation |
| `idx_llm_call_log_project` | llm_call_log | project_id, created_at |
| `idx_activity_log_project` | activity_log | project_id, created_at |
| `idx_character_outlook_pair` | character_outlook | character_id, outlook_id |
| `idx_image_asset_variant` | image_asset | still_id, asset_type, variant_type |

모든 인덱스에 `if_not_exists=True` 적용 — 기존 DB에 안전하게 적용 가능.

---

## 16. 구조화 로깅 (JSONFormatter)

### 구현

`app/core/logging_config.py`에 `JSONFormatter` 클래스 정의:

```python
# 출력 예시 (단일 행 JSON)
{"timestamp": "2026-03-19T12:00:00+00:00", "level": "INFO", "logger": "app.services.analysis_service", "message": "Scene analysis complete", "project_id": "abc-123"}
```

| 필드 | 설명 |
|------|------|
| `timestamp` | UTC ISO-8601 형식 |
| `level` | 로그 레벨 (INFO/WARNING/ERROR) |
| `logger` | 파이썬 로거 이름 |
| `message` | 포맷된 메시지 |
| `project_id` | (선택) 프로젝트 컨텍스트 |
| `episode_id` | (선택) 에피소드 컨텍스트 |
| `exception` | (선택) 예외 traceback |

### 초기화

`app/main.py`의 `on_startup` 이벤트에서 `setup_logging()` 호출. uvicorn과의 충돌을 피하기 위해 앱 시작 시점에 실행한다. root 로거의 핸들러를 JSONFormatter가 적용된 StreamHandler로 교체한다.

---

## 17. 작업 관리자 (job_manager + task_registry)

### 아키텍처

```mermaid
flowchart TD
    A[API 라우트] -->|"submit_background_job(key, fn, args)"| B[job_manager]
    B --> C{task_registry:<br/>이미 실행 중?}
    C -->|Yes| D[False 반환<br/>중복 방지]
    C -->|No| E[Thread 생성 + 등록]
    E --> F[target 실행]
    F --> G[완료/실패]
    G --> H[unregister_task]

    I[서버 시작] --> J["recover_stale_progress(db)"]
    J --> K["running → error 복구"]
```

### job_manager (`app/core/job_manager.py`)

| 함수 | 설명 |
|------|------|
| `submit_background_job(job_key, target, args, description)` | 백그라운드 작업 제출. 동일 key 실행 중이면 `False` 반환 |

- 내부적으로 `threading.Thread(daemon=True)` 사용
- 완료/실패 시 자동 `unregister_task()`
- 향후 Celery/ARQ 교체 시 이 함수만 수정

### task_registry (`app/core/task_registry.py`)

| 함수 | 설명 |
|------|------|
| `register_task(task_key, thread)` | 태스크 등록 (실행 중이면 False) |
| `unregister_task(task_key)` | 태스크 해제 |
| `is_task_running(task_key)` | 실행 여부 확인 |
| `recover_stale_progress(db_session)` | 시작 시 stale `running` → `error` 복구 |

- `_active_tasks: Dict[str, Thread]` + `threading.Lock`으로 thread-safe
- `is_alive()` 체크로 좀비 태스크 자동 감지

### 사용처

- `episodes.py` — 에피소드 분석 백그라운드 작업
- `images.py` — 참조/씬 이미지 생성 백그라운드 작업
- `main.py` `on_startup` — stale progress 복구
