Phase N+O: 컨테이너 기반 레이아웃 + Step B 제거 + 전면 정리

- Phase N: catalog 개선, fallback 전면 제거, Kei API 무한 재시도, topic_id 버그 수정
- Phase O: 컨테이너 스펙 계산(비중→px), 블록 스펙 확정, 렌더러 container div
- Step B(Sonnet) 제거: Kei(A-2)+코드로 대체. STEP_B_PROMPT/fallback/DOWNGRADE_MAP 삭제
- Selenium: container div 감지 추가
- catalog.yaml: ref_chars 구조 변환 + FAISS 재빌드
- 문서 전면 갱신: README, PROGRESS, IMPROVEMENT, Phase I~O md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 15:20:51 +09:00
parent ffad1ba82a
commit b0bcffc0f6
28 changed files with 8450 additions and 1530 deletions

View File

@@ -1,7 +1,7 @@
"""DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석).
1차: Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
fallback: Kei API 실패 시 Anthropic API 직접 호출.
Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
Kei API는 필수. fallback 없음. 성공할 때까지 무한 재시도.
"""
from __future__ import annotations
@@ -28,13 +28,20 @@ KEI_PROMPT = (
"- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
"- info_structure 필드에 기술.\n\n"
"## 3단계: 슬라이드 스토리라인 설계\n"
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘. 각 위치에 **목적(purpose)**을 부여:\n"
"- 문제제기: 왜 이 주제가 중요한가? 현재 무엇이 잘못되고 있는가?\n"
"- 근거사례: 문제의 근거, 사례, 증거 (출처 포함)\n"
"- 핵심전달: 그래서 사실은 이거다. 핵심 내용 전달.\n"
"- 용어정의: 사용된 용어를 구체적으로 설명 (보조 참조, sidebar 배치)\n"
"- 결론강조: 핵심 메시지 강조. 슬라이드 하단.\n"
"- 구조시각화: 관계도, 프로세스 등 시각화가 필요한 경우\n\n"
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
"## 4단계: 페이지 구조 판단 (비중 시스템)\n"
"콘텐츠를 분석하여 이 페이지의 **구조와 비중**을 판단하라:\n\n"
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간을 차지해야 함.\n"
" 비교라면 비교표, 관계라면 관계도, 프로세스라면 흐름도로 구조화.\n"
" 비교 구조일 때 비교 목적(왜 비교하는가)을 summary에 명시.\n"
"- **배경**: 본심을 이해하기 위한 도입/배경. 간결하게. 2-3줄이면 충분.\n"
"- **첨부**: 본심을 보조하는 참조 정보 (용어 정의 등). sidebar 배치.\n"
" role: 'reference'로 표시. 본문 흐름을 방해하지 않도록.\n"
"- **결론**: 절대 잊으면 안 되는 핵심 한 줄. footer.\n\n"
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
"page_structure 필드에 기록.\n\n"
"## 원본 텍스트 보존 원칙\n"
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n"
@@ -54,12 +61,18 @@ KEI_PROMPT = (
'"core_message": "이 슬라이드의 핵심 메시지 한 줄", '
'"total_pages": 1, '
'"info_structure": "정보 구조 설명", '
'"page_structure": {'
'"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
'"배경": {"topic_ids": [1], "weight": 0.15}, '
'"첨부": {"topic_ids": [4], "weight": 0.15}, '
'"결론": {"topic_ids": [5], "weight": 0.10}}, '
'"topics": ['
'{"id": 1, "title": "꼭지 제목", "summary": "요약", '
'"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
'"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
'"layer": "intro|core|supporting|conclusion", '
'"role": "flow|reference", '
'"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
'"emphasis": true, "direction": "vertical|horizontal|flexible", '
'"content_type": "text|image|table|mixed", '
'"detail_target": false, "page": 1}], '
@@ -73,8 +86,7 @@ KEI_PROMPT = (
async def classify_content(content: str) -> dict[str, Any] | None:
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
Kei API만 사용. Sonnet fallback 없음.
Kei API 실패 시 None 반환 → pipeline.py에서 manual_classify() 안전망.
Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
"""
result = await _call_kei_api(content)
if result:
@@ -84,7 +96,7 @@ async def classify_content(content: str) -> dict[str, Any] | None:
)
return result
logger.warning("[Kei API] 꼭지 추출 실패. manual_classify로 안전망 적용.")
logger.error("[Kei API] 꼭지 추출 실패. Kei API(localhost:8000) 확인 필요.")
return None
@@ -127,9 +139,11 @@ async def refine_concepts(
"""1단계-B: 각 꼭지의 컨셉을 구체화한다.
1단계-A 결과(topics)를 받아서, 각 꼭지의 관계 성격/표현 방법/원본 데이터를 판단.
Kei API만 사용. 실패 시 1단계-A 결과를 그대로 반환 (pipeline 안 멈춤).
Kei API만 사용. fallback 없음. 성공할 때까지 재시도.
1회 호출로 모든 꼭지를 한꺼번에 처리.
"""
import asyncio
topics = analysis.get("topics", [])
if not topics:
return analysis
@@ -150,48 +164,62 @@ async def refine_concepts(
)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
RETRY_INTERVAL = 10
attempt = 0
try:
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-refine",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[1단계-B] Kei API HTTP {response.status_code}")
return analysis
while True:
attempt += 1
try:
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-refine",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[1단계-B] Kei API HTTP {response.status_code} (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
full_text = await stream_sse_tokens(response)
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning("[1단계-B] 응답 텍스트 없음. 1단계-A 결과 유지.")
return analysis
if not full_text:
logger.warning(f"[1단계-B] 응답 텍스트 없음 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
continue
result = _parse_json(full_text)
if result and "concepts" in result:
# topics에 concept 정보 병합
concept_map = {c.get("topic_id"): c for c in result["concepts"]}
for topic in topics:
concept = concept_map.get(topic.get("id"))
if concept:
topic["relation_type"] = concept.get("relation_type", "")
topic["expression_hint"] = concept.get("expression_hint", "")
topic["source_data"] = concept.get("source_data", "")
result = _parse_json(full_text)
if result and "concepts" in result:
# topics에 concept 정보 병합
# Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 다 체크
concept_map = {}
for c in result["concepts"]:
tid = c.get("topic_id") or c.get("id")
if tid is not None:
concept_map[tid] = c
for topic in topics:
concept = concept_map.get(topic.get("id"))
if concept:
topic["relation_type"] = concept.get("relation_type", "")
topic["expression_hint"] = concept.get("expression_hint", "")
topic["source_data"] = concept.get("source_data", "")
logger.info(f"[1단계-B] 컨셉 구체화 완료: {len(result['concepts'])}")
else:
logger.warning(f"[1단계-B] JSON 파싱 실패. 1단계-A 결과 유지. 텍스트: {full_text[:200]}")
logger.info(f"[1단계-B] 컨셉 구체화 완료: {len(result['concepts'])}")
return analysis
else:
logger.warning(f"[1단계-B] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
await asyncio.sleep(RETRY_INTERVAL)
continue
except Exception as e:
logger.warning(f"[1단계-B] Kei API 실패: {e}. 1단계-A 결과 유지.")
return analysis
except Exception as e:
logger.warning(f"[1단계-B] Kei API 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
continue
async def _call_kei_api(content: str) -> dict[str, Any] | None:
@@ -234,6 +262,156 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
# ──────────────────────────────────────
# J-7: Kei 최종 검수
# ──────────────────────────────────────
KEI_REVIEW_PROMPT = """당신은 11년 경력의 기획 실장이다. 디자인 팀장이 조립한 슬라이드를 최종 검수한다.
## 검수 관점
1. 핵심 메시지(core_message)가 시각적으로 명확히 전달되는가?
2. 콘텐츠 흐름이 블록 배치와 일치하는가?
3. 각 블록이 해당 꼭지의 purpose에 적합한가?
4. 중요한 내용이 빠지거나 과도하게 축소되지 않았는가?
5. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?
- 텍스트 축약으로 해결 가능 → shrink
- 콘텐츠가 본질적으로 큼 → overflow_detected
6. **핵심전달이 body에서 가장 큰 시각적 비중을 차지하는가?**
- 핵심전달 블록이 도입부(문제제기+근거사례)보다 작으면 → rewrite로 비중 조정
7. **문제제기가 간결한가? (100자 이내)**
- 초과 시 → shrink (target_ratio: 0.5)
8. **용어정의가 sidebar에 있는가?**
- body에 있으면 → 구조 문제 지적 (issues에 명시)
9. **핵심전달 블록이 화면 안에 보이는가?**
- 잘리면 → overflow_detected
## 조정 action
- expand: 텍스트 늘림 (target_ratio, 예: 1.3)
- shrink: 텍스트 줄임 (target_ratio, 예: 0.7)
- rewrite: 텍스트 재작성 (detail에 방향)
- overflow_detected: 높이 초과, 콘텐츠 판단 필요 (zone과 블록 명시)
## 출력 (JSON만. 설명 없이.)
{"needs_adjustment": true/false, "issues": ["이슈1"], "adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", "target_ratio": 1.3, "detail": "..."}]}
"""
async def call_kei_final_review(
html: str,
block_summary: list[str],
zone_budget_text: str,
overflow_hint_text: str,
analysis: dict[str, Any] | None = None,
screenshot_b64: str | None = None,
) -> dict[str, Any] | None:
"""Phase N-4: Kei(Opus)가 스크린샷을 보고 최종 검수한다.
스크린샷이 있으면: Anthropic API 직접 호출 (Opus + 멀티모달)
스크린샷이 없으면: Kei API 경유 (텍스트 기반)
어느 경로든 Kei(Opus)가 판단. Sonnet 절대 금지.
"""
import anthropic
core_message = analysis.get("core_message", "") if analysis else ""
topics_summary = ""
if analysis:
topics_summary = "\n".join(
f"- 꼭지 {t.get('id')}: {t.get('title', '')} [{t.get('purpose', '')}]"
for t in analysis.get("topics", [])
)
review_text = (
f"## 핵심 메시지\n{core_message}\n\n"
f"## 꼭지 목록\n{topics_summary}\n\n"
f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
zone_budget_text +
overflow_hint_text +
f"\n\n위 슬라이드를 검수하고 조정이 필요한지 판단해. JSON만."
)
# 스크린샷이 있으면: Opus 직접 호출 + 이미지 전달
if screenshot_b64:
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
max_tokens=4096,
system=KEI_REVIEW_PROMPT,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": screenshot_b64,
},
},
{
"type": "text",
"text": review_text,
},
],
}],
)
result_text = response.content[0].text
result = _parse_json(result_text)
if result and "needs_adjustment" in result:
logger.info(
f"[Kei 최종 검수] 스크린샷 기반, needs_adjustment={result['needs_adjustment']}"
)
return result
logger.warning("[Kei 최종 검수] 스크린샷 기반 JSON 파싱 실패")
return None
except Exception as e:
logger.warning(f"Kei 최종 검수 (스크린샷) 실패: {e}")
return None
# 스크린샷 없으면: Kei API 경유 (텍스트 기반)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
prompt = (
KEI_REVIEW_PROMPT + "\n\n" + review_text +
f"\n\n## 조립 HTML (요약)\n{html[:3000]}"
)
try:
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-final-review",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"Kei 최종 검수 HTTP {response.status_code}")
return None
full_text = await stream_sse_tokens(response)
if full_text:
result = _parse_json(full_text)
if result and "needs_adjustment" in result:
logger.info(
f"[Kei 최종 검수] 텍스트 기반, needs_adjustment={result['needs_adjustment']}"
)
return result
logger.warning("[Kei 최종 검수] JSON 파싱 실패")
return None
logger.warning("Kei 최종 검수 텍스트 추출 실패")
return None
except Exception as e:
logger.warning(f"Kei 최종 검수 실패: {e}")
return None
# ──────────────────────────────────────
# I-9: Kei 넘침 판단 호출
# ──────────────────────────────────────
@@ -266,7 +444,7 @@ async def call_kei_overflow_judgment(
"""Kei API에 넘침 상황을 전달하고 판단을 받는다.
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
fallback: None 반환 → pipeline에서 DOWNGRADE 비상 작동.
실패 시 None → pipeline에서 무한 재시도.
"""
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
@@ -370,29 +548,4 @@ def _parse_json(text: str) -> dict[str, Any] | None:
return None
def manual_classify(content: str) -> dict[str, Any]:
"""분류 실패 시 기본 구조 fallback."""
return {
"title": "슬라이드",
"core_message": "",
"total_pages": 1,
"info_structure": "",
"topics": [
{
"id": 1,
"title": "핵심 내용",
"summary": content[:100],
"purpose": "핵심전달",
"source_hint": "",
"layer": "core",
"role": "flow",
"emphasis": False,
"direction": "flexible",
"content_type": "text",
"detail_target": False,
"page": 1,
},
],
"images": [],
"tables": [],
}
# manual_classify 삭제됨. Kei API는 필수. fallback 없음.