X-B-1: KEI_PROMPT에 유형 B 옵션 추가 - 유형 A: 기존 배경/본심/첨부/결론 (참조자료 있는 콘텐츠) - 유형 B: 본심1(상단)+본심2(하단2분할)+결론 (본문만으로 구성) - Kei가 콘텐츠 보고 A/B 선택, layout_template 필드로 반환 - 검증: 01번→A, 02번→B 정확히 선택 X-B-2: 검증기 완화 - 유형 A: 본심 필수 유지 - 유형 B: 결론(footer)만 필수, 자유 역할명 허용 - 섹션 수 차이 허용 확대 (유형 B: 4) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1448 lines
56 KiB
Python
1448 lines
56 KiB
Python
"""DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석).
|
||
|
||
Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
|
||
Kei API는 필수. fallback 없음. 성공할 때까지 무한 재시도.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from src.config import settings
|
||
from src.sse_utils import stream_sse_tokens
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
KEI_PROMPT = (
|
||
"다음 콘텐츠로 슬라이드 1장을 만들려고 해. 어떤 스토리로 구성할지 설계해줘.\n\n"
|
||
"## 1단계: 핵심 메시지 파악\n"
|
||
"- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n"
|
||
"- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n"
|
||
"- core_message 필드에 기록.\n\n"
|
||
"## 2단계: 정보 구조 파악\n"
|
||
"- 본문 흐름(flow)과 참조 정보(reference)로 분리되는 구조인가?\n"
|
||
"- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
|
||
"- info_structure 필드에 기술.\n\n"
|
||
"## 3단계: 슬라이드 스토리라인 설계\n"
|
||
"핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
|
||
"각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
|
||
"## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n"
|
||
"먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n"
|
||
"### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n"
|
||
"- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n"
|
||
"- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n"
|
||
"- page_structure 키: 배경, 본심, 첨부, 결론\n\n"
|
||
"### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\n"
|
||
"- 참조자료 없이 **본문 흐름만**으로 구성되는 콘텐츠\n"
|
||
"- 배경/첨부가 없거나 억지로 만들어야 하면 이 유형 선택\n"
|
||
"- 상단: 핵심 내용 전체폭 (이미지가 있으면 좌텍스트+우이미지 나란히)\n"
|
||
"- 하단: 세부 내용 2분할 (좌/우)\n"
|
||
"- page_structure 키: 자유 (예: 핵심목표, 프로세스변화, 기대효과, 결론)\n"
|
||
"- 결론 키는 반드시 '결론'\n\n"
|
||
"선택한 유형을 **layout_template** 필드에 'A' 또는 'B'로 기록하라.\n\n"
|
||
"### 역할별 규칙 (유형 A)\n"
|
||
"- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간.\n"
|
||
"- **배경**: 본심을 이해하기 위한 도입. 간결하게.\n"
|
||
"- **첨부**: 본심을 보조하는 참조 정보. sidebar 배치. role: 'reference'.\n"
|
||
"- **결론**: 핵심 한 줄. footer.\n\n"
|
||
"### 역할별 규칙 (유형 B)\n"
|
||
"- 상단 역할: 핵심 내용. 전체폭. zone: 'top'\n"
|
||
"- 하단 좌측: zone: 'bottom_left'\n"
|
||
"- 하단 우측: zone: 'bottom_right'\n"
|
||
"- 결론: zone: 'footer'\n\n"
|
||
"각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
|
||
"**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
|
||
"page_structure 필드에 기록.\n\n"
|
||
"## 원본 텍스트 보존 원칙\n"
|
||
"- 원본의 논리 흐름과 정보를 빠뜨리지 마라\n"
|
||
"- 원본 텍스트는 최대한 보존. 약간의 편집만.\n"
|
||
"- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라\n"
|
||
"- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시\n\n"
|
||
"## 배치 규칙\n"
|
||
"- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
|
||
"- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
|
||
"- 결론은 layer: 'conclusion' → 하단 배치\n"
|
||
"- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
|
||
"- 이미지/표가 있으면 images[], tables[]에 기록\n"
|
||
"- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
|
||
"- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
|
||
"## 출력 형식 (JSON만)\n"
|
||
"layout_template에 따라 page_structure가 달라진다.\n\n"
|
||
"유형 A 예시:\n"
|
||
"```json\n"
|
||
'{"title": "제목", '
|
||
'"core_message": "핵심 메시지", '
|
||
'"total_pages": 1, '
|
||
'"layout_template": "A", '
|
||
'"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}], '
|
||
'"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
|
||
'"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
|
||
"```\n\n"
|
||
"유형 B 예시:\n"
|
||
"```json\n"
|
||
'{"title": "제목", '
|
||
'"core_message": "핵심 메시지", '
|
||
'"total_pages": 1, '
|
||
'"layout_template": "B", '
|
||
'"page_structure": {'
|
||
'"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.35}, '
|
||
'"프로세스변화": {"zone": "bottom_left", "topic_ids": [2], "weight": 0.25}, '
|
||
'"기대효과": {"zone": "bottom_right", "topic_ids": [3], "weight": 0.25}, '
|
||
'"결론": {"zone": "footer", "topic_ids": [4], "weight": 0.15}}, '
|
||
'"topics": [...],'
|
||
'"images": [...]}\n'
|
||
"```\n\n"
|
||
"## 콘텐츠:\n"
|
||
)
|
||
|
||
|
||
async def classify_content(content: str) -> dict[str, Any] | None:
|
||
"""1단계: Kei API를 통해 꼭지를 추출하고 분석한다.
|
||
|
||
Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
|
||
"""
|
||
result = await _call_kei_api(content)
|
||
if result:
|
||
logger.info(
|
||
f"[Kei API] 꼭지 추출 완료: {result.get('title', '')}, "
|
||
f"{len(result.get('topics', []))}개 꼭지"
|
||
)
|
||
return result
|
||
|
||
logger.error("[Kei API] 꼭지 추출 실패. Kei API(localhost:8000) 확인 필요.")
|
||
return None
|
||
|
||
|
||
KEI_PROMPT_B = (
|
||
"아래는 슬라이드 스토리라인 설계 결과(1단계-A)와 원본 콘텐츠이다.\n"
|
||
"각 꼭지의 **컨셉을 구체화**해줘. 1회 응답으로 모든 꼭지를 한꺼번에 처리.\n\n"
|
||
"## 각 꼭지에 대해 판단할 것\n\n"
|
||
"1. **관계 성격 (relation_type)**:\n"
|
||
" - sequence: 시간/단계 순서 (A→B→C)\n"
|
||
" - inclusion: 포함/융합 관계 (A가 B,C를 포함하거나 B,C가 합쳐져 A를 이룸)\n"
|
||
" - comparison: 대등 비교 (A vs B)\n"
|
||
" - hierarchy: 상위-하위 (A > B > C)\n"
|
||
" - definition: 용어 정의 (나열)\n"
|
||
" - cause_effect: 원인-결과\n"
|
||
" - none: 관계 표현 불필요 (단순 텍스트)\n\n"
|
||
"2. **표현 성격 (expression_hint)** — 관계 성격을 설명. 구체 블록 이름은 쓰지 마라:\n"
|
||
" - 예: '기술 융합/포함 관계. 순서 아님. 구성요소 간 관계 표현 필요.'\n"
|
||
" - 예: '수단-목적 관계. 대등 비교가 아님. 역할 차이를 보여줘야 함.'\n"
|
||
" - 예: '용어 3개의 독립적 정의. 나열.'\n\n"
|
||
"3. **원본 데이터 핵심 항목 (source_data)**:\n"
|
||
" - 이 꼭지에 해당하는 원본의 핵심 항목들을 나열하라.\n"
|
||
" - 항목이 여러 개면 '이름(설명), 이름(설명)' 형태로 쉼표 구분.\n"
|
||
" - 원본에 팝업이 참조되면 반드시 [팝업: 제목] 마커를 포함하라.\n"
|
||
" - 원본에 이미지가 참조되면 반드시 [이미지: 제목] 마커를 포함하라.\n"
|
||
" - 출처가 있으면 포함하라.\n"
|
||
" - '활용 필요', '구체화 필요' 같은 지시사항을 쓰지 마라. 실제 콘텐츠 항목만 쓰라.\n"
|
||
" - 예시: '건설산업(종합산업, 기술 통합 융합), BIM(정보관리 도구, 출처: 국토교통부 2020)'\n"
|
||
" - 예시: '[이미지: DX와 핵심기술간 상호관계] 다이어그램, GIS 역할(공간 분석). [팝업: DX와 BIM의 구분] 비교표'\n\n"
|
||
"## 출력 형식 (JSON만)\n"
|
||
"```json\n"
|
||
'{"concepts": ['
|
||
'{"topic_id": 1, '
|
||
'"relation_type": "inclusion|sequence|comparison|hierarchy|definition|cause_effect|none", '
|
||
'"expression_hint": "관계 성격 설명 (블록 이름 쓰지 말 것)", '
|
||
'"source_data": "핵심 항목 나열 + [팝업:] [이미지:] 마커 + 출처"}]}\n'
|
||
"```\n\n"
|
||
)
|
||
|
||
|
||
async def refine_concepts(
|
||
content: str,
|
||
analysis: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""1단계-B: 각 꼭지의 컨셉을 구체화한다.
|
||
|
||
1단계-A 결과(topics)를 받아서, 각 꼭지의 관계 성격/표현 방법/원본 데이터를 판단.
|
||
Kei API만 사용. fallback 없음. 성공할 때까지 재시도.
|
||
1회 호출로 모든 꼭지를 한꺼번에 처리.
|
||
"""
|
||
import asyncio
|
||
|
||
topics = analysis.get("topics", [])
|
||
if not topics:
|
||
return analysis
|
||
|
||
# 꼭지 요약 텍스트
|
||
topics_text = "\n".join(
|
||
f"- 꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
|
||
f"[purpose: {t.get('purpose', '?')}, summary: {t.get('summary', '')}]"
|
||
for t in topics
|
||
)
|
||
|
||
prompt = (
|
||
KEI_PROMPT_B
|
||
+ f"## 1단계-A 스토리라인 결과\n"
|
||
f"핵심 메시지: {analysis.get('core_message', '?')}\n"
|
||
f"꼭지 목록:\n{topics_text}\n\n"
|
||
f"## 원본 콘텐츠\n{content}\n"
|
||
)
|
||
|
||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||
RETRY_INTERVAL = 10
|
||
attempt = 0
|
||
|
||
while True:
|
||
attempt += 1
|
||
try:
|
||
async with httpx.AsyncClient(timeout=None) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
f"{kei_url}/api/direct",
|
||
json={
|
||
"message": prompt,
|
||
},
|
||
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)
|
||
|
||
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 정보 병합
|
||
# 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'])}개")
|
||
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 실패 (시도 {attempt}): {e}")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
|
||
KEI_STRUCTURED_TEXT_PROMPT = (
|
||
"아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
|
||
"각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
|
||
"## 규칙\n"
|
||
"1. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
|
||
"2. 각 문장을 불릿(•)으로 구분하라.\n"
|
||
"3. 하위 항목이 있으면 들여쓰기 불릿( •)으로 구분하라.\n"
|
||
"4. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
|
||
"5. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
|
||
"6. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
|
||
"7. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
|
||
"## 출력 형식 (JSON만. 설명 없이.)\n"
|
||
"```json\n"
|
||
'{"structured_texts": ['
|
||
'{"topic_id": 1, '
|
||
'"structured_text": "• 첫 번째 문장\\n• 두 번째 문장\\n • 하위 항목"}]}\n'
|
||
"```\n\n"
|
||
)
|
||
|
||
|
||
async def generate_structured_text(
|
||
content: str,
|
||
analysis: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
"""1단계-B 보완: 각 꼭지의 structured_text를 생성.
|
||
|
||
refine_concepts() 후 별도 호출. 원본 텍스트를 85% 보존하여 구조화.
|
||
"""
|
||
import asyncio
|
||
|
||
topics = analysis.get("topics", [])
|
||
if not topics:
|
||
return analysis
|
||
|
||
topics_text = "\n".join(
|
||
f"- 꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
|
||
f"[purpose: {t.get('purpose', '?')}, source_hint: {t.get('source_hint', '')}]"
|
||
for t in topics
|
||
)
|
||
|
||
prompt = (
|
||
KEI_STRUCTURED_TEXT_PROMPT
|
||
+ f"## 꼭지 목록\n{topics_text}\n\n"
|
||
+ f"## 원본 콘텐츠\n{content}\n"
|
||
)
|
||
|
||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||
RETRY_INTERVAL = 10
|
||
attempt = 0
|
||
|
||
while True:
|
||
attempt += 1
|
||
try:
|
||
async with httpx.AsyncClient(timeout=None) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
f"{kei_url}/api/direct",
|
||
json={"message": prompt},
|
||
timeout=None,
|
||
) as response:
|
||
if response.status_code != 200:
|
||
logger.warning(f"[1단계-B-ST] Kei API HTTP {response.status_code} (시도 {attempt})")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
full_text = await stream_sse_tokens(response)
|
||
|
||
if not full_text:
|
||
logger.warning(f"[1단계-B-ST] 응답 텍스트 없음 (시도 {attempt})")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
result = _parse_json(full_text)
|
||
if result and "structured_texts" in result:
|
||
st_map = {}
|
||
for st in result["structured_texts"]:
|
||
tid = st.get("topic_id") or st.get("id")
|
||
if tid is not None:
|
||
st_map[tid] = st.get("structured_text", "")
|
||
|
||
for topic in topics:
|
||
text = st_map.get(topic.get("id"), "")
|
||
if text:
|
||
topic["structured_text"] = text
|
||
|
||
filled = sum(1 for t in topics if t.get("structured_text"))
|
||
logger.info(f"[1단계-B-ST] structured_text 생성 완료: {filled}/{len(topics)}개")
|
||
return analysis
|
||
else:
|
||
logger.warning(f"[1단계-B-ST] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[1단계-B-ST] Kei API 실패 (시도 {attempt}): {e}")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
|
||
async def _call_kei_api(content: str) -> dict[str, Any] | None:
|
||
"""Kei API를 통해 꼭지 추출. SSE 스트리밍으로 실시간 수신."""
|
||
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/direct",
|
||
json={
|
||
"message": KEI_PROMPT + content,
|
||
},
|
||
timeout=None,
|
||
) as response:
|
||
if response.status_code != 200:
|
||
logger.warning(f"Kei API HTTP {response.status_code}")
|
||
return None
|
||
|
||
full_text = await stream_sse_tokens(response)
|
||
|
||
if not full_text:
|
||
logger.warning("Kei API 응답에서 텍스트 추출 실패")
|
||
return None
|
||
|
||
result = _parse_json(full_text)
|
||
if result and "topics" in result:
|
||
return result
|
||
|
||
logger.warning(f"Kei API JSON 파싱 실패. 텍스트: {full_text[:200]}")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Kei API 호출 실패: {e}")
|
||
return 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 0
|
||
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/direct",
|
||
json={
|
||
"message": full_prompt,
|
||
},
|
||
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-6-20250415",
|
||
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 최종 검수
|
||
# ──────────────────────────────────────
|
||
|
||
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-6-20250415",
|
||
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/direct",
|
||
json={
|
||
"message": prompt,
|
||
},
|
||
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 넘침 판단 호출
|
||
# ──────────────────────────────────────
|
||
|
||
KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다.
|
||
디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다.
|
||
콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라.
|
||
|
||
## 판단 기준
|
||
- 텍스트 분량만 줄이면 현재 블록 구조 안에서 해결되는가? → "trim"
|
||
- 콘텐츠 자체가 컨테이너에 담기엔 본질적으로 많은가? → "restructure"
|
||
- 중요도가 높은 콘텐츠를 무리하게 축소하면 안 된다
|
||
- 부가/상세 정보는 팝업(detail page)으로 분리할 수 있다
|
||
|
||
## 출력 (JSON만. 설명 없이.)
|
||
|
||
Option 1 (텍스트 축약):
|
||
{"decision": "trim", "trim_targets": [{"topic_id": 1, "max_chars": 200, "reason": "부연 설명 축약 가능"}]}
|
||
|
||
Option 2 (핵심 재구성 + 팝업 분리):
|
||
{"decision": "restructure", "core_topics": [1, 2], "detail_topics": [3], "reason": "12행 비교표는 팝업으로 분리"}
|
||
"""
|
||
|
||
|
||
async def call_kei_overflow_judgment(
|
||
overflows: list[dict],
|
||
content: str,
|
||
analysis: dict[str, Any],
|
||
) -> dict[str, Any] | None:
|
||
"""Kei API에 넘침 상황을 전달하고 판단을 받는다.
|
||
|
||
반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
|
||
실패 시 None → pipeline에서 무한 재시도.
|
||
"""
|
||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||
|
||
overflow_desc = json.dumps(overflows, ensure_ascii=False, indent=2)
|
||
topics_desc = json.dumps(
|
||
[
|
||
{
|
||
"id": t.get("id"),
|
||
"title": t.get("title", ""),
|
||
"purpose": t.get("purpose", ""),
|
||
"summary": t.get("summary", "")[:100],
|
||
}
|
||
for t in analysis.get("topics", [])
|
||
],
|
||
ensure_ascii=False,
|
||
)
|
||
|
||
# I-8: 대형 콘텐츠 정보 포함
|
||
extra_info = ""
|
||
tables = analysis.get("tables", [])
|
||
if tables:
|
||
extra_info += f"\n\n## 테이블 정보\n{json.dumps(tables, ensure_ascii=False)}"
|
||
images = analysis.get("images", [])
|
||
if images:
|
||
extra_info += f"\n\n## 이미지 정보\n{json.dumps(images, ensure_ascii=False)}"
|
||
|
||
prompt = (
|
||
KEI_OVERFLOW_PROMPT + "\n\n"
|
||
f"## 넘침 현황\n{overflow_desc}\n\n"
|
||
f"## 꼭지 목록\n{topics_desc}"
|
||
f"{extra_info}\n\n"
|
||
f"## 원본 콘텐츠 요약\n{content[:2000]}"
|
||
)
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=None) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
f"{kei_url}/api/direct",
|
||
json={
|
||
"message": prompt,
|
||
},
|
||
timeout=None,
|
||
) as response:
|
||
if response.status_code != 200:
|
||
logger.warning(f"Kei API (overflow) HTTP {response.status_code}")
|
||
return None
|
||
full_text = await stream_sse_tokens(response)
|
||
|
||
if full_text:
|
||
result = _parse_json(full_text)
|
||
if result and "decision" in result:
|
||
logger.info(f"[Kei 넘침 판단] decision={result['decision']}")
|
||
return result
|
||
logger.warning("[Kei 넘침 판단] JSON 파싱 실패 또는 decision 없음")
|
||
return None
|
||
|
||
logger.warning("Kei API (overflow) 텍스트 추출 실패")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Kei API (overflow) 호출 실패: {e}")
|
||
return None
|
||
|
||
|
||
def _parse_json(text: str) -> dict[str, Any] | None:
|
||
"""텍스트에서 JSON을 추출한다.
|
||
|
||
Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
|
||
"""
|
||
# 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
|
||
# Kei API가 JSON을 마크다운 리스트로 감싸서 응답하는 경우 대응
|
||
lines = text.split("\n")
|
||
cleaned_lines = []
|
||
for line in lines:
|
||
stripped = line.lstrip()
|
||
if stripped.startswith("- "):
|
||
cleaned_lines.append(stripped[2:])
|
||
elif stripped.startswith("* "):
|
||
cleaned_lines.append(stripped[2:])
|
||
else:
|
||
cleaned_lines.append(stripped)
|
||
cleaned = "\n".join(cleaned_lines)
|
||
|
||
# 원본 + 클린 버전 둘 다 시도
|
||
for target in [text, cleaned]:
|
||
patterns = [
|
||
r"```json\s*(.*?)```",
|
||
r"```\s*(.*?)```",
|
||
r"(\{.*\})",
|
||
]
|
||
for pattern in patterns:
|
||
match = re.search(pattern, target, re.DOTALL)
|
||
if match:
|
||
try:
|
||
return json.loads(match.group(1).strip())
|
||
except json.JSONDecodeError:
|
||
continue
|
||
return None
|
||
|
||
|
||
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-6-20250415",
|
||
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": []}
|
||
|
||
|
||
# ──────────────────────────────────────
|
||
# Phase V: 콘텐츠-컨테이너 적합성 에스컬레이션
|
||
# ──────────────────────────────────────
|
||
|
||
KEI_ENHANCEMENT_REVIEW_PROMPT = """당신은 슬라이드 설계 전문가이다.
|
||
아래는 슬라이드 콘텐츠 품질 강화를 위한 제안 목록이다.
|
||
각 제안을 검토하고, 승인/수정/거부를 결정하라.
|
||
|
||
## 판단 기준
|
||
- 핵심 메시지가 시각적으로 강조되는가?
|
||
- 빈 공간에 유의미한 콘텐츠를 추가할 수 있는가?
|
||
- 종속 꼭지의 처리 방식(인라인/하위블록)이 적절한가?
|
||
- bold 키워드가 핵심 용어인가?
|
||
|
||
## 출력 (JSON만. 설명 없이.)
|
||
|
||
```json
|
||
{
|
||
"decisions": [
|
||
{
|
||
"type": "subordinate|fill_space|emphasis|bold_keywords",
|
||
"role": "배경|본심|첨부|결론",
|
||
"action": "approve|modify|reject",
|
||
"modification": "수정 시 구체적 내용 (approve/reject면 빈 문자열)"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
"""
|
||
|
||
|
||
async def call_kei_enhancement_review(
|
||
enhancement_report: str,
|
||
topics: list[dict],
|
||
core_message: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Stage 1.8 Step 5: Kei에게 보강 제안을 보여주고 승인/수정/거부 결정을 받는다.
|
||
|
||
Kei API(/api/direct)만 사용.
|
||
"""
|
||
import asyncio
|
||
|
||
topics_text = "\n".join(
|
||
f"- 꼭지{t.get('id', '?')}: {t.get('title', '')} [{t.get('purpose', '')}]"
|
||
for t in topics
|
||
)
|
||
|
||
prompt = (
|
||
KEI_ENHANCEMENT_REVIEW_PROMPT + "\n\n"
|
||
f"## 핵심 메시지\n{core_message}\n\n"
|
||
f"## 꼭지 목록\n{topics_text}\n\n"
|
||
f"## 보강 제안\n{enhancement_report}\n"
|
||
)
|
||
|
||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||
RETRY_INTERVAL = 10
|
||
attempt = 0
|
||
|
||
while True:
|
||
attempt += 1
|
||
try:
|
||
async with httpx.AsyncClient(timeout=None) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
f"{kei_url}/api/direct",
|
||
json={"message": prompt},
|
||
timeout=None,
|
||
) as response:
|
||
if response.status_code != 200:
|
||
logger.warning(f"[V-5 Kei 보강 검토] HTTP {response.status_code} (시도 {attempt})")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
full_text = await stream_sse_tokens(response)
|
||
|
||
if not full_text:
|
||
logger.warning(f"[V-5 Kei 보강 검토] 응답 없음 (시도 {attempt})")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
result = _parse_json(full_text)
|
||
if result and "decisions" in result:
|
||
approved = sum(1 for d in result["decisions"] if d.get("action") == "approve")
|
||
total = len(result["decisions"])
|
||
logger.info(f"[V-5 Kei 보강 검토] {approved}/{total} 승인")
|
||
return result
|
||
else:
|
||
logger.warning(f"[V-5 Kei 보강 검토] JSON 파싱 실패 (시도 {attempt})")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[V-5 Kei 보강 검토] 실패 (시도 {attempt}): {e}")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
|
||
async def call_kei_summarize_popup(
|
||
popup_title: str,
|
||
popup_content: str,
|
||
available_width_px: float,
|
||
available_height_px: float,
|
||
font_size: float,
|
||
) -> dict[str, Any] | None:
|
||
"""V'-2: 코드가 형태+크기를 결정하고, Kei가 텍스트만 채운다.
|
||
|
||
1. 코드: 팝업 원본에 표(|)가 있으면 table, 불릿(•)이면 bullets, 그 외 text
|
||
2. 코드: 공간 크기와 폰트를 고려하여 행/열 수 계산
|
||
3. Kei: 결정된 형태+크기에 맞게 원본 내용을 요약
|
||
|
||
Returns:
|
||
{
|
||
"format": "table" | "bullets" | "text",
|
||
"columns": [...], "data": [["셀", ...], ...], # table
|
||
"items": ["...", ...], # bullets
|
||
"summary": "...", # text
|
||
}
|
||
"""
|
||
import asyncio
|
||
|
||
header_h = font_size * 1.5 + font_size * 0.6
|
||
row_h = font_size * 1.5 + font_size * 0.6
|
||
bullet_h = font_size * 1.55
|
||
chars_per_col = int(available_width_px / (font_size * 0.6))
|
||
|
||
# 코드가 형태 판단
|
||
import re
|
||
has_table = popup_content.count("|") > 6 or "<table" in popup_content
|
||
has_bullets = popup_content.count("•") > 2
|
||
|
||
if has_table:
|
||
# 원본 표에서 열 수 추출 — <th> 태그 우선, 없으면 | 파싱
|
||
th_headers = re.findall(r'<th[^>]*>(.*?)</th>', popup_content)
|
||
# <strong> 등 태그 제거
|
||
th_headers = [re.sub(r'<[^>]+>', '', h).strip() for h in th_headers]
|
||
if th_headers:
|
||
# 첫 번째 <thead>의 열만 사용 (중복 테이블 헤더 제거)
|
||
orig_cols = th_headers[:3] if len(th_headers) > 3 else th_headers
|
||
col_count = len(orig_cols)
|
||
else:
|
||
table_lines = [l.strip() for l in popup_content.split("\n") if l.strip().startswith("|")]
|
||
if len(table_lines) >= 2:
|
||
orig_cols = [c.strip() for c in table_lines[0].split("|") if c.strip()]
|
||
col_count = len(orig_cols)
|
||
else:
|
||
orig_cols = []
|
||
col_count = 3
|
||
# 행 수: 공간에 맞게 계산 (제목행 제외, 데이터 행만)
|
||
space_rows = int((available_height_px - header_h) / row_h) if available_height_px > header_h else 1
|
||
# 원본 표 데이터 행 수
|
||
orig_data_rows = len(re.findall(r'<tr>', popup_content)) or len([l for l in popup_content.split("\n") if l.strip().startswith("|") and not l.strip().startswith("|--")]) - 1
|
||
orig_data_rows = max(1, orig_data_rows)
|
||
# 공간과 원본 중 작은 쪽, 최소 1 최대 5
|
||
max_rows = min(space_rows, orig_data_rows, 5)
|
||
max_rows = max(1, max_rows)
|
||
chars_per_col = int(available_width_px / col_count / (font_size * 0.6))
|
||
fmt = "table"
|
||
prompt_task = (
|
||
f"원본 표를 정확히 {col_count}열 × {max_rows}행으로 요약하라.\n"
|
||
f"열 이름: {orig_cols}\n"
|
||
f"data 배열에 정확히 {max_rows}개의 행을 넣어라. 1행만 넣지 마라.\n"
|
||
f"원본에서 가장 핵심적인 {max_rows}개 비교 항목(범위, S/W, 프로세스, 성과품, 활용 등)을 골라라.\n"
|
||
f"각 셀은 {chars_per_col}자 이내의 짧은 핵심 요약으로.\n"
|
||
f"JSON: {{\"columns\": [\"{orig_cols[0] if orig_cols else '열1'}\", ...], \"data\": [[\"셀\", ...], ...]}}"
|
||
)
|
||
elif has_bullets:
|
||
max_items = int(available_height_px / bullet_h)
|
||
max_items = max(1, max_items)
|
||
fmt = "bullets"
|
||
prompt_task = (
|
||
f"원본 불릿을 {max_items}개 이내로 요약하라.\n"
|
||
f"각 항목은 {chars_per_col}자 이내로.\n"
|
||
f"JSON: {{\"items\": [\"항목1\", ...]}}"
|
||
)
|
||
else:
|
||
max_lines = int(available_height_px / bullet_h)
|
||
fmt = "text"
|
||
prompt_task = (
|
||
f"원본을 {max_lines}줄 이내로 요약하라.\n"
|
||
f"JSON: {{\"summary\": \"요약 텍스트\"}}"
|
||
)
|
||
|
||
prompt = f"""당신은 슬라이드 콘텐츠 요약 전문가이다.
|
||
|
||
## 팝업 제목: {popup_title}
|
||
|
||
## 팝업 원본:
|
||
{popup_content}
|
||
|
||
## 요청
|
||
{prompt_task}
|
||
|
||
핵심만 남기되, 원본의 의미가 왜곡되지 않도록 하라. JSON만 응답하라."""
|
||
|
||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||
attempt = 0
|
||
|
||
while attempt < 5:
|
||
attempt += 1
|
||
try:
|
||
async with httpx.AsyncClient(timeout=None) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
f"{kei_url}/api/direct",
|
||
json={"message": prompt},
|
||
timeout=None,
|
||
) as response:
|
||
if response.status_code != 200:
|
||
logger.warning(f"[V'-2 Kei 요약] HTTP {response.status_code} (시도 {attempt})")
|
||
await asyncio.sleep(10)
|
||
continue
|
||
full_text = await stream_sse_tokens(response)
|
||
|
||
if not full_text:
|
||
await asyncio.sleep(10)
|
||
continue
|
||
|
||
result = _parse_json(full_text)
|
||
if result and isinstance(result, dict):
|
||
# 코드가 결정한 format을 주입 (Kei는 텍스트만 채움)
|
||
result["format"] = fmt
|
||
logger.info(f"[V'-2 Kei 요약] {popup_title}: format={fmt}")
|
||
return result
|
||
else:
|
||
logger.warning(f"[V'-2 Kei 요약] 파싱 실패 (시도 {attempt})")
|
||
await asyncio.sleep(10)
|
||
continue
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[V'-2 Kei 요약] 실패 (시도 {attempt}): {e}")
|
||
await asyncio.sleep(10)
|
||
continue
|
||
|
||
return None
|
||
|
||
|
||
async def call_kei_bold_keywords(
|
||
topics: list[dict],
|
||
page_structure: dict,
|
||
) -> dict[str, list[str]]:
|
||
"""V-10: Kei가 문맥 기반으로 각 역할의 bold 키워드를 판단한다.
|
||
|
||
Returns:
|
||
{"배경": ["키워드1", ...], "본심": [...], ...}
|
||
"""
|
||
import asyncio
|
||
|
||
# 역할별 structured_text 정리
|
||
topic_map = {t.get("id"): t for t in topics}
|
||
role_texts = {}
|
||
for role, info in page_structure.items():
|
||
if not isinstance(info, dict):
|
||
continue
|
||
tids = info.get("topic_ids", [])
|
||
texts = []
|
||
for tid in tids:
|
||
topic = topic_map.get(tid, {})
|
||
st = topic.get("structured_text", "") or topic.get("source_data", "")
|
||
if st:
|
||
texts.append(f"[{topic.get('title', '')}]\n{st}")
|
||
if texts:
|
||
role_texts[role] = "\n".join(texts)
|
||
|
||
if not role_texts:
|
||
return {}
|
||
|
||
role_section = "\n\n".join(
|
||
f"## {role}\n{text}" for role, text in role_texts.items()
|
||
)
|
||
|
||
prompt = f"""당신은 슬라이드 디자인 전문가이다.
|
||
|
||
아래는 슬라이드의 각 영역별 콘텐츠이다. 각 영역에서 **문맥상 정말 강조되어야 할 키워드**를 골라라.
|
||
|
||
규칙:
|
||
- 개수를 정하지 마라. 문맥에 맞게 필요한 만큼만 골라라.
|
||
- 단순 명사 나열이 아니라, 읽는 사람이 "이것이 핵심이구나"라고 느낄 키워드여야 한다.
|
||
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
|
||
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.
|
||
|
||
JSON으로 응답하라:
|
||
{{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}}
|
||
빈 역할은 빈 리스트로.
|
||
|
||
{role_section}"""
|
||
|
||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||
RETRY_INTERVAL = 10
|
||
attempt = 0
|
||
|
||
while attempt < 5:
|
||
attempt += 1
|
||
try:
|
||
async with httpx.AsyncClient(timeout=None) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
f"{kei_url}/api/direct",
|
||
json={"message": prompt},
|
||
timeout=None,
|
||
) as response:
|
||
if response.status_code != 200:
|
||
logger.warning(f"[V-10 Kei bold] HTTP {response.status_code} (시도 {attempt})")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
full_text = await stream_sse_tokens(response)
|
||
|
||
if not full_text:
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
result = _parse_json(full_text)
|
||
if result and isinstance(result, dict):
|
||
logger.info(f"[V-10 Kei bold] 결과: {result}")
|
||
return result
|
||
else:
|
||
logger.warning(f"[V-10 Kei bold] 파싱 실패 (시도 {attempt})")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[V-10 Kei bold] 실패 (시도 {attempt}): {e}")
|
||
await asyncio.sleep(RETRY_INTERVAL)
|
||
continue
|
||
|
||
logger.warning("[V-10 Kei bold] 최대 재시도 초과, 빈 결과 반환")
|
||
return {}
|
||
|
||
|
||
KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
|
||
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
|
||
재배분을 시도했지만 해결되지 않은 영역이 있다.
|
||
|
||
콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라.
|
||
|
||
## 판단 기준
|
||
- 핵심 메시지(본심)의 공간은 최대한 보장
|
||
- 배경은 보조 역할 — 간결화 가능
|
||
- 사례/근거는 인라인 축약 또는 팝업 분리 가능
|
||
- 용어 정의는 sidebar에 맞게 조정 가능
|
||
|
||
## 출력 (JSON만. 설명 없이.)
|
||
|
||
```json
|
||
{
|
||
"decisions": [
|
||
{
|
||
"role": "배경",
|
||
"action": "merge|inline|popup|trim|restructure",
|
||
"detail": "구체적 지시 (어떤 꼭지를 어떻게)",
|
||
"reason": "판단 근거 1문장"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
action 종류:
|
||
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침
|
||
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
|
||
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
|
||
- trim: 텍스트 분량을 줄임 (max_chars 지정)
|
||
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
|
||
"""
|
||
|
||
|
||
async def call_kei_fit_escalation(
|
||
fit_report: str,
|
||
topics: list[dict],
|
||
content_summary: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.
|
||
|
||
Kei API만 사용. Anthropic 직접 호출 절대 금지.
|
||
"""
|
||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||
|
||
topics_desc = json.dumps(
|
||
[
|
||
{
|
||
"id": t.get("id"),
|
||
"title": t.get("title", ""),
|
||
"purpose": t.get("purpose", ""),
|
||
"source_data": t.get("source_data", "")[:200],
|
||
}
|
||
for t in topics
|
||
],
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
|
||
prompt = (
|
||
KEI_FIT_ESCALATION_PROMPT + "\n\n"
|
||
f"## 적합성 검증 결과\n{fit_report}\n\n"
|
||
f"## 꼭지 목록\n{topics_desc}\n\n"
|
||
f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
|
||
)
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=None) as client:
|
||
async with client.stream(
|
||
"POST",
|
||
f"{kei_url}/api/direct",
|
||
json={
|
||
"message": prompt,
|
||
},
|
||
timeout=None,
|
||
) as response:
|
||
if response.status_code != 200:
|
||
logger.warning(f"Kei API (fit) HTTP {response.status_code}")
|
||
return None
|
||
full_text = await stream_sse_tokens(response)
|
||
|
||
if full_text:
|
||
result = _parse_json(full_text)
|
||
if result and "decisions" in result:
|
||
logger.info(
|
||
f"[V-4] Kei 적합성 판단: "
|
||
+ ", ".join(
|
||
f"{d['role']}→{d['action']}"
|
||
for d in result["decisions"]
|
||
)
|
||
)
|
||
return result
|
||
logger.warning("[V-4] Kei 적합성 판단 JSON 파싱 실패")
|
||
return None
|
||
|
||
logger.warning("Kei API (fit) 텍스트 추출 실패")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Kei API (fit) 호출 실패: {e}")
|
||
return None
|