# Phase G: Kei API 통신 정상화 — 실행 상세

> Kei persona_agent와의 통신이 실패하는 근본 원인 해결.
> **design_agent만 수정. persona_agent 코드 수정 0건.**
> 원칙: Kei API만 사용. Sonnet fallback 금지. 하드코딩 금지. 회귀 금지.

---

## 문제 진단 요약

Kei API 호출 시 30분~1시간 무응답 후 BrokenResourceError로 끊김.
원인은 design_agent가 SSE 스트리밍 응답을 non-streaming 방식으로 받고 있어서,
persona_agent의 전체 파이프라인(RAG + Opus planning + Sonnet 응답)이 끝날 때까지 대기.

---

## G-1: httpx non-streaming → streaming 전환 (핵심)

### 문제
```python
# 현재 코드 (3개 파일 동일 패턴)
response = await client.post(url, json={...}, timeout=None)
full_text = response.text  # ← 전체 응답 완료까지 대기 (30분+)
```

persona_agent는 `EventSourceResponse`로 SSE 스트리밍 응답을 보냄.
httpx `client.post()`는 응답 body 전체가 끝날 때까지 버퍼링.
persona_agent 파이프라인(RAG→Opus→Sonnet)이 전부 끝나야 response.text 반환.

### 해결
`httpx.AsyncClient.stream()` 사용하여 SSE 토큰을 실시간 수신:

```python
async with client.stream("POST", url, json={...}) as response:
    async for line in response.aiter_lines():
        # SSE 이벤트를 한 줄씩 실시간 처리
        if line.startswith("event:"):
            event_type = line[6:].strip()
        elif line.startswith("data:"):
            event_data = line[5:].strip()
            if event_type == "token":
                tokens.append(parse_token(event_data))
            elif event_type == "done":
                break
```

### 수정 파일 (3개)
- `src/kei_client.py` — `_call_kei_api()`: 1단계 Kei 실장
- `src/content_editor.py` — `_call_kei_editor()`: 3단계 Kei 편집자
- `src/design_director.py` — `_opus_block_recommendation()`: 2단계 Opus 추천

### 충돌/회귀
- persona_agent 변경 없음 ✅
- SSE 이벤트 형식(event:/data:) 동일 — 파싱 로직만 실시간으로 전환
- `_extract_sse_text()` 함수를 `_stream_sse_text()`로 대체 (streaming 버전)

---

## G-2: Sonnet fallback 완전 제거

### 문제
사용자 요청: "무조건 늦더라도 persona agent에게 요청해서 답을 받아"
현재 코드: Kei API 실패 → Sonnet 직접 호출로 fallback

### 해결
- `kei_client.py`: `_call_anthropic_direct()` 호출 제거. Kei API 실패 시 에러 반환 또는 재시도.
- `content_editor.py`: Sonnet fallback 제거. Kei API만 사용.
- Kei API 실패 시: 로그에 에러 기록 + SSE로 사용자에게 "Kei API 연결 실패" 알림

### 수정 파일
- `src/kei_client.py` — `classify_content()`에서 Sonnet fallback 분기 제거
- `src/content_editor.py` — `fill_content()`에서 Sonnet fallback 분기 제거

### 충돌/회귀
- `_call_anthropic_direct()` 함수는 남겨도 됨 (호출만 안 함). 또는 삭제.
- manual_classify()는 유지 (Kei API 자체가 완전히 불가능할 때의 최소 안전망)

---

## G-3: `_parse_json()` 마크다운 제거 — 3개 파일 동기화

### 문제
`kei_client.py`에만 `- ` 접두사 제거 로직이 있고,
`content_editor.py`와 `design_director.py`의 `_parse_json()`에는 없음.
Kei가 마크다운으로 JSON을 감싸면 3단계/2단계에서 파싱 실패.

### 해결
`content_editor.py`와 `design_director.py`의 `_parse_json()`에
`kei_client.py`와 동일한 마크다운 접두사 제거 전처리 추가.

### 수정 파일
- `src/content_editor.py` — `_parse_json()`
- `src/design_director.py` — `_parse_json()`

### 충돌/회귀
- 정상 JSON은 기존과 동일하게 파싱 (원본 먼저 시도 → 클린 버전 시도)
- 마크다운 JSON만 추가로 파싱 가능해짐

---

## G-4: FAISS를 CPU로 전환 (GPU 메모리 경쟁 해소)

