Phase G: Kei API 통신 정상화 — streaming 전환 + Sonnet fallback 제거

G-1: httpx non-streaming → streaming 전환 (3개 파일)
  - client.post() → client.stream("POST") + response.aiter_lines()
  - SSE 토큰을 실시간 수신 (30분+ 무응답 해소)

G-2: Sonnet fallback 완전 제거
  - kei_client.py: classify_content()에서 _call_anthropic_direct() 호출 제거
  - content_editor.py: fill_content()에서 Sonnet fallback 분기 제거
  - Kei API만 사용. 실패 시 manual_classify() 또는 _apply_defaults() 안전망

G-3: _parse_json() 마크다운 제거 3파일 동기화
  - content_editor.py, design_director.py에 kei_client.py와 동일한 전처리 추가

G-4: FAISS를 CPU로 전환 (GPU 메모리 경쟁 해소)
  - block_search.py + build_block_index.py: device="cpu"

G-5: streaming 파서에 event:error 처리
  - persona_agent 에러 시 무한 대기 방지. 즉시 중단.

G-6: content_editor.py None 가드
  - Kei API 실패 시 _parse_json(None) TypeError 방지

G-7: "mode" → "mode_hint" 필드명 수정 (3개 파일)
  - persona_agent의 실제 필드명에 맞춤

persona_agent 수정: 0건

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 01:26:03 +09:00
parent 7038418c8b
commit a01f7a7f8a
8 changed files with 519 additions and 152 deletions

View File

@@ -63,10 +63,9 @@ KEI_PROMPT = (
async def classify_content(content: str) -> dict[str, Any] | None:
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
1차: Kei API (persona + RAG + 사고)
fallback: Anthropic API 직접 호출
Kei API만 사용. Sonnet fallback 없음.
Kei API 실패 시 None 반환 → pipeline.py에서 manual_classify() 안전망.
"""
# 1차: Kei API
result = await _call_kei_api(content)
if result:
logger.info(
@@ -75,47 +74,36 @@ async def classify_content(content: str) -> dict[str, Any] | None:
)
return result
# fallback: Anthropic 직접
logger.warning("Kei API 실패. Anthropic 직접 호출로 fallback.")
result = await _call_anthropic_direct(content)
if result:
logger.info(
f"[Anthropic] 꼭지 추출 완료: {result.get('title', '')}, "
f"{len(result.get('topics', []))}개 꼭지"
)
return result
logger.warning("[Kei API] 꼭지 추출 실패. manual_classify로 안전망 적용.")
return None
async def _call_kei_api(content: str) -> dict[str, Any] | None:
"""Kei API를 통해 꼭지 추출. SSE 스트리밍 응답을 파싱."""
"""Kei API를 통해 꼭지 추출. SSE 스트리밍으로 실시간 수신."""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
try:
async with httpx.AsyncClient(timeout=None) as client:
response = await client.post(
async with client.stream(
"POST",
f"{kei_url}/api/message",
json={
"message": KEI_PROMPT + content,
"session_id": "design-agent",
"mode": "chat",
"mode_hint": "chat",
},
timeout=None,
)
) as response:
if response.status_code != 200:
logger.warning(f"Kei API HTTP {response.status_code}")
return None
if response.status_code != 200:
logger.warning(f"Kei API HTTP {response.status_code}")
return None
# SSE 응답에서 토큰 수집
full_text = _extract_sse_text(response.text)
full_text = await _stream_sse_tokens(response)
if not full_text:
logger.warning("Kei API 응답에서 텍스트 추출 실패")
return None
# JSON 추출
result = _parse_json(full_text)
if result and "topics" in result:
return result
@@ -128,6 +116,43 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
return None
async def _stream_sse_tokens(response: httpx.Response) -> str:
"""SSE 스트리밍 응답에서 토큰을 실시간 수집한다.
persona_agent의 SSE 이벤트:
- token: 텍스트 토큰 수집
- done: 완료, 중단
- error: 에러, 즉시 중단
- planning/planning_done/research_progress/warning: 스킵
"""
tokens: list[str] = []
event_type = ""
async for line in response.aiter_lines():
line = line.strip()
if not line:
event_type = ""
continue
if line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
data = line[5:].strip()
if event_type == "token" and data:
try:
token = json.loads(data)
if isinstance(token, str):
tokens.append(token)
except json.JSONDecodeError:
tokens.append(data)
elif event_type == "done":
break
elif event_type == "error":
logger.warning(f"Kei API SSE 에러: {data}")
break
return "".join(tokens)
def _extract_sse_text(raw: str) -> str:
"""SSE 응답에서 토큰 텍스트를 수집한다. CRLF/LF 모두 처리."""
tokens = []