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:
2026-03-25 18:40:20 +09:00
co-authored by Claude Opus 4.6
parent 91d5779a16
commit 9bd9dad9ac
220 changed files with 19115 additions and 667 deletions
+163 -30
View File
@@ -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: