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
co-authored by Claude Opus 4.6
parent 7038418c8b
commit a01f7a7f8a
8 changed files with 519 additions and 152 deletions
+75 -33
View File
@@ -111,20 +111,14 @@ async def fill_content(
)
try:
# 1차: Kei API (도메인 전문가 + RAG)
# Kei API만 사용. Sonnet fallback 없음.
result_text = await _call_kei_editor(user_prompt)
# fallback: Anthropic 직접
# G-6: Kei API 실패 시 None 가드
if result_text is None:
logger.warning("Kei API 편집 실패. Anthropic 직접 호출로 fallback.")
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=EDITOR_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
result_text = response.content[0].text
logger.warning("Kei API 편집 실패. 기본값 적용.")
_apply_defaults(blocks)
continue
filled = _parse_json(result_text)
@@ -165,7 +159,7 @@ async def fill_content(
async def _call_kei_editor(prompt: str) -> str | None:
"""Kei API를 통해 텍스트 편집을 요청한다.
"""Kei API를 통해 텍스트 편집을 요청한다. SSE 스트리밍으로 실시간 수신.
Kei persona의 도메인 지식 + RAG를 활용하여
건설/DX 분야 전문 용어를 정확하게 유지하면서 편집.
@@ -176,22 +170,22 @@ async def _call_kei_editor(prompt: str) -> str | None:
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": full_prompt,
"session_id": "design-agent-editor",
"mode": "chat",
"mode_hint": "chat",
},
timeout=None,
)
) as response:
if response.status_code != 200:
logger.warning(f"Kei API (editor) HTTP {response.status_code}")
return None
if response.status_code != 200:
logger.warning(f"Kei API (editor) HTTP {response.status_code}")
return None
full_text = await _stream_sse_tokens(response)
# SSE 응답에서 텍스트 수집
full_text = _extract_sse_text(response.text)
if full_text:
return full_text
@@ -203,6 +197,36 @@ async def _call_kei_editor(prompt: str) -> str | None:
return None
async def _stream_sse_tokens(response: httpx.Response) -> str:
"""SSE 스트리밍 응답에서 토큰을 실시간 수집한다."""
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 응답에서 토큰 텍스트를 수집한다."""
import re as _re
@@ -287,17 +311,35 @@ def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
def _parse_json(text: str) -> dict[str, Any] | None:
"""텍스트에서 JSON을 추출한다."""
patterns = [
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
]
for pattern in patterns:
match = re.search(pattern, text, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
"""텍스트에서 JSON을 추출한다.
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
"""
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
lines = text.split("\n")
cleaned_lines = []
for line in lines:
stripped = line.lstrip()
if stripped.startswith("- "):
cleaned_lines.append(stripped[2:])
elif stripped.startswith("* "):
cleaned_lines.append(stripped[2:])
else:
cleaned_lines.append(stripped)
cleaned = "\n".join(cleaned_lines)
# 원본 먼저 시도 → 클린 버전 시도
for target in [text, cleaned]:
patterns = [
r"```json\s*(.*?)```",
r"```\s*(.*?)```",
r"(\{.*\})",
]
for pattern in patterns:
match = re.search(pattern, target, re.DOTALL)
if match:
try:
return json.loads(match.group(1).strip())
except json.JSONDecodeError:
continue
return None