Phase S: Claude HTML 직접 생성 + 독립 검증 시스템 도입

블록 선택 방식(Phase P/Q/R) 폐기 → Claude Sonnet이 영역별 HTML 직접 생성.
생성-검증 분리: content_verifier.py로 텍스트 보존/금지 콘텐츠/구조를 코드 검증.

주요 변경:
- src/html_generator.py: 4개 프롬프트 템플릿(BG/CORE/SIDEBAR/FOOTER) + 영역별 Claude 호출
- src/content_verifier.py: L1 텍스트 보존, L2 금지 콘텐츠, L3 구조 검증 + 재시도 루프
- src/html_validator.py: 보안 검증(script/iframe 제거)
- src/renderer.py: render_slide_from_html() 추가, area div overflow:hidden
- scripts/test_phase_s.py: generate_with_retry() 통합, step2b_verification 결과 저장
- 배경 라이트 디자인(#f8fafc), 개조식 어미 변환, 축약 금지 규칙

다음 과제: 폰트 위계(핵심14>본문12>배경10-12>첨부9-11) + 동적 컨테이너 계산

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 08:37:05 +09:00
parent 9410576e60
commit 0e4b8c091c
14 changed files with 3875 additions and 242 deletions

View File

@@ -262,6 +262,240 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
# ──────────────────────────────────────
# Phase Q-4: 제약 기반 블록 선택 (Kei 1회 호출)
# ──────────────────────────────────────
BLOCK_SELECTION_PROMPT = """당신은 11년 경력의 기획 실장이다. 각 꼭지(topic)에 가장 적합한 블록과 변형(variant)을 선택하라.
## 판단 기준 (우선순위 순)
1. 콘텐츠의 **표현 의도(expression_hint)**를 가장 잘 시각화하는 블록+변형
2. 이 꼭지의 목적(purpose)에 부합하는 표현 방식
3. 블록에 변형(variant)이 있으면, 콘텐츠에 더 적합한 변형을 선택
4. 글자수 예산 내에서 의미 전달이 가능한 블록
5. 다른 꼭지와 같은 블록 타입이 되지 않도록 다양성 확보
## 변형(variant) 선택 규칙
- 블록에 변형이 여러 개 있으면, "when" 조건과 expression_hint를 비교하여 적합한 것 선택
- 기본(default)이 적합하면 variant를 "default"로 지정
- 변형의 "when"이 expression_hint와 맞으면 해당 variant 선택
## 출력 (JSON, 꼭지 수만큼)
{"selections": [{"topic_id": 1, "block_id": "블록 id", "variant": "default 또는 변형 id", "reason": "선택 근거 1문장"}, ...]}
"""
async def select_block_for_topics(
topics: list[dict[str, Any]],
candidates_per_topic: dict[int, list[dict]],
budgets_per_topic: dict[int, dict[str, dict]],
container_specs: dict[str, Any],
analysis: dict[str, Any],
) -> dict[int, dict] | None:
"""Phase Q-4: 필터링된 후보 목록에서 Kei가 topic별 블록을 1개씩 선택.
AI 1회 호출로 모든 topic의 블록을 동시 선택한다.
Args:
topics: 1단계 분석의 topic 리스트
candidates_per_topic: {topic_id: [후보 블록 리스트]}
budgets_per_topic: {topic_id: {block_id: budget_dict}}
container_specs: 역할별 ContainerSpec
analysis: 1단계 분석 결과
Returns:
{topic_id: {"block_id": "...", "reason": "..."}} 또는 None (실패)
"""
from src.block_selector import format_candidates_for_prompt
from src.space_allocator import find_container_for_topic
core_message = analysis.get("core_message", "")
# 프롬프트 구성
prompt_parts = [
BLOCK_SELECTION_PROMPT,
f"\n## 슬라이드 핵심 메시지\n{core_message}\n",
]
for topic in topics:
tid = topic.get("id")
candidates = candidates_per_topic.get(tid, [])
if not candidates:
continue
spec = find_container_for_topic(tid, container_specs)
per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 200
budget = budgets_per_topic.get(tid, {})
expression_hint = topic.get("expression_hint", "")
prompt_parts.append(
f"\n### 꼭지 {tid}: {topic.get('title', '')}\n"
f"- 목적: {topic.get('purpose', '')}\n"
f"- 관계 유형: {topic.get('relation_type', 'none')}\n"
f"- ★ 표현 의도: {expression_hint}\n"
f"- 컨테이너: {per_topic_px}px × {spec.width_px if spec else 700}px "
f"({spec.role if spec else '?'} {spec.zone if spec else '?'})\n"
f"- 후보 블록:\n"
f"{format_candidates_for_prompt(candidates, budget)}\n"
)
full_prompt = "\n".join(prompt_parts)
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
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-q4",
"mode_hint": "chat",
},
timeout=None,
) as response:
if response.status_code != 200:
logger.warning(f"[Q-4] Kei API HTTP {response.status_code}")
return None
full_text = await stream_sse_tokens(response)
if not full_text:
logger.warning("[Q-4] Kei API 응답 비어있음")
return None
result = _parse_json(full_text)
if not result or "selections" not in result:
logger.warning(f"[Q-4] JSON 파싱 실패: {full_text[:200]}")
return None
# 결과 → {topic_id: {"block_id": ..., "reason": ...}}
selections = {}
for sel in result["selections"]:
tid = sel.get("topic_id")
block_id = sel.get("block_id", "")
# catalog 존재 검증 (유령 블록 최종 차단)
candidates = candidates_per_topic.get(tid, [])
valid_ids = {c.get("id") for c in candidates}
if block_id not in valid_ids:
logger.warning(
f"[Q-4] topic {tid}: Kei가 '{block_id}' 선택했으나 후보에 없음. "
f"첫 번째 후보로 대체."
)
block_id = candidates[0]["id"] if candidates else block_id
selections[tid] = {
"block_id": block_id,
"variant": sel.get("variant", "default"),
"reason": sel.get("reason", ""),
}
logger.info(
f"[Q-4] 블록 선택 완료: "
+ ", ".join(f"t{tid}={s['block_id']}" for tid, s in selections.items())
)
return selections
except Exception as e:
logger.error(f"[Q-4] Kei 블록 선택 실패: {e}")
return None
# ──────────────────────────────────────
# Phase Q-6: 비전 모델 품질 게이트 (VASCAR식)
# ──────────────────────────────────────
VISION_QUALITY_PROMPT = """슬라이드 스크린샷을 평가하라.
## 체크리스트 (각 항목 1-5점)
1. 콘텐츠 겹침/잘림: 모든 텍스트가 컨테이너 안에 있는가?
2. 시각적 위계: 본심 영역이 가장 두드러지는가?
3. 가독성: 모든 폰트가 읽을 수 있는 크기인가? (10px 이상)
4. 블록 다양성: 서로 다른 블록 유형을 사용하고 있는가?
5. 전문성: 한국어 비즈니스 프레젠테이션으로 적절한가?
## 출력 (JSON)
{
"passed": true/false,
"score": 0-100,
"checks": {"겹침": 5, "위계": 4, "가독성": 5, "다양성": 3, "전문성": 4},
"issues": ["문제 설명 (있으면)"],
"fix_targets": [{"area": "body", "topic_id": 3, "action": "shrink|replace|rewrite", "detail": "구체적 지시"}]
}
"""
async def vision_quality_gate(
screenshot_b64: str,
analysis: dict[str, Any],
) -> dict[str, Any] | None:
"""Phase Q-6: 스크린샷 기반 시각 품질 평가.
VASCAR 논문 기반 — 렌더링 → 비전 모델 평가 → 교정 여부 결정.
Returns:
{"passed": bool, "score": int, "issues": [...], "fix_targets": [...]}
"""
import anthropic
import base64
core_message = analysis.get("core_message", "")
topic_summary = ", ".join(
f"{t.get('id')}:{t.get('title','')}" for t in analysis.get("topics", [])
)
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": screenshot_b64,
},
},
{
"type": "text",
"text": (
VISION_QUALITY_PROMPT +
f"\n\n## 컨텍스트\n"
f"핵심 메시지: {core_message}\n"
f"꼭지 구성: {topic_summary}\n"
),
},
],
}],
)
result_text = response.content[0].text if response.content else ""
result = _parse_json(result_text)
if result:
score = result.get("score", 0)
passed = result.get("passed", score >= 50)
result["passed"] = passed
logger.info(
f"[Q-6] 품질 게이트: {score}/100 → {'PASS' if passed else 'FAIL'}"
)
return result
logger.warning(f"[Q-6] JSON 파싱 실패: {result_text[:200]}")
return None
except Exception as e:
logger.error(f"[Q-6] 비전 품질 평가 실패: {e}")
return None
# ──────────────────────────────────────
# J-7: Kei 최종 검수
# ──────────────────────────────────────
@@ -548,4 +782,111 @@ def _parse_json(text: str) -> dict[str, Any] | None:
return None
# manual_classify 삭제됨. Kei API는 필수. fallback 없음.
async def select_best_candidate(
topic_results: list[dict[str, Any]],
analysis: dict[str, Any],
) -> dict[str, Any]:
"""Phase P: Kei가 스크린샷을 보고 topic별 최적 블록을 선택한다.
여러 topic을 묶어서 1회 호출. 각 topic별 후보 스크린샷을 Opus 멀티모달로 비교.
Args:
topic_results: [{
"topic_id": 1,
"topic_title": "...",
"purpose": "문제제기",
"candidates": [
{"index": 0, "type": "callout-warning", "screenshot_b64": "...", "overflowed": False},
{"index": 1, "type": "dark-bullet-list", "screenshot_b64": "...", "overflowed": False},
{"index": 2, "type": "quote-big-mark", "screenshot_b64": "...", "overflowed": True},
]
}, ...]
analysis: 1단계 분석 결과 (core_message 등)
Returns:
{"selections": [{"topic_id": 1, "selected_index": 0, "reason": "..."}]}
"""
import anthropic
core_message = analysis.get("core_message", "")
# 메시지 content 블록 구성 (텍스트 + 이미지들)
content_blocks = []
# 지시문
instruction = (
f"슬라이드의 핵심 메시지: {core_message}\n\n"
"아래 각 꼭지(topic)별로 후보 블록 스크린샷을 보여준다.\n"
"각 꼭지마다 **당초 목적에 가장 적합한 1개**를 선택하라.\n\n"
"판단 기준:\n"
"1. 당초 목적(purpose)에 적합한가? (문제 제기인데 비교 블록이면 부적합)\n"
"2. 콘텐츠 의미가 왜곡되지 않는가?\n"
"3. 컨테이너에 넘치지 않는가? (overflow 표시된 것은 감점)\n"
"4. 같은 블록이 다른 topic과 중복되면 피하라.\n\n"
"전부 안 맞으면 그나마 가장 나은 것을 선택하라.\n\n"
)
# 각 topic의 후보 스크린샷 추가
for tr in topic_results:
tid = tr["topic_id"]
purpose = tr.get("purpose", "")
instruction += f"## 꼭지 {tid}: {tr.get('topic_title', '')} (목적: {purpose})\n"
for cand in tr.get("candidates", []):
idx = cand["index"]
block_type = cand.get("type", "")
overflowed = cand.get("overflowed", False)
overflow_note = " ⚠️ OVERFLOW" if overflowed else ""
instruction += f" 후보 {idx}: {block_type}{overflow_note}\n"
content_blocks.append({"type": "text", "text": instruction})
# 스크린샷 이미지 추가
for tr in topic_results:
for cand in tr.get("candidates", []):
b64 = cand.get("screenshot_b64")
if b64:
content_blocks.append({
"type": "text",
"text": f"[꼭지 {tr['topic_id']} 후보 {cand['index']}: {cand.get('type', '')}]",
})
content_blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": b64,
},
})
content_blocks.append({
"type": "text",
"text": (
"\n## 출력 (JSON만)\n"
'{"selections": [{"topic_id": 1, "selected_index": 0, "reason": "선택 이유"}]}'
),
})
try:
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
response = await client.messages.create(
model="claude-opus-4-0-20250514",
max_tokens=2048,
messages=[{"role": "user", "content": content_blocks}],
)
result_text = response.content[0].text
result = _parse_json(result_text)
if result and "selections" in result:
logger.info(
f"[Phase P] Kei 최종 선택: "
+ ", ".join(f"t{s['topic_id']}→후보{s['selected_index']}" for s in result["selections"])
)
return result
logger.warning(f"[Phase P] 선택 JSON 파싱 실패: {result_text[:200]}")
return {"selections": []}
except Exception as e:
logger.warning(f"[Phase P] Kei 최종 선택 실패: {e}")
return {"selections": []}