# TheRoad Scene Lab — LiteLLM + Opik 리팩토링 플랜

## 목표

1. **LiteLLM Router** — 모든 텍스트 LLM 호출을 통합 (멀티키, 자동 재시도, 모델 전환)
2. **Opik Cloud** — 모든 LLM/이미지 호출 추적 (trace + span, 비용, 지연시간)
3. **프롬프트 DB + 파일 이중 관리** — DB에서 버전 관리 + UI + 파일 동기화
4. **파이프라인 단계별 모델 선택** — 프로젝트 설정에서 각 단계 provider/model 변경
5. **체크포인트/resume 강화** — 모든 단계에서 JSON + DB 이중 저장

## 현재 아키텍처 (삭제 대상)

### 삭제할 파일
```
app/modules/llm/gemini_text_client.py    → LiteLLM으로 대체
app/modules/llm/openai_client.py         → LiteLLM으로 대체
app/modules/llm/gemini_key_pool.py       → LiteLLM Router 멀티키로 대체
app/modules/llm/llm_logger.py            → Opik callback으로 대체
app/modules/llm/llm_router.py            → LiteLLM Router로 대체
```

### 유지할 파일
```
app/modules/llm/gemini_image_client.py   → T2I/I2I 전용 (LiteLLM 미지원)
```

## Phase 1: LiteLLM + Opik 기반 LLM 클라이언트

### 1.1 의존성 설치
```bash
pip install litellm opik
```

### 1.2 새 통합 클라이언트: `app/modules/llm/llm_client.py`
```python
import litellm
from litellm import Router
import opik

# Opik 콜백 설정
litellm.callbacks = ["opik"]
os.environ["OPIK_API_KEY"] = settings.opik_api_key

# LiteLLM Router (멀티키)
router = Router(model_list=[
    # Gemini 키 5개
    {"model_name": "gemini-pro", "litellm_params": {
        "model": "gemini/gemini-3.1-pro-preview",
        "api_key": key}} for key in gemini_keys
] + [
    # OpenAI
    {"model_name": "gpt", "litellm_params": {
        "model": "gpt-5.5",
        "api_key": openai_key}},
])

def call_structured(step, system, user, schema, project_llm_config):
    """통합 structured output 호출."""
    model = resolve_model(step, project_llm_config)
    response = router.completion(
        model=model,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        response_format={"type": "json_schema", "json_schema": schema},
        metadata={"opik": {
            "project_name": "theroad",
            "tags": [step],
        }},
    )
    return json.loads(response.choices[0].message.content)
```

### 1.3 Opik 트레이스 구조
```
trace: "analysis:episode_abc123"
  ├─ span: "entity_style" (gemini-3.1-pro, 2.3s, 1200 tokens)
  ├─ span: "entity_names" (gemini-3.1-pro, 1.8s, 800 tokens)
  ├─ span: "entity_review" (gpt-5.5, 3.1s, 2000 tokens)
  ├─ span: "scene_segmentation" (gemini-lite, 0.8s, 500 tokens)
  ├─ span: "outlook_extraction" (gemini-pro, 4.2s, 3000 tokens)
  ├─ span: "scene_detail_01" (gpt-5.5, 1.5s, 600 tokens)
  ├─ span: "scene_detail_02" (gpt-5.5, 1.2s, 550 tokens)
  │   ... (병렬 실행)
  └─ span: "scene_detail_48" (gpt-5.5, 1.4s, 580 tokens)

trace: "image_gen:episode_abc123"
  ├─ span: "world_guide" (gpt-5.5, 5.0s, 4000 tokens)
  ├─ span: "ref_image_gen:동녘" (gemini-image, 12s, file: ref_001.png)
  ├─ span: "prompt_translate_01" (gemini-flash, 0.5s, 200 tokens)
  ├─ span: "scene_image_01" (gemini-image, 15s, file: scene_001.png)
  ├─ span: "fal_angle_01" (fal.ai, 8s, H=45 V=-20 Z=5, file: scene_002.png)
  └─ span: "gpt_select_01" (gpt-5.5-vision, 3s, selected: scene_001.png)
```

## Phase 2: 프롬프트 DB + 파일 이중 관리

### 2.1 DB 테이블: `prompt_template`
```sql
CREATE TABLE prompt_template (
    id TEXT PRIMARY KEY,
    module TEXT NOT NULL,        -- 'scene_extractor_v2', 'outlook_extractor', ...
    name TEXT NOT NULL,          -- 'system', 'turn_scene_detail', 'extract_prompt', ...
    version TEXT NOT NULL,       -- '6.202603202200'
    content TEXT NOT NULL,       -- 프롬프트 텍스트
    schema_json TEXT,            -- JSON schema (있으면)
    is_active BOOLEAN DEFAULT TRUE,  -- 현재 활성 버전
    created_at TEXT NOT NULL,
    created_by TEXT,
    UNIQUE(module, name, version)
);
```

### 2.2 프롬프트 로드 순서
1. DB에서 `module + name + is_active=true` 조회
2. 없으면 파일 시스템에서 최신 버전 로드
3. 새 프롬프트 저장 시 DB + 파일 동시 저장

### 2.3 프롬프트 관리 UI
- 모듈별 프롬프트 목록
- 버전 히스토리
- 인라인 편집 + 저장
- A/B 비교 (두 버전 diff)

