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:
+108
-93
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user