### 문제
persona_agent가 GPU에 bge-m3 + reranker 로딩 (수 GB).
design_agent의 FAISS도 bge-m3을 GPU에 로드하려고 시도 → OOM:
```
not enough memory: you tried to allocate 1024008192 bytes
```

### 해결
`src/block_search.py`에서 SentenceTransformer를 CPU로 강제:

```python
_model = SentenceTransformer(EMBEDDING_MODEL, device="cpu")
```

`scripts/build_block_index.py`에서도 동일하게 CPU 지정.

46개 블록 검색은 CPU로도 충분히 빠름 (< 1초).

### 수정 파일
- `src/block_search.py` — `_ensure_loaded()`에서 device="cpu"
- `scripts/build_block_index.py` — SentenceTransformer에 device="cpu"

### 충돌/회귀
- persona_agent 영향 없음 (GPU 독점 사용 가능)
- 검색 속도: GPU 0.03초 → CPU 0.1~0.3초 (46개 블록 기준, 무시할 수준)

---

## G-5: streaming 파서에 event: error 처리 추가 (정밀 검토에서 발견)

### 문제
persona_agent가 에러 발생 시 `event: error` SSE 이벤트를 보냄.
현재 design_agent는 `token`과 `done`만 처리하고 `error`를 무시.
→ streaming 전환 후 persona_agent 에러 시 `done`을 기다리며 **무한 대기**.

### 해결
3개 파일의 streaming 파서에서 `event: error` 시 즉시 중단 + 에러 로그:

```python
elif event_type == "error":
    logger.warning(f"Kei API 에러: {data}")
    break  # 즉시 중단
```

또한 `planning`, `planning_done`, `research_progress`, `warning` 이벤트는 스킵 (기존 동작 유지).

### 수정 파일
- `src/kei_client.py`, `src/content_editor.py`, `src/design_director.py` (G-1과 같은 위치)

---

## G-6: content_editor.py None 가드 추가 (정밀 검토에서 발견)

### 문제
G-2에서 Sonnet fallback 제거 후, Kei API 실패 시 `result_text = None`.
`_parse_json(None)` 호출 → TypeError/AttributeError 발생.
except로 잡히지만 불필요한 예외.

### 해결
```python
result_text = await _call_kei_editor(user_prompt)
if result_text is None:
    logger.warning("Kei API 편집 실패. 기본값 적용.")
    _apply_defaults(blocks)
    continue  # 다음 페이지로
```

### 수정 파일
- `src/content_editor.py` — `fill_content()` 내

---

## G-7: `"mode"` → `"mode_hint"` 필드명 수정 (정밀 검토에서 발견)

### 문제
design_agent가 `"mode": "chat"`을 보내지만, persona_agent의 `UnifiedMessageRequest`는
`mode_hint` 필드를 사용. `mode`는 무시됨. 현재 우연히 chat으로 분류되지만 명시적이지 않음.

### 해결
3개 호출처에서 `"mode": "chat"` → `"mode_hint": "chat"` 변경.

### 수정 파일
- `src/kei_client.py` — `_call_kei_api()` json body
- `src/content_editor.py` — `_call_kei_editor()` json body
- `src/design_director.py` — `_opus_block_recommendation()` json body

---

## 수정 파일 총괄

| 파일 | 항목 | 변경 성격 |
|------|------|----------|
| `src/kei_client.py` | G-1, G-2, G-5, G-7 | httpx streaming + Sonnet fallback 제거 + error 처리 + mode_hint |
| `src/content_editor.py` | G-1, G-2, G-3, G-5, G-6, G-7 | httpx streaming + fallback 제거 + _parse_json 동기화 + error 처리 + None 가드 + mode_hint |
| `src/design_director.py` | G-1, G-3, G-5, G-7 | httpx streaming + _parse_json 동기화 + error 처리 + mode_hint |
| `src/block_search.py` | G-4 | SentenceTransformer device="cpu" |
| `scripts/build_block_index.py` | G-4 | SentenceTransformer device="cpu" |

**persona_agent 수정: 0건**

---

## 예상 효과

| 항목 | 현재 | 수정 후 |
|------|------|--------|
| 1단계 Kei API 대기 | 30분+ (전체 완료 대기) → 실패 | 토큰 실시간 수신 → 정상 완료 |
| 3단계 Kei API 대기 | 6분+ → 간헐적 실패 | 토큰 실시간 수신 → 안정적 |
| 2단계 Opus 추천 | 6분 대기 | 토큰 실시간 수신 → 체감 빨라짐 |
| Sonnet fallback | Kei 실패 시 자동 전환 | 제거. Kei만 사용. |
| GPU OOM | FAISS + persona 경쟁 | FAISS CPU 전환 → 경쟁 없음 |

---

## 검증 체크리스트

- [ ] G-1: Kei API SSE 토큰이 실시간으로 수신되는지 (로그에 토큰 출력)
- [ ] G-1: 30분 무응답 없이 정상 완료
- [ ] G-2: Kei API 실패 시 Sonnet fallback이 발동하지 않는지
- [ ] G-3: content_editor, design_director에서 마크다운 JSON 파싱 성공
- [ ] G-4: FAISS 로드 시 GPU OOM 없음. CPU에서 정상 동작.
- [ ] G-5: persona_agent 에러 시 무한 대기 안 하고 즉시 중단
- [ ] G-6: Kei API 실패 시 TypeError 없이 _apply_defaults 적용
- [ ] G-7: persona_agent 로그에 mode_hint=chat 확인
- [ ] persona_agent 코드 변경 0건 확인

---

## 수정 이력

| 날짜 | 내용 |
|------|------|
| 2026-03-25 | 초안. Kei API 통신 실패 원인 5개 진단 + 4개 수정 항목 정리. |
| 2026-03-25 | 정밀 검토로 G-5/G-6/G-7 추가. 총 7개 항목. |
| 2026-03-26 | Phase G 전체 구현 완료. 검증 통과. |

## 구현 완료 확인

| 항목 | 검증 결과 |
|------|----------|
| G-1 | 3개 파일 모두 `client.stream("POST")` + `response.aiter_lines()` 전환. 기존 `client.post()` 0건. `_stream_sse_tokens()` 함수로 SSE 실시간 수신. |
| G-2 | kei_client.py: `_call_anthropic_direct()` 호출 제거. content_editor.py: Sonnet fallback 분기 제거. Sonnet 직접 호출 0건. |
| G-3 | content_editor.py + design_director.py의 `_parse_json()`에 마크다운 `- ` 접두사 제거 전처리 추가. "원본 먼저 → 클린 버전" 순서 유지. kei_client.py와 동기화. |
| G-4 | block_search.py + build_block_index.py: `SentenceTransformer(EMBEDDING_MODEL, device="cpu")`. persona_agent GPU 독점 가능. |
| G-5 | 3개 파일의 `_stream_sse_tokens()`에서 `event_type == "error"` 시 로그 + break. 무한 대기 방지. |
| G-6 | content_editor.py: Kei API 실패(`result_text is None`) 시 `_apply_defaults(blocks); continue`. `_parse_json(None)` TypeError 방지. |
| G-7 | 3개 파일의 API 요청 body에서 `"mode": "chat"` → `"mode_hint": "chat"`. persona_agent의 실제 필드명에 맞춤. |

### 파일별 구현 상세

**kei_client.py:**
- `classify_content()`: Sonnet fallback 6줄 제거. Kei API 실패 → None 반환 → pipeline.py manual_classify() 안전망.
- `_call_kei_api()`: `client.post()` → `client.stream("POST")` + `_stream_sse_tokens(response)`. `"mode"` → `"mode_hint"`.
- `_stream_sse_tokens()`: 신규 함수. `aiter_lines()`로 SSE 실시간 수신. token/done/error 처리.

**content_editor.py:**
- `fill_content()`: Sonnet fallback 7줄 제거. `result_text is None` 시 `_apply_defaults(blocks); continue`.
- `_call_kei_editor()`: `client.post()` → `client.stream("POST")` + `_stream_sse_tokens(response)`. `"mode"` → `"mode_hint"`.
- `_stream_sse_tokens()`: 신규 함수. kei_client.py와 동일 패턴.
- `_parse_json()`: 마크다운 `- ` 접두사 제거 전처리 추가. 원본 먼저 → 클린 버전.

**design_director.py:**
- `_opus_block_recommendation()`: 인라인 SSE 파싱 30줄 → `client.stream("POST")` + `_stream_sse_tokens(response)`. `"mode"` → `"mode_hint"`.
- `_stream_sse_tokens()`: 신규 함수. 동일 패턴.
- `_parse_json()`: 마크다운 제거 전처리 추가. 원본 먼저 → 클린 버전.
- `import httpx` 모듈 레벨로 이동 (기존 지역 import → `_stream_sse_tokens`에서도 참조 가능).

**block_search.py + build_block_index.py:**
- `SentenceTransformer(EMBEDDING_MODEL)` → `SentenceTransformer(EMBEDDING_MODEL, device="cpu")`