## Phase 3: 파이프라인 모듈 리팩토링

### 3.1 각 모듈 변경 패턴
```python
# Before:
from app.modules.llm.gemini_text_client import GeminiTextClient
client = GeminiTextClient()
result = client.send_structured(user_message=prompt, ...)

# After:
from app.modules.llm.llm_client import call_structured
result = call_structured(
    step="scene_detail",
    system=system_prompt,
    user=turn_msg,
    schema=scene_detail_schema,
    project_llm_config=project_llm_config,
)
```

### 3.2 변경 대상 모듈 (13개 단계)
| # | 단계 | 파일 | 현재 | 변경 |
|---|------|------|------|------|
| 1 | entity_style | entity_extractor_v2.py:305 | GeminiTextClient | call_structured |
| 2 | entity_names | entity_extractor_v2.py:321 | GeminiTextClient | call_structured |
| 3 | entity_review | entity_extractor_v2.py:338 | OpenAI urllib | call_structured |
| 4 | entity_detail | entity_extractor_v2.py:461 | GeminiTextClient | call_structured |
| 5 | scene_segmentation | scene_extractor_v2.py:316 | GeminiTextClient(lite) | call_structured |
| 6 | scene_split | scene_extractor_v2.py:206 | GeminiTextClient(flash) | call_structured |
| 7 | scene_dependency | scene_dependency_extractor.py:55 | GeminiTextClient | call_structured |
| 8 | outlook_extraction | outlook_extractor.py:82 | ✅ 이미 call_structured | 유지 |
| 9 | scene_detail | scene_extractor_v2.py:691 | ✅ 이미 call_structured | 유지 |
| 10 | world_guide | world_guide_generator.py:128 | OpenAIClient | call_structured |
| 11 | prompt_translation | image_service.py:118 | GeminiTextClient | call_structured |
| 12 | prompt_sanitize | prompt_sanitizer.py | OpenAIClient | call_structured |
| 13 | project_summary | analysis_service.py:367 | GeminiTextClient | call_text |

## Phase 4: 병렬 구조 + Opik 추적

### 4.1 현재 병렬 구조
```python
with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {executor.submit(_process_scene, seg): seg for seg in segments}
    for future in as_completed(futures):
        result = future.result()
```

### 4.2 Opik 적용 병렬 구조
```python
import opik

@opik.track(name="analysis_pipeline")
def run_analysis(episode_id):
    # Phase 1
    entities = extract_entities(...)  # 자동 추적

    # Phase 2 — 병렬 씬 분석
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = {}
        for seg in segments:
            futures[executor.submit(_process_scene_tracked, seg)] = seg
        ...

@opik.track(name="scene_detail")
def _process_scene_tracked(seg):
    result = call_structured(step="scene_detail", ...)
    return result
```

### 4.3 이미지 생성 수동 span
```python
import opik

client = opik.Opik()
trace = client.trace(name="image_gen:episode_abc")

# T2I 생성
span = trace.span(name=f"scene_image:{still_index}")
span.update(input={"prompt": t2i_prompt, "refs": ref_files})
image_bytes = gemini_image_client.generate_image(...)
span.update(output={"file": file_path, "model": "gemini-image"})
span.end()
```

## Phase 5: 체크포인트/Resume 강화

### 5.1 모든 단계 체크포인트
| 단계 | 파일 체크포인트 | DB |
|------|----------------|-----|
| 요소 추출 | ✅ 기존 유지 | EntityCanon |
| 세그먼테이션 | ✅ segments.json | - |
| 씬 연관 | ✅ scene_dependencies.json | - |
| 아웃룩 | ✅ outlook_extraction.json | EntityCanon(outlook) |
| 씬 상세 | ✅ scene_extraction_{hash}.json | SceneStill |
| 참조 이미지 | ✅ reference_checkpoint.json (NEW) | ImageAsset |
| 씬 이미지 | ✅ scene_checkpoint.json (NEW) | ImageAsset |

### 5.2 전 단계 검증 가드
```
분석 완료 → 참조 이미지 생성 가능
참조 이미지 > 0 → 씬 이미지 생성 가능
```

## 구현 순서

1. **Phase 1.1**: `pip install litellm opik` + config
2. **Phase 1.2**: `llm_client.py` 새 통합 클라이언트
3. **Phase 1.3**: entity_extractor 마이그레이션 (가장 복잡)
4. **Phase 1.4**: 나머지 11개 단계 마이그레이션
5. **Phase 1.5**: 기존 클라이언트 삭제
6. **Phase 2.1**: prompt_template 테이블 + API
7. **Phase 2.2**: 기존 파일 프롬프트 DB 이관
8. **Phase 2.3**: 프롬프트 관리 UI
9. **Phase 3**: 프로젝트 LLM 설정 UI (이미 구현됨, 백엔드만 LiteLLM 연결)
10. **Phase 4**: Opik 트레이스 데코레이터 적용
11. **Phase 5**: 체크포인트 강화 (이미 일부 구현됨)

## 리스크

1. **LiteLLM Gemini structured output** — `response_format` 지원 여부 실제 테스트 필요
2. **Opik 병렬 span** — ThreadPoolExecutor 내에서 span context 전파 확인 필요
3. **프롬프트 DB 이관** — 95개 파일 자동 이관 스크립트 필요
4. **기존 프로젝트 호환** — 마이그레이션 후 기존 분석 결과 유지
