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
co-authored by Claude Opus 4.6
parent ffad1ba82a
commit b0bcffc0f6
28 changed files with 8450 additions and 1530 deletions
+108 -93
View File
@@ -4,8 +4,7 @@
Kei API를 통해 도메인 전문가로서 각 슬롯 텍스트를 정리한다.
팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
1차: Kei API (persona + RAG + 도메인 지식)
fallback: Anthropic API 직접 호출
Kei API 필수. fallback 없음. 성공할 때까지 무한 재시도.
"""
from __future__ import annotations
@@ -53,6 +52,21 @@ EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다
- summary: 슬라이드 표면에 보일 요약 (3줄 이내)
- detail: 펼치면 보일 전체 내용
## purpose별 분량 원칙 (가이드라인)
- 문제제기: max 100자 (2-3줄). 간결한 도입부. 장황하지 않게.
- 근거사례: max 150자. 핵심만 짧게. 상세는 자세히보기.
- 핵심전달: 200-400자. 충분히 구조화. 이것이 슬라이드의 주인공.
- 용어정의: 각 용어 max 50자. sidebar에서 짧게 정의.
- 결론강조: max 40자. 기억할 1문장.
- 비교 블록 사용 시: 비교 목적(왜 비교하는가)을 첫 행 또는 상단에 요약.
## source 슬롯 규칙 (절대 규칙)
- source 슬롯에는 반드시 정보원(출처)을 넣는다
- 꼭지 제목, 주제어, 섹션명을 source에 넣지 마라
- 출처가 원본에 없으면 source 슬롯을 비워라 (빈 문자열)
- 올바른 예: '국토교통부, 2020', 'IBM, 2011'
- 잘못된 예: '용어의 혼용', 'DX와 BIM 개념'
## JSON 형식으로만 응답한다. 설명 없이 JSON만."""
@@ -103,33 +117,64 @@ async def fill_content(
guide_lines = [f" {k}: ~{v}" for k, v in char_guide.items()]
req_text += "\n 글자 수 가이드 (참고, 의미 우선):\n" + "\n".join(guide_lines)
# Phase O-4: 컨테이너 기반 블록 스펙 전달
container_h = block.get("_container_height_px")
if container_h:
max_items = block.get("_max_items", "제한 없음")
max_chars_item = block.get("_max_chars_per_item", "제한 없음")
max_chars_total = block.get("_max_chars_total", "제한 없음")
font_size = block.get("_font_size_px", 15.2)
req_text += (
f"\n ★ 컨테이너 제약 (절대 준수):"
f"\n - 컨테이너 높이: {container_h}px"
f"\n - 최대 항목 수: {max_items}"
f"\n - 항목당 최대 글자 수: {max_chars_item}"
f"\n - 총 최대 글자 수: {max_chars_total}"
f"\n - 폰트 크기: {font_size}px"
f"\n 이 제약을 넘기면 컨테이너 밖으로 넘친다. 반드시 지켜라."
)
slot_requirements.append(req_text)
page_label = ""
if len(layout_concept.get("pages", [])) > 1:
page_label = f" (페이지 {page_idx + 1}/{len(layout_concept['pages'])})"
# Phase M: 토픽별 source 정보 추출 (P-9 원본 보존 강화)
source_section = ""
if analysis:
source_lines = []
for topic in analysis.get("topics", []):
tid = topic.get("id")
hint = topic.get("source_hint", "")
data = topic.get("source_data", "")
if hint or data:
source_lines.append(
f"- 토픽 {tid} ({topic.get('purpose', '')}): "
f"{hint}{' / ' + data if data else ''}"
)
if source_lines:
source_section = (
"\n\n## 토픽별 원본 데이터 (이 텍스트에서 추출하라. 재작성 금지.)\n"
+ "\n".join(source_lines)
)
user_prompt = (
f"## 원본 콘텐츠\n{content}\n\n"
f"## 블록 배치{page_label}\n"
+ "\n".join(slot_requirements)
+ source_section
+ "\n\n## 요청\n"
"위 블록별로 슬롯에 들어갈 텍스트를 정리하여 JSON으로 반환해줘.\n"
"내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
"원본에서 추출하라. 재작성하지 마라. 축약만 허용.\n"
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
"형식:\n"
'{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "data": {슬롯 키-값}}]}'
)
try:
# Kei API만 사용. Sonnet fallback 없음.
result_text = await _call_kei_editor(user_prompt)
# G-6: Kei API 실패 시 None 가드
if result_text is None:
logger.warning("Kei API 편집 실패. 기본값 적용.")
_apply_defaults(blocks)
continue
# Kei API만 사용. fallback 없음. 성공할 때까지 무한 재시도.
result_text = await _call_kei_editor_with_retry(user_prompt)
filled = _parse_json(result_text)
@@ -140,7 +185,14 @@ async def fill_content(
if filled_block.get("topic_id"):
for orig_block in blocks:
if orig_block.get("topic_id") == filled_block.get("topic_id"):
orig_block["data"] = filled_block.get("data", {})
# data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
new_data = filled_block.get("data", {})
preserved = {}
if "data" in orig_block:
for k in ("column_override",):
if k in orig_block["data"]:
preserved[k] = orig_block["data"][k]
orig_block["data"] = {**new_data, **preserved}
matched = True
break
# 2차: area + type으로 매칭 (topic_id 없을 때)
@@ -151,7 +203,14 @@ async def fill_content(
and orig_block.get("type") == filled_block.get("type")
and "data" not in orig_block
):
orig_block["data"] = filled_block.get("data", {})
# data 덮어쓰되 column_override 등 기존 메타 보존 (J-6)
new_data = filled_block.get("data", {})
preserved = {}
if "data" in orig_block:
for k in ("column_override",):
if k in orig_block["data"]:
preserved[k] = orig_block["data"][k]
orig_block["data"] = {**new_data, **preserved}
break
logger.info(
@@ -159,107 +218,63 @@ async def fill_content(
f"{len(filled['blocks'])}개 블록"
)
else:
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 기본값.")
_apply_defaults(blocks)
logger.warning(f"텍스트 정리 파싱 실패 (페이지 {page_idx + 1}). 재시도 필요하지만 텍스트는 받았으므로 진행.")
except Exception as e:
logger.error(f"텍스트 편집자 호출 실패: {e}", exc_info=True)
_apply_defaults(blocks)
raise
return layout_concept
async def _call_kei_editor(prompt: str) -> str | None:
"""Kei API를 통해 텍스트 편집을 요청한다. SSE 스트리밍으로 실시간 수신.
async def _call_kei_editor_with_retry(prompt: str) -> str:
"""Kei API를 통해 텍스트 편집을 요청한다. 성공할 때까지 무한 재시도.
Kei persona의 도메인 지식 + RAG를 활용하여
건설/DX 분야 전문 용어를 정확하게 유지하면서 편집.
fallback 없음. Kei API가 응답할 때까지 기다린다.
"""
import asyncio
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
full_prompt = EDITOR_PROMPT + "\n\n" + prompt
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": full_prompt,
"session_id": "design-agent-editor",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"Kei API (editor) HTTP {response.status_code}")
return None
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": full_prompt,
"session_id": "design-agent-editor",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"Kei API (editor) 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 full_text:
return full_text
if full_text:
return full_text
logger.warning("Kei API (editor) 텍스트 추출 실패")
return None
logger.warning(f"Kei API (editor) 텍스트 추출 실패 (시도 {attempt})")
await asyncio.sleep(RETRY_INTERVAL)
except Exception as e:
logger.warning(f"Kei API (editor) 호출 실패: {e}")
return None
except Exception as e:
logger.warning(f"Kei API (editor) 호출 실패 (시도 {attempt}): {e}")
await asyncio.sleep(RETRY_INTERVAL)
def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
"""실패 시 기본 데이터 적용."""
defaults = {
# headers/
"section-title-with-bg": {"title_ko": "(제목)"},
"section-header-bar": {"title": "(섹션)"},
"topic-left-right": {"title": "(소제목)", "description": ""},
"topic-center": {"title": "(제목)"},
"topic-numbered": {"number": "1", "title": "(단계)"},
# cards/
"card-image-3col": {"cards": []},
"card-dark-overlay": {"cards": []},
"card-tag-image": {"cards": []},
"card-icon-desc": {"cards": []},
"card-compare-3col": {"cards": []},
"card-step-vertical": {"steps": []},
"card-image-round": {"cards": []},
"card-stat-number": {"stats": []},
"card-numbered": {"items": []},
# tables/
"compare-3col-badge": {"headers": [], "rows": []},
"compare-2col-split": {"left_title": "A", "right_title": "B", "rows": []},
"table-simple-striped": {"headers": [], "rows": []},
# visuals/
"venn-diagram": {"center_label": "관계도", "items": [], "center_sub": "", "description": ""},
"circle-gradient": {"label": "(라벨)"},
"compare-pill-pair": {"left_label": "A", "right_label": "B"},
"process-horizontal": {"steps": []},
"flow-arrow-horizontal": {"steps": []},
"keyword-circle-row": {"keywords": []},
# emphasis/
"quote-big-mark": {"quote_text": "(인용)"},
"quote-question": {"question": "(질문)"},
"comparison-2col": {"left_title": "A", "left_content": "-", "right_title": "B", "right_content": "-"},
"banner-gradient": {"text": "(배너)"},
"dark-bullet-list": {"bullets": []},
"highlight-strip": {"segments": []},
"callout-solution": {"title": "(솔루션)", "description": ""},
"callout-warning": {"title": "(경고)", "description": ""},
"tab-label-row": {"tabs": []},
"divider-text": {"text": "구분"},
# media/
"image-row-2col": {"images": []},
"image-grid-2x2": {"images": []},
"image-side-text": {"image_src": ""},
"image-full-caption": {"src": ""},
"image-before-after": {"before_src": "", "after_src": ""},
}
for block in blocks:
if "data" not in block:
block["data"] = defaults.get(block.get("type", ""), {})
# _apply_defaults 삭제됨 — Kei API 무한 재시도로 fallback 불필요.
def _parse_json(text: str) -> dict[str, Any] | None: