## 상위 원칙

- [0_VALUE Coding Principles](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/coding-principles.md)
- [0_VALUE Infrastructure SSOT Principle](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/infrastructure-ssot-principle.md)
- [workspace-config 루트기준 SSOT와 하드코딩 분산 문제 오픈](../troubleshooting/260316_workspace_config_루트기준_SSOT와_하드코딩_분산_문제오픈.md)
- [모델 SSOT 하드코딩 분산과 workspace-config 로컬이식 통합 리서치](../research/260315_모델SSOT_하드코딩_분산과_workspace_config_로컬이식_통합리서치.md)

# LLM 모델 SSOT 전환 계획

## 상태

- in_progress

## 목표

- 주 LLM 모델 기본값을 `workspace-config/runtime.env` 중심으로 해석하도록 고정합니다.
- 과도기에는 최소 `rb8001/.env`까지만 수정하면 실제 기본 모델이 바뀌도록 맞춥니다.
- 예외적으로 API 요청에서 `model`을 명시하면 그 입력값만으로 실제 호출 모델이 바뀌게 합니다.
- 새 추상화 계층을 늘리지 않고 기존 `LLMService` 중심으로 우회 경로를 제거합니다.

## 범위

### 포함

- `rb8001`의 모델 기본값/직접 handler 생성/직접 Gemini SDK 호출/fallback 배열 정리
- `skill-slack`의 하드코딩 모델 문자열 정리
- 전수 확인으로 드러난 `rb8001`의 추가 직접 생성 경로 정리
- `skill-news`의 독자 `GEMINI_MODEL` SSOT와 직접 Gemini SDK 호출 정리
- 모델 변경 검증 체크리스트 고정

### 포함 근거

- 리서치 기준 `skill-news`는 단순 모델 문자열 1~2개가 아니라, `README`, `.env.example`, `docker-compose.yml`, 서비스 코드, API 모듈 import 시점 초기화가 함께 `.env`의 `GEMINI_MODEL`을 독자 SSOT로 강화하고 있습니다.
- 따라서 `skill-news`를 범위 밖으로 두면 로빙 전체 관점에서 "주 모델 변경이 SSOT 1회 수정으로 닫힌다"는 목표를 충족할 수 없습니다.
- 특히 `skill-news/app/api/news_endpoints.py`의 `news_summarizer = NewsSummarizer()`는 프로세스 시작 시점에 현재 모델 계약을 고정하므로, 단순 문서 수정이나 compose 수정만으로는 충분하지 않습니다.

### 제외

- 새 provider registry 파일 추가
- 새 gateway 패키지 추가
- 새 request schema 계층 추가
- 서버 배포 작업 자체

## 파일별 변경 계약

### 1. 기본 경로 정리

- [rb8001/app/services/llm/llm_service.py](../../../../rb8001/app/services/llm/llm_service.py)
  - 기본 모델 해석과 요청 모델 override 해석을 한 곳으로 모읍니다.
  - provider/model 선택은 이 서비스가 담당합니다.
- [rb8001/app/core/config.py](../../../../rb8001/app/core/config.py)
  - `DEFAULT_LLM_MODEL` 설정 경로를 유지하되, 최종 해석 원천은 `workspace-config/runtime.env`로 둡니다.

### 2. 우회 경로 제거

- [rb8001/app/services/llm/gemini_handler.py](../../../../rb8001/app/services/llm/gemini_handler.py)
  - `GEMINI_MODEL` env 우회 제거
- [rb8001/app/router/llm_endpoint.py](../../../../rb8001/app/router/llm_endpoint.py)
  - 직접 `GeminiHandler` 생성 제거
- [rb8001/app/services/coldmail_llm_classifier.py](../../../../rb8001/app/services/coldmail_llm_classifier.py)
  - 직접 `genai.GenerativeModel(...)` 생성 제거
- [rb8001/app/services/ir_analyzer.py](../../../../rb8001/app/services/ir_analyzer.py)
  - 파일 내부 fallback 배열 제거 또는 중앙 정책으로 이동
