IMPROVEMENT Phase A~D + Phase 2 전체 반영
## IMPROVEMENT (Phase A~D) - A-1: 4단계 Sonnet 디자인 조정 (_adjust_design) — CSS 변수 cascade - A-2: 5단계 HTML 전문 프롬프트 전달 - A-3: shrink/expand 하드코딩 제거 → Sonnet target_ratio 기반 - A-4: rewrite action 구현 - A-5: overflow: visible (area 레벨 텍스트 잘림 방지) - A-6: object-fit cover → contain (이미지 crop 방지) - A-7: table-layout: fixed - A-8: container query 폰트 스케일링 - B-1: details-block 템플릿 신규 (CSS 변수만 사용) - B-2: 인쇄 시 details 자동 펼침 JS - B-3: catalog에 details-block 등록 - B-4/B-5: images[]/tables[] 상세 판단 + fallback 3곳 동기화 - B-8: fallback card-grid → topic-header + char_guide 제거 - C-1: CLAUDE.md gradient 원칙 완화 - C-3: border-radius 9개 파일 var(--radius) 통일 - C-4: box-shadow 2레벨 → 1레벨 - D-0: 이미지 경로 입력 UI + API base_path - D-1: Pillow 의존성 + image_utils.py - D-2~D-4: 이미지 비율/축소방지 프롬프트 전달 - D-5: HTML에 이미지 base64 삽입 ## Phase 2 (다른 Claude 작업) - P2-A: FAISS 블록 검색 (bge-m3, 46개 블록) - P2-B: SVG N개 자동 배치 (svg_calculator.py) - P2-C: Opus 블록 추천 (Kei API 경유) - P2-D: 5단계 재검토 루프 강화 (MAX_REVIEW_ROUNDS=2) - P2-E: details-block fallback 연동 ## 버그 수정 (BF-8~10) - BF-8: 컨테이너 예산 기반 블록 배치 - BF-9: grid와 Sonnet 역할 분리 - BF-10: catalog mtime 캐시 자동 갱신 ## 블록 라이브러리 - 46개 블록 (6 카테고리), catalog/BLOCK_SLOTS/INDEX 동기화 - 구 블록 제거 (quote-block, card-grid, comparison) - 13개 _legacy 블록 보존 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+163
-30
@@ -1,8 +1,11 @@
|
||||
"""DA-13b: 3단계 — Kei 텍스트 편집자 (텍스트 정리).
|
||||
|
||||
디자인 팀장의 레이아웃 컨셉 + 원본 콘텐츠를 받아,
|
||||
각 슬롯에 맞는 텍스트를 도메인 전문가로서 정리한다.
|
||||
Kei API를 통해 도메인 전문가로서 각 슬롯 텍스트를 정리한다.
|
||||
팀장의 글자 수 가이드를 참고하되 내용 의미가 우선.
|
||||
|
||||
1차: Kei API (persona + RAG + 도메인 지식)
|
||||
fallback: Anthropic API 직접 호출
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +15,7 @@ import re
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
|
||||
from src.config import settings
|
||||
from src.design_director import BLOCK_SLOTS
|
||||
@@ -30,6 +34,9 @@ EDITOR_PROMPT = """당신은 도메인 전문가이자 콘텐츠 편집자이다
|
||||
- 전체 컨텍스트와 핵심 용어를 보존한다
|
||||
- 세련된 표현으로 편집한다 (원본 그대로가 아님)
|
||||
- 개조식(불릿, 번호)으로 작성한다. 줄글 금지.
|
||||
- **불릿 항목은 반드시 각각 별도 줄(\n)로 작성한다.** 한 줄에 여러 항목을 넣지 마라.
|
||||
- 올바른 예: "• 추진과제: 건설산업 디지털화\n• 실행과제: BIM 전면 도입\n• 출처: 국토교통부"
|
||||
- 잘못된 예: "• 추진과제: 건설산업 디지털화 • 실행과제: BIM 전면 도입 • 출처: 국토교통부"
|
||||
- 출처가 있는 내용은 출처를 반드시 보존한다
|
||||
- 출처가 없는 수치나 통계를 만들지 않는다
|
||||
|
||||
@@ -60,8 +67,6 @@ async def fill_content(
|
||||
Returns:
|
||||
슬롯이 채워진 layout_concept
|
||||
"""
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
for page_idx, page in enumerate(layout_concept.get("pages", [])):
|
||||
blocks = page.get("blocks", [])
|
||||
if not blocks:
|
||||
@@ -74,8 +79,9 @@ async def fill_content(
|
||||
slots = BLOCK_SLOTS.get(block_type, {})
|
||||
char_guide = block.get("char_guide", {})
|
||||
|
||||
topic_id = block.get("topic_id", i + 1)
|
||||
req_text = (
|
||||
f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}):\n"
|
||||
f"블록 {i+1} ({block_type}, 영역: {block.get('area', '?')}, topic_id: {topic_id}):\n"
|
||||
f" 용도: {block.get('reason', '미지정')}\n"
|
||||
f" 크기: {block.get('size', 'medium')}\n"
|
||||
f" 필수 슬롯: {slots.get('required', [])}\n"
|
||||
@@ -101,26 +107,47 @@ async def fill_content(
|
||||
"내용의 의미를 살려서 편집해. 글자 수 가이드는 참고만.\n"
|
||||
"자세히보기 대상 블록은 summary + detail 두 버전을 작성해.\n"
|
||||
"형식:\n"
|
||||
'{"blocks": [{"area": "...", "type": "...", "data": {슬롯 키-값}}]}'
|
||||
'{"blocks": [{"area": "...", "type": "...", "topic_id": 1, "data": {슬롯 키-값}}]}'
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
system=EDITOR_PROMPT,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
# 1차: Kei API (도메인 전문가 + RAG)
|
||||
result_text = await _call_kei_editor(user_prompt)
|
||||
|
||||
# fallback: Anthropic 직접
|
||||
if result_text is None:
|
||||
logger.warning("Kei API 편집 실패. Anthropic 직접 호출로 fallback.")
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
system=EDITOR_PROMPT,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
result_text = response.content[0].text
|
||||
|
||||
result_text = response.content[0].text
|
||||
filled = _parse_json(result_text)
|
||||
|
||||
if filled and "blocks" in filled:
|
||||
for filled_block in filled["blocks"]:
|
||||
for orig_block in blocks:
|
||||
if orig_block.get("area") == filled_block.get("area"):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
break
|
||||
matched = False
|
||||
# 1차: topic_id로 정확 매칭
|
||||
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", {})
|
||||
matched = True
|
||||
break
|
||||
# 2차: area + type으로 매칭 (topic_id 없을 때)
|
||||
if not matched:
|
||||
for orig_block in blocks:
|
||||
if (
|
||||
orig_block.get("area") == filled_block.get("area")
|
||||
and orig_block.get("type") == filled_block.get("type")
|
||||
and "data" not in orig_block
|
||||
):
|
||||
orig_block["data"] = filled_block.get("data", {})
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"텍스트 정리 완료 (페이지 {page_idx + 1}): "
|
||||
@@ -137,23 +164,129 @@ async def fill_content(
|
||||
return layout_concept
|
||||
|
||||
|
||||
async def _call_kei_editor(prompt: str) -> str | None:
|
||||
"""Kei API를 통해 텍스트 편집을 요청한다.
|
||||
|
||||
Kei persona의 도메인 지식 + RAG를 활용하여
|
||||
건설/DX 분야 전문 용어를 정확하게 유지하면서 편집.
|
||||
"""
|
||||
kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
|
||||
|
||||
full_prompt = EDITOR_PROMPT + "\n\n" + prompt
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
response = await client.post(
|
||||
f"{kei_url}/api/message",
|
||||
json={
|
||||
"message": full_prompt,
|
||||
"session_id": "design-agent-editor",
|
||||
"mode": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"Kei API (editor) HTTP {response.status_code}")
|
||||
return None
|
||||
|
||||
# SSE 응답에서 텍스트 수집
|
||||
full_text = _extract_sse_text(response.text)
|
||||
if full_text:
|
||||
return full_text
|
||||
|
||||
logger.warning("Kei API (editor) 텍스트 추출 실패")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Kei API (editor) 호출 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_sse_text(raw: str) -> str:
|
||||
"""SSE 응답에서 토큰 텍스트를 수집한다."""
|
||||
import re as _re
|
||||
tokens = []
|
||||
events = _re.split(r'\r?\n\r?\n', raw)
|
||||
for event in events:
|
||||
if not event.strip():
|
||||
continue
|
||||
event_type = ""
|
||||
event_data = ""
|
||||
for line in event.split('\n'):
|
||||
line = line.strip('\r')
|
||||
if line.startswith('event:'):
|
||||
event_type = line[6:].strip()
|
||||
elif line.startswith('data:'):
|
||||
event_data = line[5:].strip()
|
||||
if not event_data:
|
||||
continue
|
||||
if event_type == 'token':
|
||||
try:
|
||||
token = json.loads(event_data)
|
||||
if isinstance(token, str):
|
||||
tokens.append(token)
|
||||
except json.JSONDecodeError:
|
||||
tokens.append(event_data)
|
||||
elif event_type == 'done':
|
||||
break
|
||||
return "".join(tokens)
|
||||
|
||||
|
||||
def _apply_defaults(blocks: list[dict[str, Any]]) -> None:
|
||||
"""실패 시 기본 데이터 적용."""
|
||||
defaults = {
|
||||
"quote-block": {"quote_text": "(텍스트 정리 실패)"},
|
||||
"card-grid": {"cards": []},
|
||||
"conclusion-bar": {"conclusion_text": "(결론 생성 실패)"},
|
||||
"comparison": {
|
||||
"left_title": "항목 A", "left_content": "-",
|
||||
"right_title": "항목 B", "right_content": "-",
|
||||
},
|
||||
"relationship": {
|
||||
"center_label": "관계도", "center_sub": "",
|
||||
"items": [], "description": "",
|
||||
},
|
||||
"process": {"steps": []},
|
||||
"comparison-table": {"headers": [], "rows": []},
|
||||
"image-block": {"src": "", "alt": "이미지"},
|
||||
# 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-text-grid": {"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": []},
|
||||
"layer-diagram": {"layers": []},
|
||||
"timeline-vertical": {"events": []},
|
||||
"timeline-horizontal": {"events": []},
|
||||
"pyramid-hierarchy": {"levels": []},
|
||||
# emphasis/
|
||||
"quote-left-border": {"quote_text": "(인용)"},
|
||||
"quote-big-mark": {"quote_text": "(인용)"},
|
||||
"quote-question": {"question": "(질문)"},
|
||||
"conclusion-accent-bar": {"conclusion_text": "(결론)"},
|
||||
"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": ""},
|
||||
"details-block": {"summary_text": "(상세 내용)", "detail_content": ""},
|
||||
}
|
||||
for block in blocks:
|
||||
|
||||
Reference in New Issue
Block a user