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:
@@ -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": []}
|
||||
|
||||
Reference in New Issue
Block a user