Phase I 실행 완료 + 프로세스 재설계 (Stage 2.5 → Stage 5)
Phase I: 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14개 항목) - I-14: SSE 유틸 공통 추출 (src/sse_utils.py 신규, 3개 파일 중복 제거) - I-13: dead code 3건 삭제 (_call_anthropic_direct, _extract_sse_text x2) + import anthropic 제거 - I-1: STEP_B_PROMPT purpose 가이드 미존재 블록 3개 → 실존 블록 교체 - I-2: catalog.yaml not_for 13건 미존재 블록 참조 교체/제거 - I-12: BLOCK_SLOTS 주석 개수 수정 (cards 9, visuals 6, emphasis 10) - I-10: INDEX.md 38개 동기화 (삭제된 8개 블록 행 제거) - I-11: README.md 38개 동기화 (_legacy 제거, 트리/개수 정리) - I-3: PURPOSE_FALLBACK 상수 + purpose 기반 미등록 블록 교체 - I-7: compare-pill-pair 단독 사용 금지 검증 - I-4: 38개 블록 전체에 slot_desc 추가 - I-5: 편집자 프롬프트에 slot_desc 전달 로직 - I-6: 제목 유사도 70% 초과 시 자동 교정 - I-9: 넘침 판단 Kei API 호출 (KEI_OVERFLOW_PROMPT, call_kei_overflow_judgment) - I-8: 대형 콘텐츠 정보 Kei overflow 프롬프트에 포함 프로세스 재설계: - Stage 2.5 제거 → Stage 5에서 Sonnet 감지 + Kei 판단 통합 - _review_balance() 확장: zone 예산 + overflow_detected action 추가 - Stage 5 루프에 Kei 넘침 판단 호출 통합 - _apply_adjustments()에 kei_trim/kei_restructure action 추가 - _build_overflow_context(), _convert_kei_judgment() 헬퍼 함수 추가 - DOWNGRADE_MAP은 Kei API 실패 시 비상용으로만 잔존 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,10 @@ import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
|
||||
from src.config import settings
|
||||
from src.sse_utils import stream_sse_tokens
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -167,7 +167,7 @@ async def refine_concepts(
|
||||
logger.warning(f"[1단계-B] Kei API HTTP {response.status_code}")
|
||||
return analysis
|
||||
|
||||
full_text = await _stream_sse_tokens(response)
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
if not full_text:
|
||||
logger.warning("[1단계-B] 응답 텍스트 없음. 1단계-A 결과 유지.")
|
||||
@@ -214,7 +214,7 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
|
||||
logger.warning(f"Kei API HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
full_text = await _stream_sse_tokens(response)
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
if not full_text:
|
||||
logger.warning("Kei API 응답에서 텍스트 추출 실패")
|
||||
@@ -232,128 +232,105 @@ 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: 스킵
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# I-9: Kei 넘침 판단 호출
|
||||
# ──────────────────────────────────────
|
||||
|
||||
KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다.
|
||||
디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다.
|
||||
콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라.
|
||||
|
||||
## 판단 기준
|
||||
- 텍스트 분량만 줄이면 현재 블록 구조 안에서 해결되는가? → "trim"
|
||||
- 콘텐츠 자체가 컨테이너에 담기엔 본질적으로 많은가? → "restructure"
|
||||
- 중요도가 높은 콘텐츠를 무리하게 축소하면 안 된다
|
||||
- 부가/상세 정보는 팝업(detail page)으로 분리할 수 있다
|
||||
|
||||
## 출력 (JSON만. 설명 없이.)
|
||||
|
||||
Option 1 (텍스트 축약):
|
||||
{"decision": "trim", "trim_targets": [{"topic_id": 1, "max_chars": 200, "reason": "부연 설명 축약 가능"}]}
|
||||
|
||||
Option 2 (핵심 재구성 + 팝업 분리):
|
||||
{"decision": "restructure", "core_topics": [1, 2], "detail_topics": [3], "reason": "12행 비교표는 팝업으로 분리"}
|
||||
"""
|
||||
|
||||
|
||||
async def call_kei_overflow_judgment(
|
||||
overflows: list[dict],
|
||||
content: str,
|
||||
analysis: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Kei API에 넘침 상황을 전달하고 판단을 받는다.
|
||||
|
||||
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
|
||||
fallback: None 반환 → pipeline에서 DOWNGRADE 비상 작동.
|
||||
"""
|
||||
tokens: list[str] = []
|
||||
event_type = ""
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
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
|
||||
overflow_desc = json.dumps(overflows, ensure_ascii=False, indent=2)
|
||||
topics_desc = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": t.get("id"),
|
||||
"title": t.get("title", ""),
|
||||
"purpose": t.get("purpose", ""),
|
||||
"summary": t.get("summary", "")[:100],
|
||||
}
|
||||
for t in analysis.get("topics", [])
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
return "".join(tokens)
|
||||
# I-8: 대형 콘텐츠 정보 포함
|
||||
extra_info = ""
|
||||
tables = analysis.get("tables", [])
|
||||
if tables:
|
||||
extra_info += f"\n\n## 테이블 정보\n{json.dumps(tables, ensure_ascii=False)}"
|
||||
images = analysis.get("images", [])
|
||||
if images:
|
||||
extra_info += f"\n\n## 이미지 정보\n{json.dumps(images, ensure_ascii=False)}"
|
||||
|
||||
|
||||
def _extract_sse_text(raw: str) -> str:
|
||||
"""SSE 응답에서 토큰 텍스트를 수집한다. CRLF/LF 모두 처리."""
|
||||
tokens = []
|
||||
# CRLF 또는 LF로 이벤트 분리
|
||||
events = re.split(r'\r?\n\r?\n', raw)
|
||||
|
||||
for event in events:
|
||||
if not event.strip():
|
||||
continue
|
||||
|
||||
event_type = ""
|
||||
event_data = ""
|
||||
|
||||
for line in event.split('\n'):
|
||||
line = line.strip('\r')
|
||||
if line.startswith('event:'):
|
||||
event_type = line[6:].strip()
|
||||
elif line.startswith('data:'):
|
||||
event_data = line[5:].strip()
|
||||
|
||||
if not event_data:
|
||||
continue
|
||||
|
||||
if event_type == 'token':
|
||||
try:
|
||||
token = json.loads(event_data)
|
||||
if isinstance(token, str):
|
||||
tokens.append(token)
|
||||
except json.JSONDecodeError:
|
||||
tokens.append(event_data)
|
||||
elif event_type == 'done':
|
||||
break
|
||||
|
||||
return "".join(tokens)
|
||||
|
||||
|
||||
async def _call_anthropic_direct(content: str) -> dict[str, Any] | None:
|
||||
"""Anthropic API 직접 호출 (Kei API fallback)."""
|
||||
if not settings.anthropic_api_key:
|
||||
return None
|
||||
|
||||
system_prompt = (
|
||||
"당신은 콘텐츠를 분석하여 슬라이드 구조를 설계하는 실장이다.\n\n"
|
||||
"## 핵심 원칙\n"
|
||||
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
|
||||
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n"
|
||||
"- 슬라이드에 맞게 정리하되, 원본이 말하려는 흐름은 유지\n\n"
|
||||
"## 꼭지 추출 규칙\n"
|
||||
"- 본문에서 2~5개의 핵심 꼭지를 추출한다\n"
|
||||
"- 참조 정보는 role: 'reference', 본문 흐름은 role: 'flow'로 표시\n"
|
||||
"- 1페이지 적정 꼭지 수: 5개\n"
|
||||
"- 초과 시 2페이지 분리\n"
|
||||
"- 이미지가 있으면 images[]에, 표가 있으면 tables[]에 판단 기록\n\n"
|
||||
"## 출력 형식 (JSON만. 설명 없이.)\n"
|
||||
'{"title": "제목", "total_pages": 1, '
|
||||
'"info_structure": "정보 구조 설명", '
|
||||
'"topics": ['
|
||||
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
|
||||
'"layer": "intro|core|supporting|conclusion", '
|
||||
'"role": "flow|reference", '
|
||||
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
|
||||
'"content_type": "text|image|table|mixed", '
|
||||
'"detail_target": false, "page": 1}], '
|
||||
'"images": [], "tables": []}'
|
||||
prompt = (
|
||||
KEI_OVERFLOW_PROMPT + "\n\n"
|
||||
f"## 넘침 현황\n{overflow_desc}\n\n"
|
||||
f"## 꼭지 목록\n{topics_desc}"
|
||||
f"{extra_info}\n\n"
|
||||
f"## 원본 콘텐츠 요약\n{content[:2000]}"
|
||||
)
|
||||
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=2048,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": f"다음 콘텐츠의 꼭지를 추출해줘:\n\n{content}"}],
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-overflow",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Kei API (overflow) HTTP {response.status_code}")
|
||||
return None
|
||||
full_text = await stream_sse_tokens(response)
|
||||
|
||||
result_text = response.content[0].text
|
||||
result = _parse_json(result_text)
|
||||
|
||||
if result and "topics" in result:
|
||||
return result
|
||||
if full_text:
|
||||
result = _parse_json(full_text)
|
||||
if result and "decision" in result:
|
||||
logger.info(f"[Kei 넘침 판단] decision={result['decision']}")
|
||||
return result
|
||||
logger.warning("[Kei 넘침 판단] JSON 파싱 실패 또는 decision 없음")
|
||||
return None
|
||||
|
||||
logger.warning("Kei API (overflow) 텍스트 추출 실패")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Anthropic 직접 호출 실패: {e}")
|
||||
logger.warning(f"Kei API (overflow) 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user