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:
@@ -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 없음.
|
||||
|
||||
Reference in New Issue
Block a user