- [rb8001/app/services/diary/generator.py](../../../../rb8001/app/services/diary/generator.py)
  - 직접 `GeminiHandler()` 생성 제거 검토
- [rb8001/app/services/workflows/headlines_workflow.py](../../../../rb8001/app/services/workflows/headlines_workflow.py)
  - `or GeminiHandler(default_model)` 우회 제거

### 3. 스킬 계층 정리

- [skill-slack/app/services/digest.py](../../../../skill-slack/app/services/digest.py)
- [skill-slack/app/services/action_extractor.py](../../../../skill-slack/app/services/action_extractor.py)
- [skill-slack/app/services/summarizer.py](../../../../skill-slack/app/services/summarizer.py)
  - 하드코딩 모델 문자열을 설정값 또는 API 입력값으로 승격합니다.
- [skill-slack/app/core/config.py](../../../../skill-slack/app/core/config.py)
  - 필요 최소한의 기본 모델 설정만 추가 검토합니다.

### 4. skill-news 정리

- [skill-news/app/services/news_summarizer.py](../../../../skill-news/app/services/news_summarizer.py)
- [skill-news/app/services/companyx_news_summarizer.py](../../../../skill-news/app/services/companyx_news_summarizer.py)
- [skill-news/app/services/sea_news_filter.py](../../../../skill-news/app/services/sea_news_filter.py)
  - 직접 `genai.GenerativeModel(...)` 생성과 `GEMINI_MODEL` 독자 SSOT를 정리합니다.
- [skill-news/app/api/news_endpoints.py](../../../../skill-news/app/api/news_endpoints.py)
  - import 시점 `NewsSummarizer()` 생성이 어떤 설정 계약을 고정하는지 정리합니다.
- [skill-news/docker-compose.yml](../../../../skill-news/docker-compose.yml)
  - 모델 주입을 `workspace-config/runtime.env` 기준으로 재해석할지 확정합니다.
- [skill-news/README.md](../../../../skill-news/README.md)
- [skill-news/.env.example](../../../../skill-news/.env.example)
  - 문서 SSOT 표현을 코드/운영 계약과 맞춥니다.

## 수정 규모 기준

### 1. 최소 직접 수정 범위

- `rb8001` 9개 내외
- `skill-slack` 4개 내외
- `skill-news` 6개 내외
  - 서비스 코드 4개
  - compose 1개
  - 문서/예시 2개 중 계약상 필수 범위 포함
- 문서/테스트 제외 최소 직접 수정 예상은 `19개 내외`로 봅니다.

### 2. 완전 종결 범위

- 위 최소 범위 외에도 compose의 `.env` 오버라이드, `workspace-config` 주입 순서, 서비스별 예외를 더 확인해야 합니다.
- `skill-news`는 독자 제품 SSOT처럼 운영돼 온 흔적이 있어, 계획 실행 중 예외 선언으로 분리할지 통합 전환할지 정책 판단이 추가로 필요할 수 있습니다.
- 따라서 이 계획은 `핵심 SSOT 경로 복구 계획`이고, `로빙 전체 workspace-config 완전 종결 계획`과는 범위를 구분합니다.

## 정책 판단

### 1. 유지 가능한 정책

- API 요청에서 `model` override 허용
- `skill-slack`의 skill level별 동작 차이 자체는 유지 가능

### 2. 제거해야 할 하드코딩

- `GEMINI_MODEL` 별도 env 우회
- 파일별 fallback 배열
- router/service의 직접 handler 생성
- 서비스 파일 안의 직접 Gemini/OpenAI SDK 생성
- `skill-slack`의 고정 모델 문자열
- `skill-news`의 `.env` 기반 독자 모델 SSOT 표현
- import 시점 초기화가 특정 모델 계약을 고정하는 구조

### 3. 추가하지 않을 것

- 새 manager/registry/wrapper 계층
- 새 fallback registry 파일
- 새 provider enum 파일

## 검증 체크리스트

### 1. 기본 모델 변경 검증

- `workspace-config/runtime.env`의 `DEFAULT_LLM_MODEL` 변경 시 기본 호출 모델이 바뀝니다.
- 과도기에는 최소 `rb8001/.env`까지만 함께 수정하면 기본 호출 모델이 바뀝니다.
- 그보다 더 많은 파일 수정을 요구하지 않습니다.

### 2. API 입력 override 검증

- `LLMRequest.model` 또는 동등한 API 입력 모델값을 명시하면 해당 요청의 실제 호출 모델이 바뀝니다.
- 호출부는 provider SDK를 직접 고르지 않고 `LLMService`만 경유합니다.

### 3. 우회 제거 검증

- `GEMINI_MODEL` env 우회가 제거됩니다.
- `GeminiHandler(...)` 직접 생성이 router/service에서 사라집니다.
- `genai.GenerativeModel(...)` 직접 생성이 공용 경로 밖에서 사라집니다.
- `ir_analyzer.py`의 파일 내부 fallback 배열이 제거되거나 중앙 정책으로 이동합니다.
- `skill-slack`의 하드코딩 모델 문자열이 제거됩니다.
- `diary/generator.py`, `headlines_workflow.py`의 직접 `GeminiHandler` 생성이 제거됩니다.
- `skill-news`의 직접 Gemini SDK 생성과 `GEMINI_MODEL` 독자 SSOT가 정리됩니다.
- `skill-news/app/api/news_endpoints.py`의 import 시점 초기화가 새 계약과 모순되지 않게 정리됩니다.

## 적용 순서

1. `rb8001` 공용 경로 정리
   - `config.py`, `llm_service.py`, `gemini_handler.py`
2. `rb8001` 우회 경로 제거
   - `llm_endpoint.py`, `coldmail_llm_classifier.py`, `ir_analyzer.py`, `diary/generator.py`, `workflows/headlines_workflow.py`
3. `skill-slack` 모델 선택 정리
   - `digest.py`, `action_extractor.py`, `summarizer.py`, 필요 시 `app/core/config.py`
4. `skill-news` 모델 경로 정리
   - `news_summarizer.py`, `companyx_news_summarizer.py`, `sea_news_filter.py`, `docker-compose.yml`, `README.md`, `.env.example`
5. 기본값 변경 검증
   - `workspace-config/runtime.env`
   - 과도기 `rb8001/.env`
   - API 입력 `model` override
6. worklog 작성 후 닫힘 선언

## 레포별 경계

- 1차 대상 레포: `robeing/rb8001`
- 2차 대상 레포: `robeing/skill-slack`
- 3차 대상 레포: `robeing/skill-news`
- 문서/검증 기록 레포: `robeing/DOCS`
- 한 번에 여러 레포를 한 커밋으로 묶지 않습니다.

## 롤백 경계

- `rb8001` 변경은 `rb8001` 레포 안에서만 롤백 판단합니다.
- `skill-slack` 변경은 `skill-slack` 레포 안에서만 롤백 판단합니다.
- `skill-news` 변경은 `skill-news` 레포 안에서만 롤백 판단합니다.
- 문서 변경은 `DOCS` 레포 안에서만 롤백 판단합니다.
- 과도기에는 `workspace-config/runtime.env`와 `rb8001/.env`를 이전 값으로 되돌리는 것이 가장 작은 운영 롤백 단위입니다.

## 진행 기록

- 로컬 1차 구현 및 검증: [260316 LLM 모델 SSOT 1차구현 및 로컬검증](../worklog/260316_llm_model_ssot_1차구현_및_로컬검증.md)

## 닫힘 조건

1. 사용자가 "주 모델을 `--`로 바꾸자"라고 했을 때 기본 수정 지점이 `workspace-config/runtime.env`로 답됩니다.
2. 과도기에도 `workspace-config/runtime.env`와 `rb8001/.env`까지만 수정하면 됩니다.
3. API 입력 모델명으로 요청 단위 override가 가능합니다.
4. 리서치 문서에 적힌 직접 우회 경로가 실제 코드에서 제거됩니다.
5. `skill-news`까지 포함해 독자 모델 SSOT 표현이 정리됩니다.
6. 닫힘 선언은 `worklog`에서만 합니다.
