Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정
Phase W: - weight 비율 초기 배정 (space_allocator header 높이 반영) - block_assembler 공통 조립 함수 (filled/assembled 통합) - filled → Selenium 측정 → context 저장 - sidebar overflow 확장 + body 재배분 - sub_layouts 사전 계산 (이미지 누락 해결) Phase V': - 팝업 링크 우측상단 배치 (인라인 → position:absolute) - 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약) - 출처 라벨 삭제 + 이미지 아래 캡션 배치 - after 공란 제거 (결론 바로 위까지 body/sidebar 채움) 추가: - V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단 - ** 마크다운 → <strong> 변환 - [이미지:] 마커 제거 (bold 변환 전 처리) - grid-template-rows AFTER 크기 반영 (Sonnet final) - assemble_stage2 CSS font-size override, white-space fix - 하드코딩 전수 검토 완료 - 본심 여러 topic 텍스트 합침 Phase X 계획 문서 작성 (동적 역할 구조) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
854
ARCHITECTURE-PHASE-T.md
Normal file
@@ -0,0 +1,854 @@
|
||||
# Design Agent Architecture — Phase T
|
||||
|
||||
> MDX 원본 문서 → 고정 크기 HTML 슬라이드(1280×720px) 자동 생성 파이프라인
|
||||
> **폰트 위계가 먼저, 컨테이너가 따라간다** — 텍스트 보존 · 폰트 위계 강제 · 디자인 요소 크기를 수학적으로 역산
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 설계 원칙
|
||||
|
||||
### 1.1 AI vs 코드 역할 분리
|
||||
|
||||
| 역할 | 담당 | 해당 Stage |
|
||||
|---|---|---|
|
||||
| 콘텐츠 판단 · 분류 | AI (Kei Persona API / Opus) | 1A, 1B |
|
||||
| 폰트 위계 확정 + 컨테이너 비율 역산 | 코드 (결정론적 수학) | 1.5a |
|
||||
| 블록 선택 · 변형 결정 | 코드 (키워드 매칭 + 룩업 테이블) | 1.7 |
|
||||
| 블록 schema 기반 디자인 예산 역산 | 코드 (결정론적 수학) | 1.5b |
|
||||
| HTML 생성 | AI (Claude Sonnet 4) | 2 |
|
||||
| 텍스트·구조 검증 (L1~L3) | 코드 (kiwipiepy + regex) | 2 직후 |
|
||||
| 실측 렌더링 (L4) | Selenium (headless Chrome) | 3 직후 |
|
||||
| 시각 품질 평가 (L5) | AI (Opus Vision) | 4 |
|
||||
| HTML 조립 · 서빙 | 코드 | 3, 5 |
|
||||
|
||||
AI가 공간을 볼 수 없는 근본적 한계를 코드(수학적 예산 역산)로 보완하는 구조.
|
||||
LLM이 참고 HTML 구조를 70~90% 복사하는 경향을 장점으로 활용 — "디자인 레퍼런스" 프레이밍.
|
||||
|
||||
### 1.2 폰트 위계 (Phase T 핵심 — 이것이 모든 계산의 출발점)
|
||||
|
||||
Phase S에서 폰트 크기가 중요도와 완전히 역전됨 (sidebar 14px > key-msg 11px).
|
||||
Phase T는 **위계를 먼저 확정하고, 컨테이너가 위계에 맞춰지는** 방향으로 전환.
|
||||
|
||||
| 영역 | 중요도 | 폰트 범위 | 강제 규칙 |
|
||||
|------|--------|----------|----------|
|
||||
| 핵심 (key-msg) | 1위 | **14px** bold | 무조건 한 줄, 슬라이드에서 가장 큰 폰트 |
|
||||
| 본문 (core) | 2위 | **12px** | 본문 텍스트 기본 |
|
||||
| 배경 (bg) | 3위 | **10-12px** | 텍스트 양에 따라 범위 내 조정 |
|
||||
| 첨부 (sidebar) | 4위 | **9-11px** | 참고 자료, 가장 작아도 됨 |
|
||||
|
||||
**검증 기준:** `font_size(핵심) > font_size(본문) ≥ font_size(배경) > font_size(첨부)` — 위반 시 에러.
|
||||
|
||||
### 1.3 파이프라인 운영 패턴
|
||||
|
||||
#### 누적 컨텍스트 객체 (Pydantic BaseModel)
|
||||
|
||||
각 Stage가 독립 JSON을 읽고 쓰는 대신, `PipelineContext` 하나가 파이프라인을 따라가며 점진적으로 확장. T-0 조사 결과 **Pydantic BaseModel** 채택 (dataclass 아님) — `model_dump_json()` 직렬화, `validate_assignment=True` 타입 검증.
|
||||
|
||||
```python
|
||||
context.normalized.clean_text # Stage 0
|
||||
context.normalized.title # Stage 0
|
||||
context.normalized.images # Stage 0
|
||||
context.normalized.popups # Stage 0
|
||||
context.normalized.tables # Stage 0
|
||||
context.analysis.core_message # Stage 1A
|
||||
context.analysis.topics[0].source_hint # Stage 1A
|
||||
context.analysis.page_structure # Stage 1A
|
||||
context.topics[0].relation_type # Stage 1B
|
||||
context.topics[0].expression_hint # Stage 1B
|
||||
context.topics[0].source_data # Stage 1B
|
||||
context.font_hierarchy # Stage 1.5a
|
||||
context.container_ratio # Stage 1.5a (동적 body:sidebar 비율)
|
||||
context.containers["본심"].text_budget # Stage 1.5a
|
||||
context.references["본심"].block_id # Stage 1.7
|
||||
context.references["본심"].design_reference_html # Stage 1.7
|
||||
context.containers["본심"].design_budget # Stage 1.5b (블록 선택 후 재계산)
|
||||
context.generated_html # Stage 2
|
||||
context.rendered_html # Stage 3
|
||||
context.measurement # Stage 4
|
||||
context.quality_score # Stage 4
|
||||
```
|
||||
|
||||
#### 각 Stage 공통 실행 패턴
|
||||
|
||||
```python
|
||||
async def run_stage(stage_fn, context, stage_name, max_retries=1):
|
||||
for attempt in range(max_retries + 1):
|
||||
result = await stage_fn(context)
|
||||
errors = result.get("_errors", [])
|
||||
if not errors:
|
||||
# Pydantic: model_copy(update=...) 사용
|
||||
context = context.model_copy(update=result)
|
||||
context.save_snapshot(stage_name)
|
||||
return context
|
||||
context.errors.append({"stage": stage_name, "attempt": attempt, "errors": errors})
|
||||
if attempt < max_retries:
|
||||
context.retry_feedback = build_retry_feedback(stage_name, errors)
|
||||
raise StageFailure(stage_name, errors)
|
||||
```
|
||||
|
||||
#### 에러 3등급 분류
|
||||
|
||||
| 등급 | 의미 | 대응 |
|
||||
|------|------|------|
|
||||
| **FATAL** | 복구 불가 (원본 문제, JSON 파싱 실패) | 파이프라인 중단 |
|
||||
| **RETRYABLE** | AI 재시도로 해결 가능 (분류 오류, 누락) | Self-Refine 피드백 포함 재요청 (최대 2회) |
|
||||
| **ADJUSTABLE** | 코드로 자동 조정 가능 (높이 부족, 비율 초과) | 자동 조정 후 경고 기록 |
|
||||
|
||||
#### 스냅샷 저장
|
||||
|
||||
`data/runs/{run_id}/step{N}_context.json` — run_id는 `YYYYMMDD_HHMMSS` timestamp.
|
||||
Pydantic `model_dump_json()`으로 직렬화. `diff step1a_context.json step1b_context.json`으로 추적.
|
||||
|
||||
---
|
||||
|
||||
## 2. 파이프라인 (11 Stage)
|
||||
|
||||
### Stage 0: MDX 표준화
|
||||
|
||||
- **담당:** 코드
|
||||
- **신규 파일:** `src/mdx_normalizer.py`
|
||||
- **라이브러리:** `python-frontmatter` + `markdown-it-py` + `mdit-py-plugins` (총 ~1MB)
|
||||
- **입력:** 원본 MDX 텍스트
|
||||
- **처리 (4-Layer 파서):**
|
||||
- **Layer 1:** `python-frontmatter.parse()` → `(metadata_dict, body_str)` 분리. title 추출.
|
||||
- **Layer 2:** 코드블록 보호 (backtick 10→3 순서로 fenced block → placeholder) → MDX 전용 패턴 처리:
|
||||
- Astro `:::directive` → `[핵심요약]...[/핵심요약]` 마커
|
||||
- `<details><summary>제목</summary>내용</details>` → popups[] 추출
|
||||
- JSX `style={{}}`, `import/export` 제거
|
||||
- **Layer 3:** `markdown-it-py` AST 파싱 (`js-default` 프리셋, table 기본 포함):
|
||||
- heading 토큰 → 섹션 구조 추출 (tag, level, content, source line)
|
||||
- image 토큰 → images[] 추출 (alt, src)
|
||||
- table 토큰 → tables[] 추출 (header, rows)
|
||||
- 코드블록 placeholder 복원
|
||||
- **Layer 4:** 텍스트 정리 — 남은 HTML 태그 제거, 빈 줄 정리, 최종 clean_text
|
||||
- **출력:**
|
||||
```python
|
||||
{
|
||||
"clean_text": str, # 정규화된 순수 텍스트
|
||||
"title": str, # frontmatter 제목
|
||||
"images": [{"alt": str, "path": str}],
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"header": list, "rows": list}],
|
||||
"sections": [{"level": int, "title": str, "content": str}] # ## 기준 섹션 분리
|
||||
}
|
||||
```
|
||||
- **검증:**
|
||||
- clean_text 비어있지 않음
|
||||
- `##` 섹션 최소 1개
|
||||
- 원본 대비 30% 이상 텍스트 보존 (과도한 제거 방지)
|
||||
- images[] 수 = 원본 `![` 패턴 수
|
||||
- popups[] 수 = 원본 `<details>` 패턴 수
|
||||
- **주의:** 기존 `normalize_mdx()`의 `r"^## \d+\.\s*"` → `r"^## \d+\.\s+"` 수정 (공백 1개 이상 필수)
|
||||
- **저장:** `context.normalized.*`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1A: Kei 꼭지 추출
|
||||
|
||||
- **담당:** AI (Kei Persona API, localhost:8000, Opus — SSE 스트리밍)
|
||||
- **입력:** `context.normalized.clean_text` (Stage 0에서 정규화된 텍스트)
|
||||
- **처리:** Kei가 콘텐츠를 읽고 꼭지 분류 + 스토리라인 설계
|
||||
- **출력:**
|
||||
- `topics[]` — id, title, purpose, role, layer, weight, **source_hint** (원본 MDX 섹션 참조)
|
||||
- `page_structure` — { "본심": {topic_ids, weight}, "배경": {...}, "첨부": {...}, "결론": {...} }
|
||||
- `core_message` — 슬라이드 핵심 메시지 한 줄
|
||||
- **검증 (Pydantic + 코드 대조):**
|
||||
- **형식:** weight 합 0.9~1.1 범위, 본심 weight ≥ 0.3, 필수 필드 존재, topics > 0
|
||||
- **내용 대조:** 원본 `##` 섹션 수 vs topic 수 비교 — 차이가 크면 분류 오류 가능성
|
||||
- **내용 대조:** topic summary 키워드가 원본 해당 섹션에 실제 존재하는지 (kiwipiepy)
|
||||
- 실패 시 RETRYABLE → Self-Refine 피드백 포함 재요청 (최대 2회)
|
||||
- **저장:** `context.analysis`, `context.topics`, `context.page_structure`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1B: 컨셉 구체화
|
||||
|
||||
- **담당:** AI (Kei Persona API, Opus — SSE 스트리밍)
|
||||
- **입력:** `context.normalized.clean_text` + `context.topics` (Stage 1A 결과)
|
||||
- **처리:** 각 꼭지에 관계 유형, 표현 힌트, 원본 텍스트 참조 부여
|
||||
- **출력:** topics에 아래 필드 병합
|
||||
- `relation_type` — **7개 enum:** hierarchy / cause_effect / comparison / sequence / definition / inclusion / **none**
|
||||
- `expression_hint` — 디자인 방향 힌트 (3문장 구조: 관계 선언 + 콘텐츠 설명 + 시각 지침)
|
||||
- `source_data` — 원본 텍스트 참조
|
||||
- **검증 (Pydantic + 코드 대조 + 모순 탐지):**
|
||||
- **형식:** relation_type이 7개 enum 중 하나, expression_hint 비어있지 않음, source_data 비어있지 않음
|
||||
- **모순 결정 테이블:**
|
||||
|
||||
| purpose | 모순인 relation_type | 이유 |
|
||||
|---------|---------------------|------|
|
||||
| 결론강조 | comparison, sequence | 결론은 비교나 순서가 아님 |
|
||||
| 문제제기 | sequence, definition | 문제제기는 순서 나열이나 정의가 아님 |
|
||||
| 용어정의 | hierarchy, cause_effect | 정의 나열은 상하위나 인과가 아님 |
|
||||
| 구조시각화 | none | 시각화할 관계가 없으면 구조시각화가 아님 |
|
||||
|
||||
- **source_data 원본 대조:** source_data 키워드가 원본 clean_text에 실제 존재하는지 (kiwipiepy). 없는 출처 감지 → 할루시네이션
|
||||
- **relation_type 원본 대조:** 한국어 관계 표현 패턴으로 검증
|
||||
|
||||
| relation_type | 원본에 있어야 하는 패턴 (일부) |
|
||||
|---------------|-------------------------------|
|
||||
| comparison | vs, 반면, 차이점, 에 비해, 와 달리, 상이, 구분 |
|
||||
| sequence | →, 이후, 단계, 먼저, 점진적, 과정, 를 거쳐 |
|
||||
| hierarchy | 상위, 하위, 속하, 범주, 구성요소, 체계, 계층 |
|
||||
| inclusion | 포함, 융합, 통합, 결합, 내포, 포괄, 연계 |
|
||||
| cause_effect | 때문에, 따라서, 결과, 로 인해, 초래, 야기, 기인 |
|
||||
| definition | 이란, 정의, 의미, 을 말한다, 라 함은, 용어 |
|
||||
|
||||
- 실패 시 RETRYABLE → 모순/불일치 topic만 피드백 포함 재요청 (최대 2회)
|
||||
- **저장:** `context.topics[].relation_type`, `.expression_hint`, `.source_data`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1.5a: 폰트 위계 확정 + 컨테이너 비율 역산
|
||||
|
||||
- **담당:** 코드 (AI 아님, 결정론적 수학)
|
||||
- **입력:** page_structure weight + 각 영역의 source_data 텍스트 양
|
||||
- **핵심 원칙:** **폰트가 먼저, 컨테이너가 따라간다**
|
||||
|
||||
#### (1) 폰트 위계에서 필요 공간 계산
|
||||
|
||||
```python
|
||||
FONT_HIERARCHY = {
|
||||
"핵심": {"min": 14, "max": 14, "weight": "bold"},
|
||||
"본심": {"min": 12, "max": 12},
|
||||
"배경": {"min": 10, "max": 12},
|
||||
"첨부": {"min": 9, "max": 11},
|
||||
}
|
||||
|
||||
def calculate_required_space(role, content, font_size):
|
||||
"""이 폰트 크기로 이 텍스트를 넣으려면 몇 px 필요한가?"""
|
||||
char_width_px = font_size * 0.947 # Pretendard 한글 실측 비율
|
||||
line_height_px = font_size * 1.5 # 본문 기준
|
||||
chars_per_line = available_width // char_width_px
|
||||
total_lines = len(content) // chars_per_line
|
||||
required_height = total_lines * line_height_px + padding
|
||||
return required_height
|
||||
```
|
||||
|
||||
#### (2) 동적 body:sidebar 비율 역산
|
||||
|
||||
고정 65:35가 아니라 텍스트 양에서 역산:
|
||||
|
||||
```python
|
||||
def calculate_container_ratio(roles_text_volume, font_hierarchy):
|
||||
"""폰트 위계를 지키면서 모든 텍스트가 들어가는 비율을 역산"""
|
||||
# 1. 각 역할의 위계 기준 폰트로 필요 공간 계산
|
||||
sidebar_need = calculate_required_space("첨부", sidebar_text, font_hierarchy["첨부"]["max"])
|
||||
body_need = sum(calculate_required_space(r, t, font_hierarchy[r]["max"])
|
||||
for r, t in body_roles)
|
||||
|
||||
# 2. sidebar 충전율로 비율 결정
|
||||
sidebar_capacity_at_35 = estimate_capacity(slide_width * 0.35, font_hierarchy["첨부"]["max"])
|
||||
fill_rate = len(sidebar_text) / sidebar_capacity_at_35
|
||||
|
||||
if fill_rate < 0.5:
|
||||
ratio = (72, 28) # sidebar 텍스트 적음 → body 확대
|
||||
elif fill_rate < 0.8:
|
||||
ratio = (68, 32) # 보통
|
||||
else:
|
||||
ratio = (65, 35) # 현재 유지
|
||||
|
||||
return ratio # (body_pct, sidebar_pct)
|
||||
```
|
||||
|
||||
#### (3) 텍스트 예산 계산
|
||||
|
||||
비율 확정 후, 각 영역의 텍스트 예산:
|
||||
|
||||
```python
|
||||
def calculate_text_budget(container, content, font_size):
|
||||
char_width_px = font_size * 0.947
|
||||
line_height_px = font_size * 1.5
|
||||
inner_width = container.width_px - padding * 2
|
||||
inner_height = container.height_px - padding * 2
|
||||
|
||||
chars_per_line = int(inner_width / char_width_px)
|
||||
max_lines = int(inner_height / line_height_px)
|
||||
max_chars = chars_per_line * max_lines
|
||||
|
||||
source_chars = len(content)
|
||||
needs_compression = source_chars > max_chars
|
||||
|
||||
return TextBudget(
|
||||
font_size=font_size,
|
||||
chars_per_line=chars_per_line,
|
||||
max_lines=max_lines,
|
||||
max_chars=max_chars,
|
||||
source_chars=source_chars,
|
||||
needs_compression=needs_compression,
|
||||
)
|
||||
```
|
||||
|
||||
#### (4) 다단 레이아웃 판단
|
||||
|
||||
위계 범위 내 최소 폰트로도 텍스트가 안 들어가면 구조 변경:
|
||||
|
||||
```
|
||||
1. 위계 기준 폰트(max)로 수용량 계산
|
||||
2. 텍스트 양 > 수용량 → 폰트 1px 축소 (위계 min까지)
|
||||
3. 최소 폰트로도 불가 → 레이아웃 변경 (1단→2단)
|
||||
4. 2단으로도 불가 → 비율 조정 (sidebar 축소 → body 확대)
|
||||
5. 비율 조정으로도 불가 → 텍스트 편집 필요 경고 (context.warnings에 기록)
|
||||
```
|
||||
|
||||
- **검증:** height_px 합 ≤ 전체 높이, 폰트 위계 유지, 음수 없음
|
||||
- **저장:** `context.font_hierarchy`, `context.container_ratio`, `context.containers[].text_budget`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1.7: 참고 블록 선택 + 변형 결정
|
||||
|
||||
- **담당:** 코드 (키워드 매칭 + 룩업 테이블, AI 아님)
|
||||
- **입력:** 1B의 relation_type + expression_hint + 1.5a의 컨테이너 스펙 + catalog.yaml
|
||||
- **처리 4단계:**
|
||||
|
||||
#### (1) relation_type → 블록 후보 (1차 필터)
|
||||
|
||||
catalog.yaml의 `relation_types` 필드로 필터:
|
||||
|
||||
```python
|
||||
candidates = [b for b in catalog.blocks
|
||||
if relation_type in b.relation_types or not b.relation_types]
|
||||
```
|
||||
|
||||
#### (2) expression_hint → 블록 세분화 (2차 필터 — 키워드 포함 여부)
|
||||
|
||||
expression_hint는 긴 문장이므로 **정확한 문자열 매칭이 아니라 키워드 포함(substring) 매칭**:
|
||||
|
||||
```python
|
||||
VISUAL_TYPE_KEYWORDS = {
|
||||
"인과": {"keywords": ["인과", "현상->결과", "야기", "원인"], "blocks": ["callout-warning", "dark-bullet-list"]},
|
||||
"나열_병렬": {"keywords": ["독립적 나열", "병렬 나열", "개별 증거"], "blocks": ["dark-bullet-list", "card-icon-desc"]},
|
||||
"나열_정의": {"keywords": ["독립적 정의", "용어", "참조용"], "blocks": ["card-numbered"]},
|
||||
"포함_계층": {"keywords": ["상위-하위", "포함 관계", "계층적"], "blocks": ["venn-diagram", "keyword-circle-row"]},
|
||||
"강조_결론": {"keywords": ["핵심 메시지 강조", "임팩트", "한 줄 강조"], "blocks": ["banner-gradient", "quote-big-mark"]},
|
||||
"비교": {"keywords": ["대등 비교", "좌우 대조", "vs"], "blocks": ["compare-2col-split", "compare-3col-badge"]},
|
||||
"순서": {"keywords": ["시간 순서", "단계별", "A->B->C"], "blocks": ["flow-arrow-horizontal", "process-horizontal"]},
|
||||
}
|
||||
|
||||
def match_visual_type(expression_hint: str) -> str:
|
||||
"""expression_hint에서 키워드를 찾아 시각적 유형 반환"""
|
||||
for vtype, spec in VISUAL_TYPE_KEYWORDS.items():
|
||||
if any(kw in expression_hint for kw in spec["keywords"]):
|
||||
return vtype
|
||||
return "default"
|
||||
```
|
||||
|
||||
시각 매핑 근거 (Gestalt 원칙):
|
||||
- 폐합(Closure) → hierarchy/inclusion → 원형(벤 다이어그램)
|
||||
- 근접(Proximity) → comparison → 좌우 표/비교
|
||||
- 연속(Continuity) → sequence → 화살표 흐름
|
||||
- 유사(Similarity) → definition → 동일 형태 카드 반복
|
||||
- PPTAgent(EMNLP 2025): "참고 기반 생성"의 효과를 학술 입증
|
||||
|
||||
#### (3) 컨테이너 크기 적합성 검사
|
||||
|
||||
```python
|
||||
candidates = [b for b in candidates
|
||||
if b.min_height_px <= container.height_px]
|
||||
```
|
||||
|
||||
#### (4) 블록 변형(variant) + 레이아웃 자동 선택
|
||||
|
||||
```python
|
||||
def select_block_variant(block, container, content):
|
||||
if not block.variants or len(block.variants) <= 1:
|
||||
return block.id, "default"
|
||||
|
||||
for variant in block.variants:
|
||||
if variant.id == "compact" and container.height_px < 150:
|
||||
return block.id, "compact"
|
||||
if variant.id == "wide" and container_ratio[0] >= 70: # body 70% 이상
|
||||
return block.id, "wide"
|
||||
|
||||
return block.id, "default"
|
||||
```
|
||||
|
||||
#### (5) fallback 정의
|
||||
|
||||
모든 필터를 통과하는 후보가 없을 때의 카테고리별 기본 블록:
|
||||
|
||||
| 카테고리 | fallback 블록 | 이유 |
|
||||
|----------|-------------|------|
|
||||
| cards | card-numbered | 가장 범용, compact~xlarge 대응 |
|
||||
| emphasis | dark-bullet-list | 텍스트 중심, 높이 유연 |
|
||||
| visuals | venn-diagram | N개 자동 배치 가능 |
|
||||
| tables | compare-2col-split | 가장 기본적 비교 |
|
||||
| media | image-side-text | 텍스트+이미지 조합 |
|
||||
|
||||
#### 디자인 레퍼런스 HTML 생성
|
||||
|
||||
Jinja 변수를 샘플 데이터로 치환한 완성된 HTML + 구조 의도 주석.
|
||||
LLM이 이 구조를 70~90% 복사 → 레이아웃을 "발명"하지 않고 검증된 구조를 따름.
|
||||
|
||||
```python
|
||||
def generate_design_reference(block, variant, catalog_entry):
|
||||
template = load_template(block.template)
|
||||
sample_data = build_sample_data(catalog_entry.slots)
|
||||
rendered = template.render(**sample_data)
|
||||
|
||||
# 구조 의도 주석 추가 (LLM이 의도를 정확히 파악)
|
||||
annotated = f"<!-- {block.id}: {catalog_entry.visual} -->\n"
|
||||
if catalog_entry.get("visual_diff"):
|
||||
annotated += f"<!-- 차별점: {catalog_entry.visual_diff} -->\n"
|
||||
annotated += rendered
|
||||
|
||||
return annotated
|
||||
```
|
||||
|
||||
- **출력:**
|
||||
|
||||
```json
|
||||
{
|
||||
"block_id": "dark-bullet-list",
|
||||
"variant": "default",
|
||||
"visual_type": "인과",
|
||||
"schema": {
|
||||
"title": {"max_lines": 1, "font_size": 16, "max_chars": 30},
|
||||
"bullet_item": {"max_lines": 1, "font_size": 14, "max_chars": 86},
|
||||
"max_bullets": 5
|
||||
},
|
||||
"design_reference_html": "<!-- dark-bullet-list: 다크 배경 + 파란 제목 + 흰 불릿 -->\n<div ...>..."
|
||||
}
|
||||
```
|
||||
|
||||
- **검증:** 선택된 블록이 catalog.yaml에 실제 존재, min_height_px ≤ container.height_px
|
||||
- **저장:** `context.references["본심"].*`
|
||||
|
||||
---
|
||||
|
||||
### Stage 1.5b: 디자인 예산 재계산 (블록 선택 후)
|
||||
|
||||
- **담당:** 코드 (AI 아님)
|
||||
- **입력:** Stage 1.7에서 선택된 블록의 schema + Stage 1.5a의 컨테이너 스펙
|
||||
- **목적:** 텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산. **텍스트를 줄이는 것이 아니라 도형·이미지·CSS 요소의 크기를 맞추는 방향.**
|
||||
|
||||
```python
|
||||
def calculate_design_budget(container, text_budget, block_schema):
|
||||
# 블록 schema에서 텍스트 슬롯별 높이 합산
|
||||
text_height = 0
|
||||
for slot_name, spec in block_schema.items():
|
||||
if slot_name.startswith("max_"):
|
||||
continue
|
||||
slot_lines = spec.get("max_lines", 1)
|
||||
slot_font = spec.get("font_size", 14)
|
||||
text_height += slot_lines * (slot_font * 1.6)
|
||||
|
||||
remaining_height = container.height_px - text_height - padding
|
||||
remaining_width = container.width_px - padding
|
||||
|
||||
return DesignBudget(
|
||||
available_height_px=remaining_height,
|
||||
available_width_px=remaining_width,
|
||||
max_circle_diameter=min(remaining_height, remaining_width) - 4,
|
||||
max_img_width=remaining_width * 0.4,
|
||||
max_img_height=remaining_height,
|
||||
fits=remaining_height >= 0,
|
||||
)
|
||||
```
|
||||
|
||||
- **검증:** available_height_px ≥ 0 (음수 = 블록이 컨테이너에 안 맞음 → Stage 1.7 재선택 또는 ADJUSTABLE)
|
||||
- **저장:** `context.containers["본심"].design_budget`
|
||||
|
||||
---
|
||||
|
||||
### Stage 2: HTML 생성
|
||||
|
||||
- **담당:** AI (Claude Sonnet 4, Anthropic API 직접, 현재 모델: `claude-sonnet-4-20250514`)
|
||||
- **입력:** 원본 텍스트 + 누적 컨텍스트 전체
|
||||
- **처리:** 영역별(배경/본심/첨부/결론) **각각 개별 호출**로 HTML 생성
|
||||
|
||||
프롬프트 구성 — 모든 수치를 **구체적으로** 전달 (Phase S 교훈: 추상적 프롬프트는 실패):
|
||||
|
||||
| 출처 | 포함 내용 |
|
||||
|------|----------|
|
||||
| Stage 0 | clean_text (원본 텍스트 — "이 텍스트를 그대로 사용하라") |
|
||||
| Stage 1A | core_message |
|
||||
| Stage 1B | expression_hint, relation_type |
|
||||
| Stage 1.5a | 확정된 폰트 크기, 줄 수, 글자 수, 컨테이너 px |
|
||||
| Stage 1.5b | 디자인 요소 크기 제약 (max_circle_px, max_img_width 등) |
|
||||
| Stage 1.7 | 디자인 레퍼런스 HTML + visual_diff 설명 |
|
||||
|
||||
프롬프트 예시:
|
||||
|
||||
```
|
||||
[디자인 레퍼런스]
|
||||
아래 HTML의 구조와 색상 패턴을 따르되 콘텐츠를 교체하세요.
|
||||
<!-- dark-bullet-list: 다크 배경 + 파란 제목 + 흰 불릿 -->
|
||||
<!-- 차별점: 같은 다크 계열 callout-warning과 달리 경고 아이콘 없음. 순수 나열용. -->
|
||||
<div style="background:#1a2332; padding:20px; border-radius:6px;">
|
||||
<!-- SLOT: title (1줄, 16px bold, max 30자) -->
|
||||
<h3 style="color:#4a9eff; font-size:16px; font-weight:700;">샘플 제목</h3>
|
||||
<!-- SLOT: bullets (1줄씩, 14px, max 86자, max 5개) -->
|
||||
<ul style="list-style:none; padding:0;">
|
||||
<li style="color:#e2e8f0; font-size:14px; padding:4px 0;">• 샘플 항목 1</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
[수치 제약 — 반드시 준수]
|
||||
- 컨테이너: 너비 707px, 높이 176px
|
||||
- 폰트: 11px (배경 영역 위계)
|
||||
- 줄당 최대 68자
|
||||
- 최대 10줄
|
||||
- 디자인 요소 예산: 높이 84px, 너비 707px
|
||||
|
||||
[원본 텍스트 — 축약/변형 금지]
|
||||
"DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음..."
|
||||
|
||||
[필수 규칙]
|
||||
- inline style만 사용, <style> 블록 금지
|
||||
- overflow:hidden 금지
|
||||
- 디자인 레퍼런스의 구조를 따르되 콘텐츠에 맞게 커스텀
|
||||
- 개조식 통일 (서술형 ~하다/~이다 → 개조식 ~에 해당/~인식되는 중)
|
||||
```
|
||||
|
||||
- **이미지 처리:** Stage 0에서 추출된 `images[]`의 경로와 크기 정보를 프롬프트에 포함. Stage 5에서 base64 인라인 변환.
|
||||
- **팝업 처리:** Stage 0에서 추출된 `popups[]`를 `<details>/<summary>` HTML로 변환 지시.
|
||||
- **출력:** `{body_html, sidebar_html, footer_html}`
|
||||
- **저장:** `context.generated_html`
|
||||
|
||||
---
|
||||
|
||||
### 분산 검증 시스템
|
||||
|
||||
5층 검증을 한 곳에 집중하지 않고, **각 Layer가 적합한 시점에 분산 실행**.
|
||||
재시도 프롬프트는 Self-Refine(NeurIPS 2023) 패턴: `localization + evidence + instruction`.
|
||||
VASCAR(2024)의 Scorer+Suggester 분리: 점수 매기기와 피드백 생성을 분리.
|
||||
|
||||
#### Stage 2 직후: L1 + L2 + L3 (코드 검증)
|
||||
|
||||
| Layer | 도구 | 검증 내용 |
|
||||
|---|---|---|
|
||||
| L1 | kiwipiepy + regex | 키워드 보존율 80% 이상 |
|
||||
| L2 | regex | Kei 메모("간결한 문제 제기용" 등)가 출력에 포함 안 됐는지 |
|
||||
| L3 | regex | overflow:hidden 없는지, 폰트 위계 위반 없는지, inline style만 사용했는지 |
|
||||
|
||||
실패 시 → Self-Refine 프롬프트로 Stage 2 재실행 (최대 2회):
|
||||
|
||||
```
|
||||
[재생성 요청 - 시도 2/3]
|
||||
이전 생성의 문제:
|
||||
1. L1: 키워드 보존율 65%. 누락: {'BIM', '혼용', '설계오류'}
|
||||
2. L3: overflow:hidden 감지
|
||||
|
||||
수정 지시 (Self-Refine):
|
||||
- localization: 키워드 보존 실패, 구조 위반
|
||||
- evidence: 원본 핵심 키워드 3개 누락, overflow:hidden 존재
|
||||
- instruction: 누락 키워드 포함, overflow:hidden 제거, 나머지 제약 동일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stage 3: 렌더링 (조립)
|
||||
|
||||
- **담당:** 코드 (AI 아님)
|
||||
- **입력:** L1~L3 통과한 body/sidebar/footer HTML + 프리셋 grid + **동적 비율**
|
||||
- **처리:**
|
||||
- tokens.css + base.css 인라인 병합
|
||||
- CSS Grid 프레임 구성 — **동적 비율 적용** (예: `72fr 28fr` or `65fr 35fr`)
|
||||
- 각 영역 HTML을 `<div class="area-body">` 등에 삽입
|
||||
- Pretendard 폰트 CDN 링크 포함
|
||||
- **출력:** 완전한 단독 실행 HTML
|
||||
- **저장:** `context.rendered_html`
|
||||
|
||||
#### Stage 3 직후: L4 (Selenium 실측)
|
||||
|
||||
```python
|
||||
def validate_after_stage3(context, rendered_html):
|
||||
measurements = selenium_measure(rendered_html)
|
||||
errors = []
|
||||
for area, m in measurements.items():
|
||||
if m.scroll_height > m.client_height:
|
||||
overflow = m.scroll_height - m.client_height
|
||||
errors.append({
|
||||
"layer": "L4", "severity": "RETRYABLE",
|
||||
"localization": f"{area} overflow {overflow}px",
|
||||
"evidence": f"scrollHeight {m.scroll_height} > clientHeight {m.client_height}",
|
||||
"instruction": f"이 영역의 디자인 요소를 {overflow+10}px 줄이거나 bullet 1개 제거"
|
||||
})
|
||||
return errors # 실패 → 해당 영역만 Stage 2로 (최대 2회)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: 품질 게이트 (L5)
|
||||
|
||||
- **담당:** Selenium (스크린샷 캡처) + Opus Vision (품질 판정, 현재 모델: `claude-opus-4-0-20250514`)
|
||||
- **처리:**
|
||||
- 전체 페이지 스크린샷 캡처 → Opus Vision에 base64 전송
|
||||
- 5가지 평가 기준 (VASCAR 방식):
|
||||
1. 콘텐츠 겹침/잘림 없는가?
|
||||
2. 본심 영역이 시각적으로 가장 두드러지는가?
|
||||
3. 폰트가 읽을 수 있는 크기인가? **폰트 위계가 유지되는가?**
|
||||
4. 한국어 비즈니스 프레젠테이션으로서 적절한가?
|
||||
5. 블록 유형에 다양성이 있는가?
|
||||
- 0~100점 평가:
|
||||
- 30점 미만 → 출력 차단 (FATAL)
|
||||
- 30~60점 → Opus 피드백으로 Stage 2 재실행
|
||||
- 60점 이상 → Stage 5로
|
||||
- **L4와의 차이:** L4는 영역 단위 px 실측(Stage 3 직후), L5는 조립 후 전체 페이지 **시각적** 평가
|
||||
- **저장:** `context.measurement`, `context.quality_score`
|
||||
|
||||
---
|
||||
|
||||
### Stage 5: 서빙
|
||||
|
||||
- **담당:** 코드
|
||||
- **처리:**
|
||||
- 이미지 경로 → base64 인라인 변환 (다운로드 HTML에서도 이미지 표시)
|
||||
- `<details>` 인쇄 시 자동 펼침 JS 삽입 (`window.onbeforeprint`)
|
||||
- final.html 저장
|
||||
- **저장:** `data/runs/{run_id}/final.html`
|
||||
|
||||
---
|
||||
|
||||
## 3. 검증 흐름 요약
|
||||
|
||||
```
|
||||
Stage 1A (Kei 분석)
|
||||
↓
|
||||
1A 검증 (Pydantic + 원본 대조) ──실패──→ Kei 재요청 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 1B (컨셉 구체화)
|
||||
↓
|
||||
1B 검증 (모순 탐지 + 원본 대조) ──실패──→ Kei 재요청 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 1.5a → 1.7 → 1.5b (코드, 결정론적)
|
||||
↓
|
||||
Stage 2 (HTML 생성)
|
||||
↓
|
||||
L1+L2+L3 ──실패──→ Self-Refine → Stage 2 재실행 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 3 (조립)
|
||||
↓
|
||||
L4 ──실패──→ 실패 영역만 Stage 2로 (최대 2회)
|
||||
↓ 통과
|
||||
Stage 4 (L5 최종 판정)
|
||||
↓
|
||||
30점 미만 → 차단 (FATAL)
|
||||
30~60점 → Opus 피드백으로 Stage 2 재실행 (최대 1회)
|
||||
60점 이상 → Stage 5
|
||||
```
|
||||
|
||||
### 재시도 총 예산
|
||||
|
||||
| 지점 | 최대 재시도 | 대상 |
|
||||
|------|-----------|------|
|
||||
| Stage 1A | 2회 | Kei 전체 |
|
||||
| Stage 1B | 2회 | 실패 topic만 |
|
||||
| L1~L3 | 2회 | 실패 영역만 |
|
||||
| L4 | 2회 | 실패 영역만 |
|
||||
| L5 | 1회 | 전체 |
|
||||
| **최악 합계** | **Stage 2 최대 5회** | 영역별 독립이므로 1영역 기준 |
|
||||
|
||||
전체 파이프라인 타임아웃: 300초. 초과 시 최선 결과 반환 + 경고.
|
||||
|
||||
---
|
||||
|
||||
## 4. 카탈로그 시스템 (catalog.yaml)
|
||||
|
||||
### 4.1 블록 구조
|
||||
|
||||
38개 블록, **6개 카테고리:**
|
||||
|
||||
| 카테고리 | 블록 수 | 용도 |
|
||||
|----------|---------|------|
|
||||
| headers | 5 | 꼭지/섹션 제목 |
|
||||
| cards | 9 | 항목 나열/비교 |
|
||||
| **tables** | **3** | 비교 표, 스트라이프 표 |
|
||||
| **visuals** | **6** | 벤 다이어그램, 프로세스, 흐름 |
|
||||
| emphasis | 10 | 강조/콜아웃/배너/불릿 |
|
||||
| media | 5 | 이미지 배치 |
|
||||
|
||||
별도 섹션: **4개 레이아웃 프리셋** (sidebar-right, two-column, hero-detail, single-column)
|
||||
|
||||
### 4.2 블록 메타데이터 (현재 상태 + Phase T 추가)
|
||||
|
||||
```yaml
|
||||
- id: block-id
|
||||
name: 한글 이름
|
||||
category: headers | cards | tables | visuals | emphasis | media
|
||||
template: blocks/category/block-id.html
|
||||
height_cost: compact | medium | large | xlarge
|
||||
min_height_px: 80
|
||||
relation_types: [comparison, cause_effect] # 빈 배열 = 모든 relation에 가능
|
||||
min_items: 2 # 19/38 블록에 존재
|
||||
max_items: 5 # 19/38 블록에 존재
|
||||
visual: "시각적 설명"
|
||||
when: "사용 적합 상황"
|
||||
not_for: "부적합 상황"
|
||||
purpose_fit: [핵심전달, 문제제기]
|
||||
zone: full-width-only # 4/38 블록에 존재 (선택)
|
||||
slots:
|
||||
required: [title, description]
|
||||
optional: [icon, source]
|
||||
# --- Phase T 필수 추가 ---
|
||||
schema: # ★ 현재 19/38 → 38/38 완성 필요
|
||||
title: {max_lines: 1, font_size: 16, ref_chars: {body: 30, sidebar: 20}}
|
||||
description: {max_lines: 3, font_size: 14, ref_chars: {body: 150, sidebar: 90}}
|
||||
visual_diff: | # ★ 신규 (T-4), 유사 블록 20개에 추가
|
||||
유사 블록과의 차이: ...
|
||||
variants: # 현재 4/38 → 필요 시 확장
|
||||
- id: default
|
||||
- id: compact
|
||||
```
|
||||
|
||||
### 4.3 expression_hint → 블록 매핑 (키워드 포함 매칭)
|
||||
|
||||
| 시각적 유형 | 매칭 키워드 | 매핑 블록 |
|
||||
|---|---|---|
|
||||
| 인과 | "인과", "현상->결과", "야기" | callout-warning, dark-bullet-list |
|
||||
| 나열_병렬 | "독립적 나열", "병렬", "개별 증거" | dark-bullet-list, card-icon-desc |
|
||||
| 나열_정의 | "독립적 정의", "참조용", "용어" | card-numbered |
|
||||
| 포함_계층 | "상위-하위", "포함 관계", "계층적" | venn-diagram, keyword-circle-row |
|
||||
| 강조_결론 | "핵심 메시지 강조", "임팩트" | banner-gradient, quote-big-mark |
|
||||
| 비교 | "대등 비교", "좌우 대조", "vs" | compare-2col-split, compare-3col-badge |
|
||||
| 순서 | "시간 순서", "단계별", "A->B->C" | flow-arrow-horizontal, process-horizontal |
|
||||
|
||||
### 4.4 Phase T에서 필요한 개선
|
||||
|
||||
| 항목 | 현재 | 목표 | 우선순위 |
|
||||
|------|------|------|----------|
|
||||
| schema 완성 | 19/38 | 38/38 | 높음 (Stage 1.5b 필수) |
|
||||
| visual_diff 추가 | 0/38 | 20/38 (유사 블록 그룹) | 중간 (T-3 프롬프트 품질) |
|
||||
| ref_chars ↔ 컨테이너 폭 정합성 검증 | 없음 | 시작 시 자동 검증 | 높음 |
|
||||
| 블록 독립 렌더링 스크린샷 | 없음 | 38개 PNG | 중간 (visual_diff 근거) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 흐름 요약
|
||||
|
||||
```
|
||||
[원본 MDX]
|
||||
│
|
||||
▼
|
||||
Stage 0 ─── 코드 ──→ context.normalized
|
||||
│ (python-frontmatter + markdown-it-py)
|
||||
│ {clean_text, title, images[], popups[], tables[], sections[]}
|
||||
▼
|
||||
Stage 1A ── Kei ──→ context.analysis
|
||||
│ {topics[], page_structure, core_message}
|
||||
│ ✓ Pydantic 검증 + 원본 대조
|
||||
▼
|
||||
Stage 1B ── Kei ──→ context.topics[].relation_type, .expression_hint, .source_data
|
||||
│ ✓ 모순 탐지 + 원본 패턴 대조
|
||||
▼
|
||||
Stage 1.5a ─ 코드 ──→ context.font_hierarchy, .container_ratio, .containers[].text_budget
|
||||
│ (폰트 위계 확정 → 비율 역산 → 텍스트 예산)
|
||||
▼
|
||||
Stage 1.7 ── 코드 ──→ context.references[]
|
||||
│ (relation_type 1차 + expression_hint 2차 → 블록+변형)
|
||||
│ (Jinja 치환 → 디자인 레퍼런스 HTML)
|
||||
▼
|
||||
Stage 1.5b ─ 코드 ──→ context.containers[].design_budget
|
||||
│ (블록 schema 기반 디자인 요소 크기 역산)
|
||||
▼
|
||||
Stage 2 ── Sonnet ──→ context.generated_html
|
||||
│ (디자인 레퍼런스 + 구체적 수치 + 원본 텍스트)
|
||||
│ ✓ L1+L2+L3 코드 검증
|
||||
▼
|
||||
Stage 3 ── 코드 ──→ context.rendered_html
|
||||
│ (동적 비율 grid 조립)
|
||||
│ ✓ L4 Selenium 실측
|
||||
▼
|
||||
Stage 4 ── Opus ──→ context.quality_score
|
||||
│ (스크린샷 기반 시각 평가, 30점 미만 차단)
|
||||
▼
|
||||
Stage 5 ── 코드 ──→ final.html
|
||||
(이미지 base64 변환, details JS 삽입)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 에러 핸들링
|
||||
|
||||
| 실패 지점 | 등급 | 복구 전략 |
|
||||
|---|---|---|
|
||||
| Stage 0 검증 실패 | FATAL | 원본 MDX 자체 문제 — 사용자에게 에러 반환 |
|
||||
| Stage 1A Pydantic 실패 | RETRYABLE | Self-Refine 피드백 포함 Kei 재요청 (최대 2회) |
|
||||
| Stage 1B 모순 탐지 | RETRYABLE | 모순 topic만 피드백 포함 재요청 (최대 2회) |
|
||||
| Stage 1.5a 수치 이상 | FATAL | 결정론적이므로 재시도 무의미 — 입력 점검 필요 |
|
||||
| Stage 1.7 적합 블록 없음 | ADJUSTABLE | 카테고리별 fallback 블록 선택 + 경고 기록 |
|
||||
| Stage 1.5b 음수 예산 | ADJUSTABLE | 폰트 1px 축소 or 블록 재선택 |
|
||||
| L1~L3 실패 | RETRYABLE | Self-Refine 프롬프트로 Stage 2 재실행 (최대 2회) |
|
||||
| L4 overflow | RETRYABLE | 실패 영역만 Stage 2로 + 구체적 px 피드백 (최대 2회) |
|
||||
| L5 30점 미만 | FATAL | 출력 차단 + 에러 기록 |
|
||||
| L5 30~60점 | RETRYABLE | Opus 피드백으로 Stage 2 재실행 (최대 1회) |
|
||||
| 타임아웃 (300초) | FATAL | 최선 결과 반환 + 경고 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 기술 스택 (Phase T)
|
||||
|
||||
| 구성 요소 | 기술 | 비고 |
|
||||
|---|---|---|
|
||||
| 웹 서버 | **FastAPI** + uvicorn | 포트 8001, SSE 스트리밍 |
|
||||
| 파이프라인 런타임 | Python (async) | Pydantic BaseModel (PipelineContext) |
|
||||
| MDX 파싱 | python-frontmatter + markdown-it-py + mdit-py-plugins | ~1MB 추가 |
|
||||
| 콘텐츠 판단 | Kei Persona API (localhost:8000, Opus, SSE) | httpx streaming |
|
||||
| HTML 생성 | Claude Sonnet 4 (Anthropic API) | 영역별 개별 호출 |
|
||||
| 한국어 키워드 추출 | kiwipiepy | L1 검증 + Stage 1A/1B 원본 대조 |
|
||||
| 관계 표현 패턴 | regex 7종 (relation_type별) | Stage 1B 검증 보조 |
|
||||
| 시각 품질 평가 | Opus Vision (Anthropic API) | L5, 스크린샷 기반 |
|
||||
| 실측 렌더링 | Selenium headless Chrome | L4, 1280×920 viewport |
|
||||
| 블록 카탈로그 | catalog.yaml (38개 블록) | schema 38/38 완성 필요 |
|
||||
| 템플릿 엔진 | Jinja2 | 블록 HTML 렌더링 |
|
||||
| 디자인 토큰 | tokens.css + base.css | Pretendard Variable |
|
||||
| HTTP 클라이언트 | httpx | Kei API SSE 통신 |
|
||||
| 스냅샷 저장 | JSON (Pydantic model_dump_json) | `data/runs/{run_id}/` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 마이그레이션 맵 (현재 코드 → Phase T)
|
||||
|
||||
### 신규 생성
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/pipeline_context.py` | PipelineContext Pydantic 모델 |
|
||||
| `src/mdx_normalizer.py` | Stage 0 MDX 파서 (4-Layer) |
|
||||
| `src/validators.py` | Stage 1A/1B Pydantic 스키마 + 모순 탐지 + 원본 대조 |
|
||||
| `src/block_reference.py` | Stage 1.7 블록 선택 + 디자인 레퍼런스 생성 |
|
||||
| `scripts/capture_block_screenshots.py` | 38개 블록 독립 렌더링 스크린샷 |
|
||||
|
||||
### 수정
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `src/pipeline.py` | run_stage 패턴 + 11-Stage 러너 + PipelineContext 기반 |
|
||||
| `src/html_generator.py` | 프롬프트에 context 기반 수치+레퍼런스 주입, 하드코딩 CSS 제거 |
|
||||
| `src/space_allocator.py` | 폰트 위계 + 동적 비율 역산 + design_budget 계산 |
|
||||
| `src/content_verifier.py` | L1에 kiwipiepy 추가, L3에 폰트 위계 검증 추가 |
|
||||
| `templates/catalog.yaml` | schema 19개 추가 완성 + visual_diff 20개 추가 |
|
||||
|
||||
### 미사용 (Phase S에서 이미 미사용, 삭제 후보)
|
||||
|
||||
| 파일/함수 | 이유 |
|
||||
|-----------|------|
|
||||
| `src/block_selector.py` | Phase R'에서 제거됨. Stage 1.7의 block_reference.py로 대체 |
|
||||
| `src/content_editor.py` | Phase S에서 별도 텍스트 편집 Stage 제거됨 |
|
||||
| `src/design_director.py` | Step B 프롬프트 제거됨. 프리셋 선택 로직만 space_allocator로 이동 |
|
||||
|
||||
---
|
||||
|
||||
## 9. Phase T 범위 vs Phase ZZ 예고
|
||||
|
||||
### Phase T (현재) — 폰트 위계 + 파이프라인 안정화
|
||||
|
||||
- 11-Stage 파이프라인 전체 구현 (PipelineContext + run_stage 패턴)
|
||||
- Stage 0: MDX 4-Layer 파서
|
||||
- Stage 1A/1B: Pydantic 검증 + 모순 탐지 + 원본 대조
|
||||
- Stage 1.5a: **폰트 위계 확정 + 동적 비율 역산** (Phase T 핵심)
|
||||
- Stage 1.7: 블록 참고 선택 (키워드 매칭 + fallback)
|
||||
- Stage 1.5b: 블록 schema 기반 디자인 예산 역산
|
||||
- catalog.yaml schema 38/38 완성 + visual_diff 20개
|
||||
- 분산 검증 (L1~L5) + Self-Refine 재시도
|
||||
- **합격 기준:** 어떤 MDX에서도 폰트 위계 유지, overflow 없음, 품질 60점+
|
||||
|
||||
### Phase ZZ (최종 전환) — 판단 체계 전환 + 워크플로우
|
||||
|
||||
- Kei Persona API → Opus 직접 + Gitea 위키 판단 기준 전환 (비교 평가 후 결정)
|
||||
- 청크별 보존율 차등화 (verbatim / summary / core_80)
|
||||
- Stage 1.5a → 1A/1B 역방향 협상 루프 (weight 재조정 요청)
|
||||
- Gitea 이슈 기반 워크플로우 전환
|
||||
- Starlight `.astro` 임베딩
|
||||
- 반응형 전환 여부 판단
|
||||
@@ -1,12 +1,525 @@
|
||||
# Phase T: 폰트 위계 + 동적 컨테이너 계산 + 디자인 선택
|
||||
# Phase T: 파이프라인 기반 정비 + 폰트 위계 + 동적 컨테이너 + 디자인 선택
|
||||
|
||||
> 작성일: 2026-03-31
|
||||
> 상태: 설계
|
||||
> 근거: Phase S 결과물에서 (1) 폰트 크기가 중요도와 역전, (2) 블록 디자인 선택이 전혀 없음, (3) 컨테이너 비율이 고정. 이 3가지를 해결.
|
||||
> 작성일: 2026-03-31 (초안) → 2026-04-01 (T-0~T-4 확정)
|
||||
> 상태: 설계 확정, 실행 대기
|
||||
> 근거: Phase S 결과물에서 (1) 누적 컨텍스트/검증 기반 구조 부재, (2) MDX 표준화 부재, (3) 단계별 검증 부재, (4) 블록 디자인 미활용, (5) 블록 간 설명 미구분, (6) 폰트 위계 역전, (7) 컨테이너 비율 고정. 이 7가지를 해결.
|
||||
> 선행: Phase S (Claude HTML 직접 생성 + 독립 검증 시스템)
|
||||
|
||||
---
|
||||
|
||||
## Phase T 구조 (T-0 ~ T-4)
|
||||
|
||||
```
|
||||
T-0: 파이프라인 기반 구조 (PipelineContext + run_stage 패턴)
|
||||
│ 모든 T-1~T-4의 기반. 이것 없이는 검증도 프롬프트 주입도 안 됨.
|
||||
│
|
||||
├── T-1: MDX 표준화 (Stage 0 추가)
|
||||
│ 깨끗한 입력이 context에 들어가야 이후 모든 Stage가 정확해짐
|
||||
│
|
||||
├── T-2: 각 Stage에 검증 추가
|
||||
│ T-0의 validate() 패턴을 각 Stage 내부에 구현
|
||||
│
|
||||
├── T-3: 블록 참고 디자인 + 프롬프트 정비
|
||||
│ 참고 블록 선택 + 프롬프트에 context 주입 + 하드코딩 CSS 제거
|
||||
│
|
||||
└── T-4: 블록 설명 catalog.yaml 보완
|
||||
유사 블록 간 visual_diff 추가 (코드 변경 없음, yaml만)
|
||||
```
|
||||
|
||||
### 실행 순서
|
||||
- **T-0 먼저** (기반 구조)
|
||||
- T-1, T-4는 독립 실행 가능
|
||||
- T-2는 T-0 이후
|
||||
- T-3는 T-0, T-4 이후 (블록 설명이 보완된 후 프롬프트에 넣어야 정확)
|
||||
|
||||
### 의존 관계
|
||||
```
|
||||
T-0 ──→ T-1 (context에 clean_text 저장)
|
||||
T-0 ──→ T-2 (validate 패턴 구현)
|
||||
T-0 ──→ T-3 (context에서 프롬프트 조립)
|
||||
T-4 ──→ T-3 (visual_diff가 프롬프트에 포함)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## T-0. 파이프라인 기반 구조 (신규, 최우선)
|
||||
|
||||
### 목적
|
||||
모든 Stage가 하나의 누적 컨텍스트 객체를 공유하고, 각 Stage가 transform → validate → update 패턴을 따르도록 기반 구조를 만든다.
|
||||
|
||||
### 1. PipelineContext 객체
|
||||
|
||||
각 Stage가 개별 JSON을 읽고 쓰는 대신, 하나의 context 객체가 파이프라인을 따라가며 점진적으로 풍부해진다.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PipelineContext:
|
||||
# Stage 0 (T-1)
|
||||
raw_content: str = ""
|
||||
clean_text: str = ""
|
||||
title: str = ""
|
||||
images: list[dict] = field(default_factory=list)
|
||||
popups: list[dict] = field(default_factory=list)
|
||||
tables: list[dict] = field(default_factory=list)
|
||||
|
||||
# Stage 1A
|
||||
topics: list[dict] = field(default_factory=list)
|
||||
page_structure: dict = field(default_factory=dict)
|
||||
core_message: str = ""
|
||||
|
||||
# Stage 1B
|
||||
# → topics[].relation_type, expression_hint, source_data 병합
|
||||
|
||||
# Stage 1.5
|
||||
preset: dict = field(default_factory=dict)
|
||||
containers: dict = field(default_factory=dict) # 역할별 ContainerSpec
|
||||
|
||||
# Stage 1.7 (T-3)
|
||||
references: dict = field(default_factory=dict) # 역할별 참고 블록 HTML
|
||||
|
||||
# Stage 2
|
||||
generated_html: dict = field(default_factory=dict) # body/sidebar/footer
|
||||
|
||||
# 메타
|
||||
run_id: str = ""
|
||||
run_dir: Path = None
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
def save_snapshot(self, stage_name: str):
|
||||
"""디버깅용 스냅샷 저장. 기존 step*.json 역할을 대체."""
|
||||
if self.run_dir:
|
||||
path = self.run_dir / f"{stage_name}_context.json"
|
||||
path.write_text(
|
||||
json.dumps(asdict(self), ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
```
|
||||
|
||||
### 2. run_stage() 패턴
|
||||
|
||||
```python
|
||||
async def run_stage(stage_fn, context, stage_name, max_retries=1):
|
||||
"""모든 Stage의 공통 실행 패턴."""
|
||||
for attempt in range(max_retries + 1):
|
||||
result = await stage_fn(context) # 변환
|
||||
errors = result.get("_errors", []) # 검증 (각 Stage가 자체 수행)
|
||||
if not errors:
|
||||
context.update(result) # 누적 컨텍스트에 병합
|
||||
context.save_snapshot(stage_name) # 스냅샷 저장
|
||||
return context
|
||||
if attempt < max_retries:
|
||||
context.errors.append(f"[{stage_name}] 재시도 {attempt+1}: {errors}")
|
||||
raise StageFailure(stage_name, errors)
|
||||
```
|
||||
|
||||
### 3. 파이프라인 러너
|
||||
|
||||
```python
|
||||
async def generate_slide(content: str):
|
||||
context = PipelineContext(raw_content=content, run_id=str(int(time.time()*1000)))
|
||||
|
||||
stages = [
|
||||
("stage_0_normalize", normalize_mdx_stage, 0), # T-1: 코드, 재시도 없음
|
||||
("stage_1a_classify", classify_content_stage, 2), # Kei API, 최대 2회
|
||||
("stage_1b_refine", refine_concepts_stage, 2), # Kei API, 최대 2회
|
||||
("stage_1_5_containers", calculate_containers_stage, 0), # 코드
|
||||
("stage_1_7_references", select_references_stage, 0), # T-3: 코드
|
||||
("stage_2_generate", generate_html_stage, 2), # Sonnet, 최대 2회
|
||||
("stage_3_render", render_stage, 0), # 코드
|
||||
("stage_4_quality", quality_gate_stage, 0), # Selenium + Opus
|
||||
]
|
||||
|
||||
for stage_name, stage_fn, max_retries in stages:
|
||||
context = await run_stage(stage_fn, context, stage_name, max_retries)
|
||||
yield {"event": "progress", "data": stage_name}
|
||||
|
||||
yield {"event": "result", "data": context.generated_html}
|
||||
```
|
||||
|
||||
### 4. context → 프롬프트 주입
|
||||
|
||||
각 AI Stage가 프롬프트를 구성할 때 context에서 필요한 정보를 꺼낸다:
|
||||
|
||||
```python
|
||||
# Stage 1A — context.clean_text만 필요
|
||||
prompt = KEI_PROMPT + context.clean_text
|
||||
|
||||
# Stage 1B — context.clean_text + context.topics
|
||||
prompt = KEI_PROMPT_B.format(
|
||||
content=context.clean_text,
|
||||
topics=format_topics(context.topics),
|
||||
)
|
||||
|
||||
# Stage 2 — 이전 모든 단계의 누적 결과 활용
|
||||
prompt = CORE_PROMPT.format(
|
||||
height=context.containers["본심"].height_px, # 1.5에서
|
||||
content_block=context.clean_text, # Stage 0에서
|
||||
core_message=context.core_message, # 1A에서
|
||||
expression_hint=topic.expression_hint, # 1B에서
|
||||
reference_design=context.references["본심"].html, # 1.7에서 (T-3)
|
||||
reference_diff=context.references["본심"].visual_diff, # T-4에서
|
||||
)
|
||||
```
|
||||
|
||||
### 수정 파일
|
||||
- `src/pipeline_context.py` (신규) — PipelineContext 클래스
|
||||
- `src/pipeline.py` — run_stage 패턴 + 러너 재구성
|
||||
- 각 Stage 함수가 context를 받고 리턴하도록 시그니처 변경
|
||||
|
||||
---
|
||||
|
||||
## T-1. Stage 0 — MDX 표준화 (신규)
|
||||
|
||||
### 목적
|
||||
파이프라인 진입 전에 원본 MDX를 깨끗한 구조화 텍스트로 변환. 이후 모든 단계(1A, 1B, 2)에서 동일한 깨끗한 입력 사용.
|
||||
|
||||
### 현재 상태
|
||||
- `html_generator.py`에 `normalize_mdx()` 함수가 이미 존재 (Line 401)
|
||||
- **Stage 2에서만 호출** — Stage 1A/1B는 원본 MDX(JSX, frontmatter 포함)를 그대로 받음
|
||||
- Kei에게 `style={{cursor: 'pointer'}}` 같은 JSX 코드가 전달됨
|
||||
|
||||
### 변경 사항
|
||||
|
||||
**위치 이동:** `normalize_mdx()`를 `pipeline.py` 최상단에서 1회 호출, 모든 후속 단계에 전달
|
||||
|
||||
**추가 처리:**
|
||||
- title 추출: frontmatter에서 title → 별도 보관
|
||||
- 이미지 추출: `` → `images[]` 리스트 분리, 본문에 `[이미지: alt]` 마커
|
||||
- 팝업 마킹: `<details><summary>제목</summary>내용</details>` → `[팝업: 제목]\n내용\n[/팝업]`
|
||||
- 핵심요약 마킹: `:::note[제목]` → `[핵심요약]\n내용\n[/핵심요약]`
|
||||
- 표 보존: markdown table 구조 유지
|
||||
|
||||
**출력:**
|
||||
```python
|
||||
{
|
||||
"clean_text": str, # 정규화된 순수 텍스트
|
||||
"title": str, # frontmatter 제목
|
||||
"images": [{"alt": str, "path": str}],
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"header": str, "rows": list}]
|
||||
}
|
||||
```
|
||||
|
||||
**수정 파일:** `src/pipeline.py`, `src/mdx_normalizer.py` (신규), `src/html_generator.py`
|
||||
|
||||
---
|
||||
|
||||
## T-2. 각 Stage에 검증 추가 (신규)
|
||||
|
||||
### 목적
|
||||
각 Stage 내부에서 **형식 검증 + 내용 검증**을 수행하고, 실패 시 **구체적 피드백으로 회귀**한다.
|
||||
단순 숫자 체크(하드코딩)가 아니라, "이 결과가 원본 콘텐츠에 대해 적절한가?"를 판단.
|
||||
|
||||
### 현재 상태
|
||||
- Stage 2 이후에만 검증 (5층 + 품질 게이트)
|
||||
- Stage 1A, 1B, 1.5에는 **검증 없음**
|
||||
|
||||
### 검증 4계층 구조 (모든 Stage 공통)
|
||||
|
||||
각 Stage의 validate()는 4가지를 순서대로 수행:
|
||||
|
||||
```
|
||||
1. 형식 검증 (코드) — 값 범위, 유효 enum, null 체크 → 즉시 판단
|
||||
2. 내용 검증 (코드+대조) — 결과가 원본 콘텐츠에 대해 적절한가 → 원본과 대조
|
||||
3. 실패 피드백 생성 — 무엇이 왜 잘못됐는지 구체적 문장으로 → Kei/Sonnet에게 전달
|
||||
4. 회귀 실행 — 피드백을 포함하여 해당 Stage만 재실행
|
||||
```
|
||||
|
||||
### Stage별 검증 상세
|
||||
|
||||
---
|
||||
|
||||
#### Stage 0 검증 (MDX 표준화)
|
||||
|
||||
**형식 검증 (코드):**
|
||||
- clean_text 비어있지 않음
|
||||
- `##` 섹션 최소 1개
|
||||
- 원본 대비 30% 이상 텍스트 보존 (과도한 제거 방지)
|
||||
|
||||
**내용 검증 (코드, 원본 대조):**
|
||||
- 원본의 `##` 섹션 수와 정규화 후 섹션 수 비교 — 섹션이 누락되었는지
|
||||
- 원본의 이미지 참조 수와 추출된 images[] 수 비교 — 이미지가 누락되었는지
|
||||
- 원본의 `<details>` 수와 추출된 popups[] 수 비교
|
||||
|
||||
**실패 피드백:** "원본에 ## 섹션이 5개인데 정규화 후 3개. 누락: '용어 정의', '상호관계'"
|
||||
**회귀:** 코드 Stage → 자동 조정 불가 시 에러 반환 (원본 MDX 자체에 문제)
|
||||
**비용:** 0
|
||||
|
||||
---
|
||||
|
||||
#### Stage 1A 검증 (꼭지 추출)
|
||||
|
||||
**형식 검증 (Pydantic):**
|
||||
- topics 리스트 비어있지 않음
|
||||
- weight 합 0.9~1.1 범위
|
||||
- purpose/role/layer 유효 enum
|
||||
- 본심 존재, 본심 weight ≥ 0.3
|
||||
- page_structure의 topic_ids가 실제 topics에 존재
|
||||
|
||||
**내용 검증 (코드, 원본 대조):**
|
||||
- 원본의 `##` 섹션 수 vs topic 수 — 차이가 크면 분류가 잘못됐을 가능성
|
||||
- 예: 원본 `##` 5개인데 topic 2개 → "3개 섹션이 topic에 매핑 안 됨"
|
||||
- 각 topic의 summary 키워드가 원본 해당 섹션에 실제 존재하는지
|
||||
- 예: topic 3 summary="DX와 BIM의 비교"인데 원본에 "비교"라는 단어가 없으면 → Kei가 해석을 덧붙인 것
|
||||
- 본심 topic의 source_hint가 가리키는 원본 위치에 실제 핵심 콘텐츠가 있는지
|
||||
|
||||
**실패 피드백 예시:**
|
||||
```
|
||||
"topic 3의 summary에 '비교'가 있지만 원본 해당 섹션에는 '비교'가 없고
|
||||
'상호관계'와 '포함'이라는 표현이 있습니다.
|
||||
topic 3의 purpose가 '핵심전달'인데 실제 내용은 '포함 관계 설명'에 가깝습니다.
|
||||
summary를 원본에 맞게 재작성하고, 비중을 재판단해주세요."
|
||||
```
|
||||
|
||||
**회귀:** 피드백 + 원본의 해당 섹션 텍스트를 함께 Kei에게 재요청 (최대 2회)
|
||||
**비용:** 0 (코드 대조)
|
||||
|
||||
---
|
||||
|
||||
#### Stage 1B 검증 (컨셉 구체화)
|
||||
|
||||
**형식 검증 (Pydantic):**
|
||||
- relation_type 유효 enum (sequence|inclusion|comparison|hierarchy|definition|cause_effect|none)
|
||||
- expression_hint 비어있지 않음
|
||||
- source_data 비어있지 않음
|
||||
|
||||
**내용 검증 (코드, 원본 대조 + 결정 테이블):**
|
||||
|
||||
*모순 검사 (결정 테이블):*
|
||||
| purpose | 모순인 relation_type | 이유 |
|
||||
|---------|---------------------|------|
|
||||
| 결론강조 | comparison, sequence | 결론은 비교나 순서가 아님 |
|
||||
| 문제제기 | sequence, definition | 문제제기는 순서 나열이나 정의가 아님 |
|
||||
| 용어정의 | hierarchy, cause_effect | 정의 나열은 상하위나 인과가 아님 |
|
||||
| 구조시각화 | none | 시각화할 관계가 없으면 구조시각화가 아님 |
|
||||
|
||||
*source_data 원본 대조:*
|
||||
- source_data에서 명사/핵심어 추출
|
||||
- 해당 키워드가 원본 clean_text에 실제 존재하는지 확인
|
||||
- 예: source_data에 "Gartner 2023 보고서"가 있는데 원본에 "Gartner"가 없으면 → Kei가 없는 출처를 만들어낸 것
|
||||
|
||||
*relation_type과 원본 구조 대조:*
|
||||
- relation_type=comparison인데 원본에 대조/비교 구조(vs, 반면, 차이점)가 없으면 → 의심
|
||||
- relation_type=sequence인데 원본에 순서 표현(→, 이후, 다음)이 없으면 → 의심
|
||||
|
||||
**실패 피드백 예시:**
|
||||
```
|
||||
"topic 1의 relation_type이 'comparison'인데, 원본에 비교 구조(vs, 반면, 차이점)가 없습니다.
|
||||
원본 텍스트: 'DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음'
|
||||
이 문장은 '혼용 현상 기술'이지 'A vs B 비교'가 아닙니다.
|
||||
cause_effect(혼용 → 오인) 또는 definition(용어 정립 필요)이 더 적절할 수 있습니다.
|
||||
재판단해주세요."
|
||||
```
|
||||
|
||||
**회귀:** 모순/불일치 topic만 피드백과 함께 Kei에게 재요청 (최대 2회)
|
||||
**비용:** 0 (코드 대조 + 결정 테이블)
|
||||
|
||||
---
|
||||
|
||||
#### Stage 1.5 검증 (컨테이너 스펙)
|
||||
|
||||
**형식 검증 (코드):**
|
||||
- 모든 height_px > 0
|
||||
- body zone 총 할당 ≤ body zone 예산 (490px)
|
||||
- sidebar, footer도 예산 이내
|
||||
|
||||
**내용 검증 (코드, 실현 가능성):**
|
||||
- topic당 height ≥ 50px — 50px 미만이면 어떤 블록/디자인도 콘텐츠를 담을 수 없음
|
||||
- topic의 source_data 텍스트 길이 vs 할당된 높이 비교:
|
||||
- 텍스트 500자인데 높이 58px → 12px 폰트로 약 4줄(120자)만 수용 → "텍스트의 24%만 표시 가능" 경고
|
||||
- 역할별 폰트 위계 충돌:
|
||||
- 배경 컨테이너가 본심보다 크면 → 폰트 역전 위험
|
||||
|
||||
**실패 피드백 예시:**
|
||||
```
|
||||
"배경 역할에 topic 2개가 배정되어 topic당 58px.
|
||||
topic 1의 source_data는 150자인데, 58px에서 12px 폰트로 약 60자만 수용 가능 (40%).
|
||||
최소 80px/topic이 필요합니다.
|
||||
배경 높이를 160px(80px×2)로 올리고, 본심에서 43px를 차감합니다.
|
||||
차감 후 본심 309px — compare-2col-split(308px)이 들어갈 수 있는지 확인 필요."
|
||||
```
|
||||
|
||||
**회귀:** 코드 Stage → 자동 조정:
|
||||
1. 최소 높이(80px/topic) 보장
|
||||
2. 부족분을 가장 큰 컨테이너에서 차감
|
||||
3. 차감 후 해당 컨테이너에 할당된 블록이 여전히 들어가는지 검증
|
||||
4. 안 들어가면 → weight 재조정이 필요하다는 경고를 context에 기록
|
||||
**비용:** 0
|
||||
|
||||
---
|
||||
|
||||
#### Stage 2+ 검증: 기존 유지
|
||||
- Stage 2: 5층 검증 (텍스트 보존, 금지 콘텐츠, 구조, overflow, 시각 품질)
|
||||
- Stage 4: 품질 게이트 (30점 미만 차단)
|
||||
- 기존 content_verifier.py + slide_measurer.py 그대로
|
||||
|
||||
---
|
||||
|
||||
### 에러 분류 체계
|
||||
|
||||
모든 검증 에러는 3등급으로 분류:
|
||||
|
||||
| 등급 | 의미 | 대응 |
|
||||
|------|------|------|
|
||||
| **FATAL** | 복구 불가 (원본 자체 문제, JSON 파싱 실패) | 파이프라인 중단, 사용자에게 에러 반환 |
|
||||
| **RETRYABLE** | AI 재시도로 해결 가능 (분류 오류, 모순, 누락) | 구체적 피드백 포함 재요청, 최대 2회 |
|
||||
| **ADJUSTABLE** | 코드로 자동 조정 가능 (높이 부족, 비율 초과) | 자동 조정 후 경고 기록 |
|
||||
|
||||
### 수정 파일
|
||||
- `src/validators.py` (신규) — Pydantic 스키마 + 모순 결정 테이블 + 원본 대조 함수
|
||||
- `src/pipeline.py` — 각 Stage에 validate() 호출 삽입 (T-0의 run_stage 패턴)
|
||||
|
||||
---
|
||||
|
||||
## T-3. 디자인 블록 참고 프로세스 (신규)
|
||||
|
||||
### 목적
|
||||
AI가 HTML 생성 시 콘텐츠의 relation_type에 맞는 블록 디자인을 참고 자료로 제공. 다양하고 의미에 맞는 결과물 생성.
|
||||
|
||||
### 현재 상태
|
||||
- 역할별(배경/본심/첨부/결론) 고정 프롬프트 사용
|
||||
- relation_type 무관하게 같은 CSS 구조 적용
|
||||
- 38개 블록 디자인 미활용
|
||||
|
||||
### relation_type → 참고 블록 매핑 (코드, 결정론적)
|
||||
|
||||
| relation_type | 참고 블록 (우선순위순) | 근거 |
|
||||
|---------------|----------------------|------|
|
||||
| hierarchy | venn-diagram, keyword-circle-row | 포함/상하위 → 원형 |
|
||||
| inclusion | venn-diagram, circle-gradient | 포함/융합 → 겹침 원형 |
|
||||
| comparison | compare-2col-split, compare-3col-badge | 대등 비교 → 좌우 표 |
|
||||
| cause_effect | callout-warning, dark-bullet-list, topic-left-right | 인과 → 경고/강조 |
|
||||
| sequence | flow-arrow-horizontal, process-horizontal | 순서 → 화살표 |
|
||||
| definition | card-numbered, dark-bullet-list | 정의 나열 → 카드/불릿 |
|
||||
| none | banner-gradient, topic-left-right, quote-big-mark | 텍스트/강조 |
|
||||
|
||||
### 적용 방식
|
||||
|
||||
**현재:**
|
||||
```python
|
||||
prompt = CORE_PROMPT.format(height=h, content_block=text, ...)
|
||||
# relation_type 무관하게 항상 같은 CSS
|
||||
```
|
||||
|
||||
**변경:**
|
||||
```python
|
||||
# 1. relation_type에서 참고 블록 결정 (코드)
|
||||
ref_blocks = RELATION_BLOCK_MAP[relation_type]
|
||||
|
||||
# 2. 블록 HTML 로드
|
||||
ref_html = load_block_html(ref_blocks[0])
|
||||
|
||||
# 3. 프롬프트에 참고 디자인 첨부
|
||||
prompt = CORE_PROMPT.format(
|
||||
height=h, content_block=text,
|
||||
reference_design=ref_html, # ← 신규
|
||||
reference_block_name=ref_blocks[0],
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**프롬프트 추가 섹션:**
|
||||
```
|
||||
## 참고 디자인 ({reference_block_name})
|
||||
이 콘텐츠의 관계 성격({relation_type})에 적합한 디자인 참고 자료이다.
|
||||
색상, 레이아웃 패턴, 시각적 구조를 참고하되 콘텐츠에 맞게 커스터마이징하라.
|
||||
그대로 복사하지 마라. 참고만 하라.
|
||||
|
||||
{reference_design}
|
||||
```
|
||||
|
||||
### 영역별 적용
|
||||
|
||||
| 영역 | 프롬프트 | 참고 디자인 |
|
||||
|------|---------|------------|
|
||||
| 배경 | BG_PROMPT | relation_type에 따라 동적 |
|
||||
| 본심 | CORE_PROMPT | relation_type에 따라 동적 (가장 중요) |
|
||||
| 첨부 | SIDEBAR_PROMPT | 항상 card-numbered (용어정의) |
|
||||
| 결론 | FOOTER_PROMPT | 항상 banner-gradient |
|
||||
|
||||
### 기존 방식 비교
|
||||
|
||||
| 방식 | 블록 역할 | 한계 |
|
||||
|------|----------|------|
|
||||
| Phase P~R | 블록 선택 → 슬롯 채우기 | 경직. 슬롯에 안 맞으면 왜곡 |
|
||||
| Phase S | 블록 무시, 고정 프롬프트 | 단조. 모든 콘텐츠 같은 모양 |
|
||||
| Phase T | 블록을 참고 자료로 제공 | 유연. 다양한 디자인 + 콘텐츠 적응 |
|
||||
|
||||
**수정 파일:** `src/html_generator.py`, `src/block_reference.py` (신규), `templates/catalog.yaml`
|
||||
|
||||
---
|
||||
|
||||
## T-4. 블록 설명(catalog.yaml) 보완 (신규)
|
||||
|
||||
### 목적
|
||||
catalog.yaml의 블록 설명이 너무 범용적이어서 유사 블록 간 구분이 안 됨. 각 블록의 **고유한 시각적 특징**과 **유사 블록과의 차이점**을 명확히 기술하여, T-3에서 AI가 참고 디자인을 받았을 때 "왜 이 디자인을 참고하는지"를 이해할 수 있도록 한다.
|
||||
|
||||
### 현재 문제
|
||||
|
||||
유사 블록들의 설명이 구분되지 않음:
|
||||
```
|
||||
card-compare-3col: "3열 카드. 각 카드 상단 색상 헤더 + 불릿"
|
||||
card-image-3col: "3열 카드. 각 카드 상단에 이미지 + 밑줄 제목 + 불릿"
|
||||
card-icon-desc: "2~4열. 중앙 큰 이모지 아이콘 + 제목 + 설명"
|
||||
```
|
||||
→ "3열 카드 + 불릿"이 2개. 실제 렌더링은 완전히 다르지만 텍스트만으로는 구분 불가.
|
||||
|
||||
### 추가 필드: `visual_diff`
|
||||
|
||||
각 블록에 **유사 블록 그룹 내 차이점**을 명시하는 `visual_diff` 필드를 추가.
|
||||
|
||||
```yaml
|
||||
- id: card-compare-3col
|
||||
visual: "3열 카드. 각 카드 상단 색상 헤더(제목+서브) + 이미지 + 불릿 목록."
|
||||
visual_diff: |
|
||||
유사 블록과의 차이:
|
||||
- compare-2col-split: 2개 항목을 행별 비교 (좌/기준/우 구조)
|
||||
- compare-3col-badge: VS 배지가 있는 표 형식 비교
|
||||
- 이 블록(card-compare-3col): 3개 항목이 각각 독립 카드로 분리.
|
||||
카드마다 고유 색상 헤더(빨강/파랑/회색).
|
||||
행별 대조가 아닌 카드별 독립 설명.
|
||||
|
||||
적합: 3개 카테고리가 대등하게 병렬 나열, 각 항목에 이미지 포함
|
||||
부적합: "A의 X vs B의 X" 항목별 대조 → compare-2col-split
|
||||
```
|
||||
|
||||
### 보완 대상 그룹 (유사 블록이 2개 이상인 그룹)
|
||||
|
||||
| 그룹 | 유사 블록 | 구분 필요한 이유 |
|
||||
|------|----------|----------------|
|
||||
| **비교 계열** (5개) | compare-2col-split, compare-3col-badge, comparison-2col, compare-pill-pair, card-compare-3col | 전부 "비교"인데 구조가 다 다름 |
|
||||
| **카드 계열** (6개) | card-icon-desc, card-image-3col, card-dark-overlay, card-numbered, card-stat-number, card-tag-image | 전부 "카드"인데 용도가 다름 |
|
||||
| **텍스트 강조** (4개) | quote-big-mark, quote-question, callout-warning, callout-solution | 전부 "강조"인데 톤이 다름 |
|
||||
| **흐름/프로세스** (2개) | flow-arrow-horizontal, process-horizontal | 둘 다 "흐름"인데 정보량이 다름 |
|
||||
| **헤더** (3개) | topic-left-right, topic-center, topic-numbered | 전부 "제목"인데 레이아웃이 다름 |
|
||||
|
||||
→ **20개 블록**에 `visual_diff` 추가. 나머지 18개는 그룹 내 유일하므로 기존 `visual`만으로 충분.
|
||||
|
||||
### 작업 방식
|
||||
- 각 블록의 **실제 HTML을 렌더링**해서 시각적 차이를 확인한 후 기술 (추정으로 쓰지 않음)
|
||||
- `visual_diff`는 **다른 블록과의 상대적 차이**를 기술 (절대적 설명이 아님)
|
||||
- T-3의 `build_design_brief()`가 이 필드를 읽어서 프롬프트에 포함
|
||||
|
||||
### T-3과의 연계
|
||||
```python
|
||||
def build_design_brief(block: dict, catalog_entry: dict) -> str:
|
||||
brief = f"디자인 레퍼런스: {catalog_entry['name']}\n"
|
||||
brief += f"시각 패턴: {catalog_entry['visual']}\n"
|
||||
if catalog_entry.get('visual_diff'):
|
||||
brief += f"차별점: {catalog_entry['visual_diff']}\n" # ← T-4 추가
|
||||
brief += f"적합 상황: {catalog_entry['when']}\n"
|
||||
return brief
|
||||
```
|
||||
|
||||
### 수정 파일
|
||||
- `templates/catalog.yaml` — 20개 블록에 `visual_diff` 필드 추가
|
||||
|
||||
### T-3과 독립 실행 가능
|
||||
- catalog.yaml만 수정하는 작업이라 코드 변경 없음
|
||||
- T-3 이전에 해도 되고 이후에 해도 됨 (하지만 T-3 이전에 하면 AI가 처음부터 정확한 설명을 볼 수 있으므로 이전 권장)
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제
|
||||
|
||||
### 현재 상태
|
||||
@@ -288,3 +801,286 @@ def calculate_text_capacity(width_px: int, height_px: int, font_size: float, ...
|
||||
- [ ] "용어 정의" 라벨이 카드 좌측에 정렬되었는가?
|
||||
- [ ] 모든 영역에서 텍스트가 잘리지 않는가?
|
||||
- [ ] 다른 MDX(텍스트 양이 다른)에서도 위계가 유지되는가?
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# Phase T — 실행 계획
|
||||
|
||||
> 각 Step(T-0~T-4)별로 무엇을(큰 꼭지), 어떻게(세부 태스크) 수행하는지 정리.
|
||||
> 각 세부 태스크의 기술/도구는 별도 조사 후 확정. 여기서는 "무엇을 왜 하는지"에 집중.
|
||||
|
||||
---
|
||||
|
||||
## T-0. 파이프라인 기반 구조
|
||||
|
||||
### A. PipelineContext 객체 설계
|
||||
> 목적: 모든 Stage가 하나의 누적 객체를 통해 데이터를 주고받도록 한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | Stage간 전달 데이터 전수 조사 | 현재 step*.json의 모든 키를 수집하여 어느 Stage에서 생성/소비되는지 매핑 | |
|
||||
| b | 필드 분류 | 각 필드를 생성 Stage × 소비 Stage로 정리. 불필요한 중복 필드 제거 | |
|
||||
| c | PipelineContext 스키마 설계 | Pydantic BaseModel 또는 dataclass. 각 필드에 타입, 생성 시점, 소비 시점 명시 | Pydantic vs dataclass 직렬화 성능 |
|
||||
| d | save_snapshot() 구현 | 각 Stage 완료 시 context 전체를 JSON 직렬화하여 저장. 기존 step*.json 역할 대체 | JSON 직렬화에서 Path, dataclass 등 처리 |
|
||||
| e | 기존 디버깅 도구 호환성 | generate_run_report.py가 새 스냅샷을 읽을 수 있는지 확인. 필요 시 수정 | |
|
||||
|
||||
### B. run_stage() 패턴 구현
|
||||
> 목적: 모든 Stage의 공통 실행/검증/재시도 패턴을 정의한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 공통 패턴 설계 | transform(context) → validate(result) → update(context) → snapshot() | |
|
||||
| b | 에러 분류 체계 | FATAL(중단) / RETRYABLE(재시도) / ADJUSTABLE(자동조정) 3등급 | |
|
||||
| c | 재시도 로직 | RETRYABLE 시 피드백을 context에 기록 + 해당 Stage만 재실행. 최대 횟수는 Stage별 설정 | LLM retry 시 가장 효과적인 피드백 형식 |
|
||||
| d | FATAL 처리 | 파이프라인 중단 + 에러 상세를 context에 기록 + SSE error 이벤트 | |
|
||||
| e | ADJUSTABLE 처리 | 코드로 자동 조정 + 경고를 context에 기록. 다음 Stage는 조정된 값으로 진행 | |
|
||||
| f | Stage 시그니처 통일 | 모든 Stage: `async def stage_fn(context) → dict` (결과 + `_errors` 키) | |
|
||||
|
||||
### C. 기존 pipeline.py 리팩토링
|
||||
> 목적: 현재 generate_slide()를 T-0 패턴으로 전환한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 현재 Stage 호출 분석 | generate_slide() 내 변수 흐름 전수 분석. 어떤 변수가 어디서 어디로 | |
|
||||
| b | Stage 함수 래핑 | 각 기존 함수를 context 입출력으로 래핑. 내부 로직은 최소 변경 | |
|
||||
| c | SSE 스트리밍 통합 | run_stage() 완료마다 progress 이벤트 yield. 기존 SSE 구조 유지 | async generator + run_stage 조합 |
|
||||
| d | 기존 step*.json 코드 제거 | save_snapshot()으로 대체. 기존 저장 코드 삭제 | |
|
||||
|
||||
**산출물:** `src/pipeline_context.py` (신규), `src/pipeline.py` (수정)
|
||||
|
||||
---
|
||||
|
||||
## T-1. MDX 표준화
|
||||
|
||||
### A. 기존 normalize_mdx() 분석
|
||||
> 목적: 현재 처리 범위와 한계를 정확히 파악한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 현재 처리 항목 전수 조사 | html_generator.py:401~476의 모든 regex 패턴과 처리 결과 정리 | |
|
||||
| b | 미처리 패턴 수집 | 실제 MDX 파일을 넣어서 정규화 후 남는 JSX/HTML 잔여물 확인 | |
|
||||
| c | 실제 문제 사례 수집 | run 로그에서 Stage 1A/1B에 raw MDX가 전달되어 발생한 문제 확인 | |
|
||||
|
||||
### B. 4층 레이어 MDX 파서 구현
|
||||
> 목적: 각 도구가 가장 잘하는 것만 조합하여 정확한 파싱을 달성한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | Layer 1: YAML 추출 | `python-frontmatter`로 frontmatter → dict. title, sidebar 등 분리 | python-frontmatter API |
|
||||
| b | Layer 2: MDX 전용 패턴 | Regex로 Astro `:::directive`, `<details>/<summary>`, JSX `style={{}}`, import/export 처리 | |
|
||||
| c | 코드블록 보호 | ` ``` ` 영역을 placeholder로 보호 → JSX 제거 → 복원. **순서 필수** (안 하면 코드블록 내용 파괴) | |
|
||||
| d | Layer 3: AST 파싱 | `markdown-it-py`로 제목/표/이미지/리스트 구조 추출 | markdown-it-py table/image token 구조 |
|
||||
| e | Layer 4: 텍스트 정리 | 남은 HTML 태그 정리, 빈 줄 정리, 최종 clean_text 생성 | |
|
||||
|
||||
### C. 파이프라인 연결
|
||||
> 목적: Stage 0으로 파이프라인 맨 앞에 삽입한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | mdx_normalizer.py 생성 | normalize() → `{clean_text, title, images[], popups[], tables[]}` 반환 | |
|
||||
| b | pipeline.py 연결 | 최상단에서 호출 → context에 저장 | |
|
||||
| c | html_generator.py 정리 | 기존 normalize_mdx() 호출 제거 → context.clean_text 사용 | |
|
||||
| d | Stage 1A/1B 입력 변경 | raw content → context.clean_text로 전환 | |
|
||||
|
||||
**산출물:** `src/mdx_normalizer.py` (신규), `src/pipeline.py` (수정), `src/html_generator.py` (수정)
|
||||
|
||||
---
|
||||
|
||||
## T-2. 각 Stage에 검증 추가
|
||||
|
||||
### A. 검증 스키마 설계 (Pydantic)
|
||||
> 목적: 각 Stage 출력의 형식 검증을 타입 시스템으로 보장한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | Stage 1A 스키마 | AnalysisSchema: topics[], page_structure, weight 합산, 본심 존재 검증 | Pydantic v2 model_validator |
|
||||
| b | Stage 1B 스키마 | ConceptSchema: relation_type enum, expression_hint 필수, source_data 필수 | |
|
||||
| c | ContainerSpec 스키마 | height_px > 0, topic당 최소 높이 | |
|
||||
| d | 크로스 필드 검증 | model_validator에서 weight 합 0.9~1.1, 본심 weight ≥ 0.3 등 | |
|
||||
|
||||
### B. 내용 검증 로직 (원본 대조)
|
||||
> 목적: 단순 형식 체크를 넘어 "결과가 원본에 대해 적절한가"를 판단한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 키워드 추출 | 원본 clean_text에서 명사/핵심어 추출 | 한국어 키워드 추출: konlpy vs kiwi vs regex. 의존성 최소화 관점 |
|
||||
| b | 1A 대조: summary 검증 | topic summary의 키워드가 원본 해당 섹션에 존재하는지. Kei가 해석을 덧붙이지 않았는지 | |
|
||||
| c | 1B 대조: source_data 검증 | source_data 키워드가 원본에 존재하는지. 없는 출처를 만들어내지 않았는지 (할루시네이션 방지) | |
|
||||
| d | 1B 대조: relation_type 검증 | relation_type에 해당하는 언어적 패턴이 원본에 있는지. comparison → "vs/반면/차이", sequence → "→/이후/다음" 등 | 한국어 관계 표현 패턴 수집 |
|
||||
|
||||
### C. 모순 결정 테이블
|
||||
> 목적: purpose × relation_type × layer 간 논리적 모순을 탐지한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 하드 모순 정의 | purpose × relation_type 확실한 모순 쌍 정의 (결론강조+comparison 등) | |
|
||||
| b | 소프트 경고 정의 | 의심 수준의 조합 (핵심전달+definition 등) | |
|
||||
| c | layer × role 모순 | conclusion+reference=모순, intro+reference=모순 | |
|
||||
| d | 피드백 문장 생성 | 각 모순에 "왜 모순인지" + "대안 제안" 문장 연결 | |
|
||||
| e | 결정 테이블 구현 | 데이터(dict)로 규칙, 코드로 판정. 규칙 추가/삭제가 코드 변경 없이 가능하도록 | |
|
||||
|
||||
### D. 실패 피드백 생성 및 회귀
|
||||
> 목적: 검증 실패 시 구체적 피드백으로 재시도하여 품질을 올린다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 피드백 템플릿 설계 | 에러별로 어떤 정보를 포함할지: 실패 필드, 현재 값, 원본 해당 부분, 대안 | |
|
||||
| b | Kei 재요청 방식 | 기존 프롬프트 + "## 이전 응답의 문제점" 섹션 추가 | LLM retry 피드백 최적 형식 (Instructor/PPTAgent) |
|
||||
| c | 재시도 횟수 관리 | AI Stage: 최대 2회, 코드 Stage: 자동 조정 1회 | |
|
||||
| d | 최선 결과 처리 | 재시도 초과 시 최선 결과로 진행 + 경고를 context에 기록 | |
|
||||
|
||||
### E. 기존 검증과 통합
|
||||
> 목적: Stage 2 이후의 기존 검증을 T-0 패턴에 맞추어 정리한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 통합 설계 | content_verifier.py의 5층 검증이 run_stage 패턴에 어떻게 들어가는지 | |
|
||||
| b | 에러 재분류 | 기존 검증 에러를 FATAL/RETRYABLE/ADJUSTABLE로 분류 | |
|
||||
| c | 중복 제거 | Stage 0~1.5에서 이미 검증한 것을 Stage 2에서 다시 하지 않도록 | |
|
||||
|
||||
**산출물:** `src/validators.py` (신규), `src/pipeline.py` (수정), `src/content_verifier.py` (수정)
|
||||
|
||||
---
|
||||
|
||||
## T-3. 블록 참고 디자인 + 프롬프트 정비
|
||||
|
||||
### A. relation_type → 참고 블록 매핑 설계
|
||||
> 목적: 콘텐츠의 논리 관계에 맞는 블록을 코드가 결정론적으로 선택한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | RELATION_BLOCK_MAP 정의 | 7개 relation_type × 참고 블록 후보 (우선순위순) | |
|
||||
| b | expression_hint 2차 필터 | 같은 comparison이라도 "대등 비교" vs "우열 비교"에 따라 다른 블록 | 실제 run에서 나온 expression_hint 패턴 수집 |
|
||||
| c | 매핑 근거 정리 | 왜 hierarchy→venn인지 (Gestalt 원칙, 시각 커뮤니케이션 이론) | 관계유형→시각패턴 학술 근거 |
|
||||
| d | fallback 정의 | 매핑에 해당 블록 없을 때 기본값 | |
|
||||
| e | 매핑 위치 결정 | 코드(결정론적) vs catalog.yaml(수정 용이). 장단점 비교 후 결정 | |
|
||||
|
||||
### B. 참고 블록 HTML 로딩 시스템
|
||||
> 목적: 선택된 블록의 실제 HTML을 프롬프트에 제공할 수 있도록 한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | load_block_html() | catalog.yaml → template 경로 → HTML 파일 로드 | |
|
||||
| b | mtime 캐시 | 블록 파일 변경 시 자동 갱신. 38개 블록 ~76KB 메모리 | |
|
||||
| c | 시작 시 검증 | validate_block_references() — 매핑된 블록 전체 존재 확인 | |
|
||||
| d | build_design_brief() | catalog의 visual + when + visual_diff(T-4) → 자연어 브리프 | |
|
||||
|
||||
### C. 프롬프트 재설계
|
||||
> 목적: 하드코딩 CSS를 제거하고, context의 누적 정보를 체계적으로 프롬프트에 주입한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 하드코딩 CSS 제거 | BG/CORE/SIDEBAR/FOOTER_PROMPT에서 고정 CSS 섹션 제거 | |
|
||||
| b | 참고 블록 주입 | `{reference_design}` 변수로 블록 HTML + design brief 삽입 | |
|
||||
| c | "참고/복사금지" 프레이밍 | 참고할 것(색상, 레이아웃) / 참고하지 말 것(구조 복사, 항목 수 맞추기) 명시 | few-shot reference 시 LLM 적응 vs 복사 경향 |
|
||||
| d | expression_hint 지시 | "이 콘텐츠의 관계는 {relation_type}이다. 시각적으로 드러나도록" | |
|
||||
| e | context 누적 정보 주입 | core_message(1A), containers.height_px(1.5), relation_type(1B) 등 체계적 삽입 | |
|
||||
| f | 공통 규칙 일관성 | 텍스트 보존, inline style 등 중복 규칙을 상수로 추출, 영역별 차이만 분리 | |
|
||||
|
||||
### D. html_generator.py 수정
|
||||
> 목적: generate_slide_html()이 context 기반으로 동작하도록 전환한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | context에서 references 꺼내기 | generate_slide_html()이 context.references 사용 | |
|
||||
| b | 프롬프트 조립 로직 | context.containers + context.references + 공통규칙 + 영역별규칙 → 최종 프롬프트 | |
|
||||
| c | 기존 함수 전환 | _map_sections_for_role(), _get_definitions() 등이 context.clean_text 사용 | |
|
||||
|
||||
**산출물:** `src/block_reference.py` (신규), `src/html_generator.py` (수정)
|
||||
|
||||
---
|
||||
|
||||
## T-4. 블록 설명 catalog.yaml 보완
|
||||
|
||||
### A. 유사 블록 그룹 정의
|
||||
> 목적: visual_diff가 필요한 블록 20개를 특정한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 그룹핑 | 38개 블록을 시각적 유사성 기준으로 그룹. 비교(5), 카드(6), 강조(4), 흐름(2), 헤더(3) = 20개 | |
|
||||
| b | 그룹 내 유일 블록 제외 | 유일한 블록은 기존 visual만으로 충분 | |
|
||||
|
||||
### B. 실제 렌더링 기반 차이점 확인
|
||||
> 목적: "추정"이 아닌 "실측"으로 차이점을 기술한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | 블록 독립 렌더링 | 각 블록을 샘플 데이터로 렌더링 → 스크린샷 캡처 | Selenium/Playwright로 블록 독립 렌더링 스크립트 |
|
||||
| b | 그룹 내 시각 비교 | 같은 그룹 블록들을 나란히 놓고 차이점 확인 | |
|
||||
| c | 실측 높이 검증 | height_cost 분류가 실제와 맞는지 확인. 불일치 시 catalog 수정 | |
|
||||
|
||||
### C. visual_diff 작성 및 반영
|
||||
> 목적: 각 블록의 고유한 차이점을 catalog.yaml에 추가한다.
|
||||
|
||||
| # | 태스크 | 설명 | 조사 필요 |
|
||||
|---|--------|------|----------|
|
||||
| a | visual_diff 작성 | 그룹 내 다른 블록과의 차이. "이 블록만의 특징", "적합/부적합 상황" 구체적 예시 포함 | |
|
||||
| b | catalog.yaml 반영 | 20개 블록에 visual_diff 필드 추가 | |
|
||||
| c | T-3 연동 확인 | build_design_brief()가 visual_diff를 읽는지 확인 | |
|
||||
| d | FAISS 인덱스 재빌드 | catalog 변경 후 build_block_index.py 재실행 필요 여부 확인 | |
|
||||
|
||||
**산출물:** `templates/catalog.yaml` (수정)
|
||||
|
||||
---
|
||||
|
||||
## 전체 태스크 요약
|
||||
|
||||
| Step | 큰 꼭지 | 세부 태스크 수 | 핵심 산출물 |
|
||||
|------|---------|-------------|------------|
|
||||
| T-0 | A(Context) + B(run_stage) + C(리팩토링) | 16개 | pipeline_context.py, pipeline.py |
|
||||
| T-1 | A(분석) + B(파서) + C(연결) | 12개 | mdx_normalizer.py |
|
||||
| T-2 | A(스키마) + B(내용검증) + C(모순) + D(피드백) + E(통합) | 18개 | validators.py |
|
||||
| T-3 | A(매핑) + B(로딩) + C(프롬프트) + D(수정) | 17개 | block_reference.py, html_generator.py |
|
||||
| T-4 | A(그룹) + B(렌더링) + C(작성) | 9개 | catalog.yaml |
|
||||
| **합계** | **15개 꼭지** | **72개 태스크** | **5개 파일** |
|
||||
|
||||
---
|
||||
|
||||
## 조사 항목 (전체 완료 — 2026-04-01)
|
||||
|
||||
| Step | 항목 | 결과 요약 | 상태 |
|
||||
|------|------|----------|------|
|
||||
| T-0 | Pydantic vs dataclass | **Pydantic BaseModel 채택.** model_dump_json() 직렬화, validate_assignment=True 타입 검증 | ✅ |
|
||||
| T-0 | async generator + run_stage | **Approach C 채택.** Stage가 coroutine이든 async generator이든 run_stage()가 통합 처리 | ✅ |
|
||||
| T-1 | python-frontmatter | **v1.1.0.** parse() → (dict, str) 튜플. 의존성 43KB. frontmatter 없는 문서도 안전 처리 | ✅ |
|
||||
| T-1 | markdown-it-py | **v4.0 js-default.** table 기본 포함. flat token list로 table/image 추출 간결 (~20줄). 한국어 문제 없음 | ✅ |
|
||||
| T-1 | 코드블록 보호 | backtick 10→3 순서 매칭. 중첩/inline 모두 검증됨 | ✅ |
|
||||
| T-2 | 한국어 키워드 추출 | **kiwipiepy 채택.** Java 불필요, Windows 즉시 동작, pip 한 줄. konlpy 부적합(Java 의존) | ✅ |
|
||||
| T-2 | 관계 표현 패턴 | 7개 relation_type별 15개+ regex 패턴 수집 완료. 건설/BIM/DX 도메인 기준 | ✅ |
|
||||
| T-2 | LLM retry 피드백 형식 | **Self-Refine(NeurIPS 2023) 채택.** localization + evidence + instruction. VASCAR Scorer+Suggester 분리 | ✅ |
|
||||
| T-3 | expression_hint 패턴 | 10개 고유값 → **5개 시각적 유형** 분류. definition 내 2차 구분 필수 확인 | ✅ |
|
||||
| T-3 | few-shot reference LLM 경향 | 구조 70-90% 복사. **"디자인 레퍼런스"** 프레이밍 최적. HTML+구조주석 > CSS만 | ✅ |
|
||||
| T-3 | 관계유형→시각패턴 근거 | Gestalt 폐합→벤, 근접→좌우, 연속→화살표. PPTAgent(EMNLP 2025) 학술 입증 | ✅ |
|
||||
| T-4 | 블록 독립 렌더링 | **Selenium 사용** (이미 존재). 브라우저 1회→42회 navigate. ~1분. scripts/capture_block_screenshots.py | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 검토 결과 발견 사항 (2026-04-01)
|
||||
|
||||
조사 완료 후 아키텍처 문서(ARCHITECTURE-PHASE-T.md)와 실제 코드를 대조하여 발견된 보완 사항.
|
||||
|
||||
### 설계 보완 (아키텍처 반영 완료)
|
||||
|
||||
| # | 발견 사항 | 해결 |
|
||||
|---|----------|------|
|
||||
| 1 | Stage 1.5의 순환 의존 — design_budget이 block_schema 필요하지만 블록은 1.7에서 선택 | **1.5a→1.7→1.5b 분리.** 1.5a는 폰트 위계+비율만, 1.5b는 블록 선택 후 디자인 예산 재계산 |
|
||||
| 2 | expression_hint 매칭이 정확한 문자열 매칭 → 실제 hint는 긴 문장 | **키워드 포함(substring) 매칭으로 변경.** VISUAL_TYPE_KEYWORDS dict |
|
||||
| 3 | 폰트 위계가 아키텍처에 명시 안 됨 — Phase T 핵심인데 | **Section 1.2에 폰트 위계 + Stage 1.5a에 역산 로직 추가** |
|
||||
| 4 | 동적 컨테이너 비율이 없음 — 고정 65:35 | **Stage 1.5a에 텍스트 양 기반 비율 역산 추가** |
|
||||
| 5 | Stage 1A/1B 검증 누락 | **Stage 1A: Pydantic + 원본 대조, 1B: 모순 탐지 + source_data 할루시네이션 감지 추가** |
|
||||
| 6 | Stage 1.7 fallback 미정의 | **카테고리별 fallback 블록 테이블 정의** |
|
||||
| 7 | 재시도 총 예산 불분명 | **Stage별 최대 횟수 + 전체 300초 타임아웃 명시** |
|
||||
| 8 | 마이그레이션 맵 없음 | **Section 8: 신규/수정/삭제 파일 맵 추가** |
|
||||
|
||||
### 사실 오류 (아키텍처 수정 완료)
|
||||
|
||||
| # | 오류 | 수정 |
|
||||
|---|------|------|
|
||||
| 1 | 웹 프레임워크 Flask → **FastAPI** | 수정 |
|
||||
| 2 | 글자폭 비율 0.75 → **0.947** | 수정 |
|
||||
| 3 | relation_type 6개 → **7개 (none 추가)** | 수정 |
|
||||
| 4 | 카탈로그 카테고리 5개 → **6개 (tables, visuals 추가)** | 수정 |
|
||||
| 5 | weight 합 "= 100%" → **"0.9~1.1 범위"** | 수정 |
|
||||
| 6 | Stage 0 출력에 popups, tables 누락 | 추가 |
|
||||
| 7 | Stage 1A 출력에 source_hint 누락 | 추가 |
|
||||
|
||||
150
PHASE-T-PRIME.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Phase T' (T-Prime) — 결과물 품질 개선
|
||||
|
||||
> 작성일: 2026-04-02
|
||||
> 근거: Phase T 파이프라인 실행 결과물(20260402_083722) 시각 검토에서 발견된 6건
|
||||
> 선행: Phase T 파이프라인 구조 완성 (Stage 0~5 동작, 프롬프트 동적 생성 교체 완료)
|
||||
|
||||
---
|
||||
|
||||
## 발견된 문제 6건
|
||||
|
||||
### TP-1. 배경 영역이 다크로 가장 눈에 띔
|
||||
|
||||
**현상:** 배경(보조 영역)이 dark-bullet-list의 다크 배경(#1a2332)을 사용하여 슬라이드에서 가장 강조됨. 본심(핵심)보다 배경에 시선이 먼저 감.
|
||||
|
||||
**원인:** Stage 1.7(block_reference.py)에서 배경 역할에 cause_effect → dark-bullet-list를 선택. dark-bullet-list는 다크 배경 블록이므로 배경 역할에 부적합.
|
||||
|
||||
**해결 방향:**
|
||||
- block_reference.py에서 **배경 역할은 다크 계열 블록 제외** 규칙 추가
|
||||
- 배경용 블록 후보: 라이트 계열만 (card-numbered, card-icon-desc, callout-solution 등)
|
||||
- 또는 배경 역할 전용 매핑 추가: cause_effect + 배경 → callout-solution (라이트 파란 배경)
|
||||
|
||||
**수정 파일:** `src/block_reference.py`
|
||||
|
||||
---
|
||||
|
||||
### TP-2. 본심 이미지만 크고 메시지 전달 불명확
|
||||
|
||||
**현상:** 본심 영역에 벤 다이어그램 이미지가 크게 차지하고, 텍스트가 아래에 밀려있어서 무슨 메시지를 전달하려는지 불명확.
|
||||
|
||||
**원인:** 본심 프롬프트(build_area_prompt)에서 "이미지와 텍스트의 배치 관계", "핵심 메시지를 어떻게 시각적으로 강조할지" 지시가 부족.
|
||||
|
||||
**해결 방향:**
|
||||
- 본심 프롬프트에 추가:
|
||||
- "텍스트가 주인공. 이미지는 텍스트를 보조하는 역할"
|
||||
- "이미지는 float:right 또는 텍스트 옆에 배치. 이미지가 전체 폭을 차지하면 안 됨"
|
||||
- "핵심 메시지(key-msg)가 시각적으로 가장 눈에 띄어야 함 — 배경색 + 큰 폰트"
|
||||
- 이미지가 있을 때 레이아웃: 텍스트 좌측 + 이미지 우측 float, 또는 2단 구성
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 본심 섹션)
|
||||
|
||||
---
|
||||
|
||||
### TP-3. 용어정의(sidebar) 오른쪽 잘림
|
||||
|
||||
**현상:** sidebar 카드의 텍스트가 오른쪽에서 잘려서 안 보임.
|
||||
|
||||
**원인 후보:**
|
||||
1. Sonnet이 생성한 HTML의 width가 sidebar 컨테이너(380px)를 초과
|
||||
2. 카드 내부 padding + 텍스트가 너비를 넘침
|
||||
3. word-break: keep-all이 긴 영문(Building Information Modeling)을 줄바꿈하지 않음
|
||||
|
||||
**해결 방향:**
|
||||
- build_area_prompt 첨부 섹션에 추가:
|
||||
- "word-break: break-word (긴 영문 줄바꿈)"
|
||||
- "각 카드 width: 100%. 카드 내부 padding 포함하여 컨테이너 안에 맞출 것"
|
||||
- "텍스트가 잘리면 안 됨. 넘치면 폰트를 줄여서 맞출 것"
|
||||
- sidebar 폰트가 10px인데, 긴 영문 제목이 있으면 더 줄여야 할 수 있음
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 첨부 섹션)
|
||||
|
||||
---
|
||||
|
||||
### TP-4. 불릿 2줄째 들여쓰기 불일치
|
||||
|
||||
**현상:** 불릿(•) 첫줄 텍스트 시작점과 2줄째 시작점이 일직선이 아님. 여러 영역에서 공통.
|
||||
|
||||
**원인:** build_area_prompt에서 `padding-left/text-indent` 지시가 있지만 Sonnet이 일관되게 안 따름.
|
||||
|
||||
**해결 방향:**
|
||||
- 프롬프트에 **구체적 HTML 예시**를 포함하여 강제:
|
||||
```
|
||||
불릿 예시 (이 HTML을 정확히 따라라):
|
||||
<div style="padding-left:14px; text-indent:-14px;">• 첫줄 텍스트가 여기서 시작하고
|
||||
둘째줄도 정확히 같은 위치에서 시작해야 한다</div>
|
||||
```
|
||||
- 들여쓰기 CSS를 프롬프트가 아니라 **후처리(Stage 3)에서 강제 적용**하는 것도 고려
|
||||
- 생성된 HTML에서 `• ` 로 시작하는 텍스트를 찾아 padding-left/text-indent를 코드로 주입
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 공통) + 선택적으로 `src/renderer.py` (후처리)
|
||||
|
||||
---
|
||||
|
||||
### TP-5. 팝업 링크 위치 부적절
|
||||
|
||||
**현상:** "[DX와 BIM의 구분 상세보기]" 링크가 본문 하단에 한 줄로 떡하니 놓여있음. 본문의 흐름을 방해.
|
||||
|
||||
**원인:** build_area_prompt에서 "상세보기 링크를 어디에 배치하라"는 위치 지시가 없음.
|
||||
|
||||
**해결 방향:**
|
||||
- 본심 프롬프트에 추가:
|
||||
- "상세보기 링크는 관련 내용의 우측 상단에 작게 배치 (font-size: 10px, color: #2563eb, 우측 정렬)"
|
||||
- "본문 흐름 중간에 넣지 마라. 해당 섹션의 헤더 옆에 배치"
|
||||
- 예시:
|
||||
```html
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3>DX와 핵심기술의 올바른 관계</h3>
|
||||
<a style="font-size:10px; color:#2563eb;">상세보기 →</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**수정 파일:** `src/html_generator.py` (build_area_prompt 본심 섹션)
|
||||
|
||||
---
|
||||
|
||||
### TP-6. 첨부 HTML 디자인 없음
|
||||
|
||||
**현상:** 첨부1_혼용 대표 사례.html, 첨부2_DX와 BIM의 구분.html이 raw MDX content를 그냥 HTML로 감싼 것. 테이블 스타일은 있지만 전체 디자인이 없음.
|
||||
|
||||
**원인:** Stage 5에서 popup.content를 `<body>` 안에 그대로 넣음. Sonnet에게 디자인을 시키지 않음.
|
||||
|
||||
**해결 방향:**
|
||||
- 첨부 HTML도 Sonnet에게 디자인 요청
|
||||
- 또는 슬라이드와 동일한 디자인 토큰(tokens.css + base.css)을 적용한 템플릿 사용
|
||||
- 첨부 HTML은 슬라이드(1280x720)가 아니라 **A4 세로 문서 형태** (읽기 쉬운 형태)
|
||||
|
||||
**수정 파일:** `src/pipeline.py` (Stage 5 팝업 HTML 생성 부분)
|
||||
|
||||
---
|
||||
|
||||
## 수정 분류
|
||||
|
||||
| 분류 | 관련 문제 | 수정 파일 | 규모 |
|
||||
|------|----------|----------|------|
|
||||
| **A. 블록 선택 규칙** | TP-1 | block_reference.py | 작음 |
|
||||
| **B. 프롬프트 강화** | TP-2, TP-3, TP-4, TP-5 | html_generator.py | 중간 |
|
||||
| **C. 들여쓰기 후처리** | TP-4 | renderer.py (선택) | 작음 |
|
||||
| **D. 첨부 HTML 디자인** | TP-6 | pipeline.py | 중간 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
TP-1 (블록 선택 규칙) → 작음, 독립
|
||||
TP-2~5 (프롬프트 강화) → 중간, build_area_prompt 한 곳에서 처리
|
||||
TP-4 추가 (들여쓰기 후처리) → 작음, 프롬프트로 안 되면 코드로 강제
|
||||
TP-6 (첨부 HTML 디자인) → 중간, 독립
|
||||
→ 전체 재실행 + 시각 검토
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 검증 기준
|
||||
|
||||
- [ ] 배경이 라이트 톤. 본심이 가장 눈에 띄는가
|
||||
- [ ] 본심에서 텍스트가 주인공이고 이미지가 보조인가
|
||||
- [ ] 용어정의가 잘리지 않고 전부 보이는가
|
||||
- [ ] 모든 영역에서 불릿 2줄째가 첫줄과 일직선인가
|
||||
- [ ] 팝업 링크가 우측 상단에 작게 있는가
|
||||
- [ ] 첨부 HTML이 디자인된 문서 형태인가
|
||||
135
PHASE-T-REMAINING.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Phase T 잔여 작업 — 프롬프트 동적 생성 + 미반영 사항
|
||||
|
||||
> 작성일: 2026-04-02
|
||||
> 상태: Phase T 구조(Stage 0~5)는 완성. 하지만 핵심인 프롬프트가 Phase S 하드코딩 그대로.
|
||||
> 이 문서: 프롬프트 교체 + 미반영 사항 전체 리스트 + 실행 계획.
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 문제 리스트
|
||||
|
||||
### 1-1. 프롬프트 하드코딩 (근본 문제)
|
||||
|
||||
html_generator.py의 BG_PROMPT, CORE_PROMPT, SIDEBAR_PROMPT, FOOTER_PROMPT 4개가
|
||||
Phase S 때 만든 고정 CSS 값(9px, 10px, #f8fafc, padding:14px 등)으로 박혀있음.
|
||||
Phase T에서 계산한 폰트 위계, 디자인 예산, 블록 레퍼런스가 결과에 반영되지 않는 근본 원인.
|
||||
|
||||
### 1-2. 폰트 크기 위계 미반영
|
||||
|
||||
Phase T Stage 1.5a에서 계산: 핵심=14px, 본심=12px, 배경=11px, 첨부=10px.
|
||||
하지만 프롬프트가 배경=9px, 첨부=10px 등 다른 값을 하드코딩.
|
||||
→ 프롬프트가 Phase T 위계 값을 사용해야 함.
|
||||
|
||||
### 1-3. 배경-본심 가로 길이 불일치
|
||||
|
||||
sidebar-right 구조에서 배경과 본심은 같은 body zone에 있으므로 가로 폭이 동일해야 함.
|
||||
현재 프롬프트가 각각 다른 width를 지정할 수 있어서 Sonnet이 다르게 생성.
|
||||
→ "body 영역 전체 폭 100%" 강제.
|
||||
|
||||
### 1-4. 들여쓰기 불일치
|
||||
|
||||
불릿(•) 첫째줄 텍스트 시작점과 둘째줄 시작점이 정확히 일직선이어야 함.
|
||||
글씨 크기가 영역마다 다르므로(배경 11px, 본심 12px, 첨부 10px) 들여쓰기 px도 각 폰트에 맞게.
|
||||
padding-left와 text-indent를 폰트 크기 기준으로 계산.
|
||||
|
||||
계산 방식:
|
||||
- 불릿 마커 "• " 폭 ≈ font_size × 1.2 (한글 기준)
|
||||
- padding-left = ceil(font_size × 1.2)
|
||||
- text-indent = -padding-left
|
||||
|
||||
| 영역 | 폰트 | padding-left | text-indent |
|
||||
|------|------|-------------|-------------|
|
||||
| 배경 (11px) | 11px | 14px | -14px |
|
||||
| 본심 (12px) | 12px | 15px | -15px |
|
||||
| 첨부 (10px) | 10px | 12px | -12px |
|
||||
|
||||
### 1-5. 블록 선택 → 컨테이너 맞춤 재구성 미반영
|
||||
|
||||
Phase T의 핵심 목적:
|
||||
- Stage 1.7에서 relation_type + expression_hint → 참고 블록 선택
|
||||
- 선택된 블록의 구조(색상, 레이아웃, 패턴)를 따르되 컨테이너 크기에 맞게 재구성
|
||||
- AI가 "발명"하지 않고 검증된 블록 구조를 따르게
|
||||
|
||||
현재: Stage 1.7이 블록을 선택하고 레퍼런스 HTML을 생성하지만,
|
||||
프롬프트가 이것을 무시하고 하드코딩 CSS를 따름.
|
||||
→ 프롬프트가 레퍼런스 HTML을 "따르라"고 지시해야 함.
|
||||
|
||||
### 1-6. 팝업(상세 내용) 별도 HTML 분리
|
||||
|
||||
본문에 다 넣을 수 없는 상세 내용(DX-BIM 비교표 12행 등)은:
|
||||
1. final.html에는 "상세보기" 링크만
|
||||
2. 상세 내용은 별도 첨부 HTML 파일로 생성
|
||||
|
||||
출력 구조:
|
||||
```
|
||||
data/runs/{run_id}/
|
||||
├── final.html ← 슬라이드 본문
|
||||
├── 첨부1_DX_BIM_비교표.html ← details에서 분리된 상세
|
||||
├── 첨부2_xxx.html ← 필요 시 추가
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 1-7. 동일 내용 중복 금지
|
||||
|
||||
같은 내용이 본문에 2번 나오면 안 됨.
|
||||
예: "DX와 BIM 비교표 보기" 링크 + 본문에 비교표 전체 → 중복.
|
||||
비교표는 첨부 HTML로 분리하고, 본문에는 링크만.
|
||||
|
||||
---
|
||||
|
||||
## 2. 수정 완료 항목 (이미 처리됨)
|
||||
|
||||
| # | 항목 | 파일 | 상태 |
|
||||
|---|------|------|------|
|
||||
| A | 동적 비율 72:28 grid 반영 | renderer.py | ✅ 완료 |
|
||||
| B | body-footer 공란 제거 | renderer.py | ✅ 완료 |
|
||||
| C | L4 overflow 시 재생성 트리거 | pipeline.py | ✅ 완료 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 실행 계획
|
||||
|
||||
### Step R-1: 프롬프트 동적 생성 함수 (1-1, 1-2, 1-3, 1-4, 1-5, 1-7 해결)
|
||||
|
||||
**파일:** html_generator.py
|
||||
|
||||
하드코딩 BG_PROMPT/CORE_PROMPT/SIDEBAR_PROMPT/FOOTER_PROMPT를 삭제하고,
|
||||
`build_area_prompt(role, context)` 함수로 교체.
|
||||
|
||||
이 함수가 context에서 가져오는 것:
|
||||
- font_size ← context.font_hierarchy (1-2)
|
||||
- width ← context.containers[role].width_px (1-3)
|
||||
- height ← context.containers[role].height_px
|
||||
- indent_px ← font_size 기반 계산 (1-4)
|
||||
- reference_html ← context.references[role].design_reference_html (1-5)
|
||||
- design_budget ← context.containers[role].design_budget
|
||||
- "중복 금지" 규칙 (1-7)
|
||||
|
||||
### Step R-2: 팝업 별도 HTML 생성 (1-6 해결)
|
||||
|
||||
**파일:** pipeline.py (Stage 5), html_generator.py
|
||||
|
||||
Stage 0에서 추출된 popups[]를 별도 HTML 파일로 생성.
|
||||
final.html에는 "상세보기" 링크만 남기고, 상세 내용은 첨부N_제목.html로 저장.
|
||||
|
||||
### Step R-3: 검증 + 시뮬레이션
|
||||
|
||||
**파일:** scripts/test_phase_t_audit.py 확장
|
||||
|
||||
- 프롬프트에 하드코딩 px 값이 없는지 검사
|
||||
- font_hierarchy 값이 프롬프트에 반영되는지 확인
|
||||
- 들여쓰기 CSS가 폰트 크기 기반인지 확인
|
||||
- 레퍼런스 HTML이 프롬프트에 포함되는지 확인
|
||||
- 팝업 별도 HTML 생성 확인
|
||||
- 실제 데이터로 전체 시뮬레이션
|
||||
|
||||
---
|
||||
|
||||
## 4. 실행 순서
|
||||
|
||||
```
|
||||
Step R-1 (프롬프트 동적 생성) → Step R-3 (검증)
|
||||
Step R-2 (팝업 분리) → Step R-3 (검증)
|
||||
```
|
||||
|
||||
R-1이 가장 크고 핵심. R-2는 독립 작업.
|
||||
36
PHASE-V-PRIME.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Phase V' — 조립 로직 수정 4건
|
||||
|
||||
> 작성일: 2026-04-06
|
||||
> 상태: 정리 완료, 미착수
|
||||
|
||||
---
|
||||
|
||||
## V'-1: 팝업 링크 위치
|
||||
|
||||
**현재:** 팝업 링크가 텍스트에 인라인으로 붙어있어 눈에 잘 안 보임
|
||||
**변경:** 컨테이너의 빈 공간에 배치. 표가 있으면 표 우측상단에 배치.
|
||||
**대상 파일:** `src/block_assembler.py`, `scripts/assemble_stage2.py`
|
||||
|
||||
---
|
||||
|
||||
## V'-2: 표 내용 Kei 판단
|
||||
|
||||
**현재:** 팝업 원본 콘텐츠의 마크다운 표를 그대로 compact 변환하여 삽입
|
||||
**변경:** Kei가 핵심 내용을 판단하여 표 내용을 채움. 행/열 크기가 결정된 후 Kei가 해당 공간에 맞는 요약을 생성.
|
||||
**대상 파일:** `scripts/assemble_stage2.py`, `src/kei_client.py` (새 함수)
|
||||
|
||||
---
|
||||
|
||||
## V'-3: 출처 라벨 삭제
|
||||
|
||||
**현재:** `출처: [그림 1] DX와 핵심기술간 상호관계` — "출처:" 라벨 포함
|
||||
**변경:** 이미지 아래에 텍스트를 넣되 "출처:" 라벨 삭제. 예: `[그림 1] DX와 핵심기술간 상호관계`
|
||||
**대상 파일:** `src/block_assembler.py`, `scripts/assemble_stage2.py`
|
||||
|
||||
---
|
||||
|
||||
## V'-4: after 공란 제거
|
||||
|
||||
**현재:** code_assembled에만 적용됨 (결론 바로 위까지 body/sidebar 채움). block_assembler의 after(assemble_slide_html)에는 미적용 — body와 sidebar 높이 차이로 공란 발생 가능.
|
||||
**변경:** `assemble_slide_html()`에서도 after 컨테이너 조립 시 결론 바로 위까지 body/sidebar 높이를 맞춤.
|
||||
**대상 파일:** `src/block_assembler.py` — `assemble_slide_html()`
|
||||
317
PHASE-V.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Phase V (Verification) — 콘텐츠-컨테이너 적합성 검증 + Kei 에스컬레이션
|
||||
|
||||
> 작성일: 2026-04-02
|
||||
> 근거: Phase T' 디버깅 과정에서 발견된 파이프라인 구조적 결함
|
||||
> 선행: Phase T (파이프라인 구조), Phase T' (시각 품질)
|
||||
|
||||
---
|
||||
|
||||
## 배경
|
||||
|
||||
### 발견된 구조적 문제
|
||||
|
||||
Phase T' 디버깅 중 step-by-step 시각 검토를 진행하면서 다음이 드러남:
|
||||
|
||||
1. **컨테이너 크기가 콘텐츠 분량과 무관하게 결정됨**
|
||||
- Stage 1.5a에서 weight(0.6, 0.2, 0.1, 0.1) 고정 배분
|
||||
- 배경에 꼭지 2개(220자)가 배정되었으나 117px밖에 안 됨 → 넘침
|
||||
- 본심은 345px인데 실제 필요 260px → 85px 남음
|
||||
|
||||
2. **"들어가는지" 검증 단계가 없음**
|
||||
- 블록 선택(Stage 1.7) 후 바로 HTML 생성(Stage 2)으로 넘어감
|
||||
- 콘텐츠가 컨테이너에 실제로 들어가는지 아무도 확인하지 않음
|
||||
- Sonnet이 넘치는 내용을 받아서 overflow/스크롤/잘림 발생
|
||||
|
||||
3. **안 될 때 판단하는 주체가 없음**
|
||||
- 공간 부족 시 옵션(합치기, 축약, 팝업 이동, 구조 변경)을 생성하고
|
||||
- Kei 페르소나에게 결정을 요청하는 프로세스가 없음
|
||||
- 현재는 그냥 Sonnet에게 "넣어라"만 함
|
||||
|
||||
4. **영역당 블록 1개만 선택됨**
|
||||
- 배경에 꼭지 2개가 있어도 블록 1개(callout-warning)만 선택
|
||||
- 1꼭지 = 1블록 원칙이 지켜지지 않음
|
||||
|
||||
---
|
||||
|
||||
## 절대 원칙
|
||||
|
||||
1. **하드코딩 금지** — font-size 외 모든 수치는 동적 계산. 어떤 MDX가 들어와도 동일하게 동작
|
||||
2. **스크롤 절대 금지** — overflow:auto/scroll 어떤 영역에서도 불허
|
||||
3. **1꼭지 = 1블록** — 컨테이너에 꼭지 N개면 블록 N개가 개별 선택
|
||||
4. **콘텐츠 분량 → 컨테이너 크기** — weight 고정이 아니라 콘텐츠 필요 높이 기반 배분
|
||||
5. **AI가 옵션 생성, Kei가 결정** — 안 될 때 하드코딩 대응이 아니라 Kei 판단 요청
|
||||
|
||||
---
|
||||
|
||||
## 개선된 파이프라인
|
||||
|
||||
```
|
||||
기존:
|
||||
1A → 1B → 1.5a(weight고정) → 1.5b → 1.7(영역당1블록) → 2(HTML) → 3 → 4
|
||||
↑
|
||||
여기서 넘치거나 잘림
|
||||
|
||||
개선:
|
||||
1A → 1B → 1.7(꼭지별1블록) → 1.8★(적합성검증) → 2(HTML) → 3 → 4
|
||||
│
|
||||
├ 필요 높이 계산
|
||||
├ 컨테이너 재배분
|
||||
└ 안 되면 → Kei 에스컬레이션
|
||||
```
|
||||
|
||||
### 변경 사항 요약
|
||||
|
||||
| Stage | 기존 | 개선 |
|
||||
|-------|------|------|
|
||||
| 1.5a | weight 고정 배분 | 삭제 — 1.8에서 콘텐츠 기반 계산 |
|
||||
| 1.7 | 영역당 블록 1개 | **꼭지당 블록 1개** |
|
||||
| **1.8 (신규)** | 없음 | **적합성 검증 + 재배분 + Kei 에스컬레이션** |
|
||||
|
||||
---
|
||||
|
||||
## Stage 1.8: 적합성 검증 (신규)
|
||||
|
||||
### 입력
|
||||
|
||||
- 꼭지 목록 + 영역 배정 (Stage 1A/1B)
|
||||
- 꼭지별 선택된 블록 + 블록 최소 높이 (Stage 1.7)
|
||||
- 슬라이드 크기 (1280×720), padding, gap 등 고정 스펙
|
||||
|
||||
### 처리 흐름 (AI가 자동 — 하드코딩 아님)
|
||||
|
||||
```
|
||||
Step 1: 필요 높이 계산
|
||||
각 컨테이너별로:
|
||||
- 배정된 꼭지들의 텍스트 분량(자수) 파악
|
||||
- 해당 영역 font-size + line-height로 필요 줄 수 계산
|
||||
- 선택된 블록의 padding, 제목, 마진 등 오버헤드 합산
|
||||
- → 필요 최소 높이(px) 산출
|
||||
|
||||
Step 2: 슬라이드 공간 배분
|
||||
720px 에서:
|
||||
- header(고정) + footer(고정) + gap 빼기
|
||||
- 남은 공간을 각 영역의 필요 높이 비율로 배분
|
||||
- sidebar는 body와 같은 row이므로 sidebar 높이 = body 영역 합계
|
||||
|
||||
Step 3: 적합성 검증
|
||||
각 컨테이너별로:
|
||||
- 배분된 높이 ≥ 필요 높이 → 통과
|
||||
- 배분된 높이 < 필요 높이 → Step 4로
|
||||
|
||||
Step 4: 재배분 시도
|
||||
여유 있는 영역에서 부족한 영역으로 공간 이동:
|
||||
- 각 영역의 (배분 높이 - 필요 높이) = 여유분 계산
|
||||
- 여유분 > 0인 영역에서 부족 영역으로 재분배
|
||||
- 재배분 후 모든 영역이 필요 높이 이상 → 통과
|
||||
- 아직 부족 → Step 5로
|
||||
|
||||
Step 5: Kei 에스컬레이션
|
||||
AI가 현황 + 시도 결과 + 옵션을 정리하여 Kei에게 요청:
|
||||
|
||||
[현황]
|
||||
- 어떤 영역이 몇 px 부족한지
|
||||
- 어떤 영역에 여유가 있는지
|
||||
|
||||
[시도 결과]
|
||||
- 재배분으로 해결 가능한지/불가능한지
|
||||
- 해결 가능하면 어떤 영역에서 얼마를 가져오는지
|
||||
|
||||
[옵션]
|
||||
A. 꼭지 합치기 — 여러 꼭지를 하나의 블록 안에서 흐름으로 연결
|
||||
B. 인라인 축약 — 사례 등을 괄호 한 줄로 축약
|
||||
C. 팝업 이동 — 상세 내용을 팝업으로 빼고 링크만 남김
|
||||
D. 컨테이너 재조정 — 다른 영역에서 공간을 가져옴
|
||||
E. 그리드 구조 변경 — 배경 전체폭 등 레이아웃 자체 변경
|
||||
F. 기타 (Kei 판단)
|
||||
|
||||
[결정 요청]
|
||||
위 옵션 중 선택하거나 다른 방향을 제시해주세요.
|
||||
```
|
||||
|
||||
### Stage 1.8 내부 루프
|
||||
|
||||
```
|
||||
Step 1: 부족/여유 검증 (calculate_fit)
|
||||
Step 2: 재배분 시도 (redistribute)
|
||||
Step 3: 부족 시 → Kei 에스컬레이션 (call_kei_fit_escalation)
|
||||
Step 4: 여유 시 → 보충 콘텐츠 탐색 (analyze_enhancements)
|
||||
├ 관련 팝업에 구조화 콘텐츠(표/비교) 있으면 제안
|
||||
├ 영역 핵심 결론 → 강조 블록 제안
|
||||
└ 텍스트 핵심 키워드 → bold 목록 생성
|
||||
Step 5: Kei 확인 (AI가 제안, Kei가 승인/수정)
|
||||
Step 6: 보충 블록 선택 + fit 재검증
|
||||
├ Kei가 승인한 보충 콘텐츠에 맞는 블록을 catalog에서 선택
|
||||
├ 추가 블록의 높이가 여유 공간에 들어가는지 재검증
|
||||
└ 안 들어가면 축소 (행 수 줄이기) 또는 제외
|
||||
Step 7: 세부 컨테이너 배치 계산
|
||||
├ 메인 컨테이너 안에서 세부 컨테이너 배치 (SVG/텍스트/표/key-msg)
|
||||
├ 각 세부 컨테이너 크기를 콘텐츠에서 동적 계산
|
||||
├ 빈 공간 측정 → 보충 콘텐츠 크기 결정 (표 행 수 등)
|
||||
├ 세부 컨테이너 간 정렬 (좌우 높이 다르면 짧은 쪽 중앙맞춤)
|
||||
└ 최종 overflow 검증
|
||||
Step 8: 확정 출력
|
||||
```
|
||||
|
||||
### 출력
|
||||
|
||||
- 확정된 컨테이너 크기 (재배분 반영)
|
||||
- 각 컨테이너별 꼭지-블록 매핑 (Kei 결정 반영)
|
||||
- 보충 블록 목록 (여유 공간에 추가된 블록)
|
||||
- 강조 블록 목록 (핵심 결론용)
|
||||
- bold 키워드 목록 (Stage 2 프롬프트에 전달)
|
||||
- 콘텐츠 정리 방향 (합치기/축약/팝업 등 — Kei 결정 반영)
|
||||
|
||||
---
|
||||
|
||||
## 태스크 목록
|
||||
|
||||
### V-1: Stage 1.7 수정 — 꼭지별 블록 선택
|
||||
|
||||
- **현재:** `select_reference_block()`이 영역(배경/본심/첨부/결론) 단위로 1개 블록 선택
|
||||
- **변경:** 각 영역 내 꼭지마다 개별적으로 블록 선택
|
||||
- **파일:** `src/block_reference.py`, `src/pipeline.py` (Stage 1.7 호출부)
|
||||
- **완료 기준:** 배경에 꼭지 2개 → 블록 2개 선택. 꼭지 1개면 블록 1개.
|
||||
|
||||
### V-2: Stage 1.8 신규 구현 — 적합성 검증
|
||||
|
||||
- **파일:** `src/fit_verifier.py` (신규)
|
||||
- **내용:**
|
||||
- Step 1~3: 필요 높이 계산 + 공간 배분 + 적합성 검증
|
||||
- 모든 계산은 동적 (font-size, line-height, 블록 padding 등에서 도출)
|
||||
- **완료 기준:** 배경 117px → 부족 감지 → 재배분 시도
|
||||
|
||||
### V-3: Stage 1.8 재배분 로직
|
||||
|
||||
- **파일:** `src/fit_verifier.py`
|
||||
- **내용:**
|
||||
- Step 4: 여유 영역 → 부족 영역으로 공간 재분배
|
||||
- 재배분 후 결과를 Stage 2에 전달
|
||||
- **완료 기준:** 배경 117→151px, 본심 345→311px 자동 재배분
|
||||
|
||||
### V-4: Stage 1.8 Kei 에스컬레이션
|
||||
|
||||
- **파일:** `src/fit_verifier.py`, `src/kei_client.py`
|
||||
- **내용:**
|
||||
- Step 5: 재배분으로도 안 될 때 옵션 생성 + Kei API 호출
|
||||
- Kei 응답 파싱 → 결정에 따라 컨테이너/콘텐츠 조정
|
||||
- **의존성:** V-2, V-3
|
||||
- **완료 기준:** Kei에게 옵션 전달 → Kei 결정 수신 → 파이프라인 계속
|
||||
|
||||
### V-5: Stage 1.5a 리팩터
|
||||
|
||||
- **파일:** `src/space_allocator.py`, `src/pipeline.py`
|
||||
- **내용:**
|
||||
- 기존 weight 고정 배분 로직을 V-2의 콘텐츠 기반 계산으로 교체
|
||||
- 또는 1.5a를 삭제하고 1.8이 컨테이너 계산을 전담
|
||||
- **완료 기준:** weight 하드코딩 제거. 콘텐츠 분량 기반 동적 배분.
|
||||
|
||||
### V-6: 통합 검증 — 완료
|
||||
|
||||
- 전수 하드코딩 스캔: 레이아웃 하드코딩 0개
|
||||
- 통합 테스트: 31/31 통과
|
||||
- step-by-step HTML: step1~step3 생성 + 시각 검토
|
||||
|
||||
---
|
||||
|
||||
## Phase V-2: 콘텐츠 품질 강화 (Step 3 디버깅에서 발견)
|
||||
|
||||
> Step 3 시각 검토에서 발견된 4가지 개선 사항.
|
||||
> 모두 "AI가 분석, Kei가 확인"하는 동일 프로세스.
|
||||
|
||||
### V-7: 주종 관계 블록 내 종속 꼭지 처리
|
||||
|
||||
- **발견:** 배경에 꼭지1(intro)+꼭지2(supporting) → 블록 1개로 합쳤지만, 종속 꼭지를 어떻게 표현할지 미정
|
||||
- **규칙 (동적):**
|
||||
- 종속 꼭지 콘텐츠 분량 확인 (fit_verifier의 텍스트 분량 계산 활용)
|
||||
- 짧으면 (팝업 참조 1~2줄) → 인라인 (주 블록 안에 한 줄)
|
||||
- 길거나 구조 있으면 → 하위 블록 (블록 안의 블록)
|
||||
- 독립성 있으면 → 보조 블록 (나란히)
|
||||
- **판단 기준:** 종속 꼭지의 source_data 길이 + 팝업 참조 여부 + purpose
|
||||
- **Kei 확인:** AI가 "인라인/하위블록/보조블록" 중 제안 → Kei가 확인
|
||||
- **파일:** `src/fit_verifier.py`, `src/block_reference.py`
|
||||
|
||||
### V-8: 여유 공간 콘텐츠 보충
|
||||
|
||||
- **발견:** 본심 컨테이너에 ~53px 여유. 관련 팝업(DX vs BIM 비교표 1135자)이 있는데 활용 안 됨
|
||||
- **규칙 (동적):**
|
||||
- 재배분 후 여유 공간 감지 (shortfall < -threshold)
|
||||
- 해당 영역의 꼭지에 관련 팝업이 있는지 확인 (source_data에 [팝업:] 참조)
|
||||
- 팝업에 구조화 콘텐츠(표, 비교, 목록)가 있으면
|
||||
- 여유 공간에 맞는 블록 추가 선택 (catalog에서)
|
||||
- 팝업 핵심만 요약하여 배치 제안
|
||||
- **Kei 확인:** "53px 여유. 비교표 핵심 3행을 넣을까요?" → Kei가 확인
|
||||
- **파일:** `src/fit_verifier.py`
|
||||
|
||||
### V-9: 영역 핵심 결론 강조 블록
|
||||
|
||||
- **발견:** 배경의 "체계적 정립 필요"가 단순 불릿과 동급. 시각적 강조 없음
|
||||
- **규칙 (동적):**
|
||||
- 각 영역의 꼭지 purpose에서 핵심 결론 추출
|
||||
- purpose=문제제기 → 마지막 문장이 결론적 패턴("~필요", "~해야")이면 강조 블록
|
||||
- purpose=핵심전달 → core_message와 관련된 문장이면 강조 블록
|
||||
- 강조 블록: highlight-strip, callout 내 강조 div 등 catalog에서 선택
|
||||
- **Kei 확인:** "이 문장을 강조 블록으로 처리할까요?" → Kei가 확인
|
||||
- **파일:** `src/fit_verifier.py`, `src/block_reference.py`
|
||||
|
||||
### V-10: 텍스트 핵심 키워드 bold
|
||||
|
||||
- **규칙 (동적):**
|
||||
- source_data에서 핵심 용어 추출 (꼭지 title에 포함된 키워드, **bold** 마크된 텍스트)
|
||||
- 해당 키워드를 HTML 생성 시 bold 처리하도록 Sonnet에게 전달
|
||||
- **파일:** `src/html_generator.py` (Stage 2 프롬프트에 키워드 목록 포함)
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
Phase V-1 (완료):
|
||||
V-1 → V-2 → V-3 → V-4 → V-5 → V-6 ✅
|
||||
|
||||
Phase V-2 (신규):
|
||||
V-7 (종속 꼭지 처리) ← fit_verifier 활용
|
||||
V-8 (여유 공간 보충) ← fit_verifier 확장
|
||||
V-9 (강조 블록) ← purpose 분석
|
||||
V-10 (bold 키워드) ← source_data 분석
|
||||
→ 전체 통합 검증 (step-by-step HTML 재생성)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 하드코딩 전수 점검 결과
|
||||
|
||||
### 반드시 제거 (Phase V에서 동적 계산으로 교체)
|
||||
|
||||
| # | 파일 | 라인 | 값 | 문제 | 교체 방향 |
|
||||
|---|------|------|-----|------|----------|
|
||||
| 1 | `space_allocator.py` | 161 | `474` | body zone 높이 고정 | 슬라이드에서 header+footer+gap 빼고 동적 계산 |
|
||||
| 2 | `space_allocator.py` | 160 | `0.35*0.85` | sidebar 비율+패딩 고정 | container_ratio + 실제 padding에서 계산 |
|
||||
| 3 | `html_generator.py` | 543 | `720-80-66-footer_h-40` | body zone 높이 산술 하드코딩 | containers에서 받은 height_px 사용 |
|
||||
| 4 | `html_generator.py` | 604,921 | `380` | sidebar width fallback | containers에서 받은 width_px 사용 (fallback 제거) |
|
||||
| 5 | `html_generator.py` | 621 | `1200` | footer width fallback | containers에서 받은 width_px 사용 |
|
||||
| 6 | `design_director.py` | 314 | `490` | FRAME_AVAILABLE_HEIGHT 고정 | 슬라이드 스펙에서 동적 계산 |
|
||||
| 7 | `design_director.py` | 328-366 | `budget_px` 다수 | 프리셋별 zone budget 고정 | Stage 1.8에서 콘텐츠 기반 재계산 |
|
||||
| 8 | `space_allocator.py` | 301,583 | `0.85` | 패딩 비율 고정 | `(slide_width - padding*2) / slide_width` 로 계산 |
|
||||
|
||||
### fallback 값 (정상 흐름에서는 도달하면 안 됨)
|
||||
|
||||
| # | 파일 | 라인 | 값 | 비고 |
|
||||
|---|------|------|-----|------|
|
||||
| 9 | `space_allocator.py` | 299 | `490` | zone_budget 기본값 — preset에서 반드시 와야 함 |
|
||||
| 10 | `space_allocator.py` | 304,313 | `0.25` | weight 기본값 — Kei가 반드시 제공해야 함 |
|
||||
| 11 | `pipeline.py` | 960 | `490` | budget_px fallback |
|
||||
|
||||
### 교체 원칙
|
||||
|
||||
- **모든 px 값:** 이전 Stage의 계산 결과(containers, font_hierarchy 등)에서 받아 사용
|
||||
- **비율 값(0.85 등):** 실제 padding/gap에서 역산하여 계산
|
||||
- **fallback:** 정상 흐름에서 절대 도달하지 않도록 이전 Stage에서 반드시 값을 제공
|
||||
- **font-size만 예외:** 디자인 토큰으로 정의된 텍스트 크기는 하드코딩 허용
|
||||
|
||||
---
|
||||
|
||||
## 이전 Phase와의 관계
|
||||
|
||||
- **Phase T:** 파이프라인 Stage 0~5 구조 완성 → Phase V는 Stage 1.7~1.8 개선
|
||||
- **Phase T' (TP-1~6):** 시각 품질 개선 (프롬프트, 후처리) → Phase V 적용 후 재검증 필요
|
||||
- **Phase T' 후처리:** sidebar width:100%, overflow 제거, bold 변환, 폰트 캡 → 유지
|
||||
273
PHASE-W-PLAN.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Phase W — 실행 계획 (Task별 방향 + 방법)
|
||||
|
||||
> 작성일: 2026-04-03
|
||||
> 상위 문서: PHASE-W.md
|
||||
|
||||
---
|
||||
|
||||
## W-1: space_allocator — weight 비율 초기 배정
|
||||
|
||||
### W-1-1: zone_budget을 weight 비율로 계산
|
||||
|
||||
**현재:** `zone_budget = zone_info.get("budget_px")` → 프리셋 490px 고정
|
||||
**방향:** `zone_budget = total_available × zone_weight_sum / all_weight_sum`
|
||||
**파일:** `src/space_allocator.py` — `calculate_container_specs()` 내부
|
||||
**방법:**
|
||||
- 전체 가용 높이 = slide_height - padding×2 - gap×2 - header
|
||||
- 각 zone의 weight 합을 구함 (body zone = 배경+본심 weight, sidebar = 첨부 weight 등)
|
||||
- 전체 weight 합 대비 비율로 zone_budget 계산
|
||||
- 이전에 구현했던 코드를 다시 적용 (173113 run에서 동작 확인됨)
|
||||
**검증:** weight 합 1.0일 때 모든 컨테이너 높이 합 출력하여 전체 가용의 95% 이상인지 확인
|
||||
|
||||
### W-1-2: 전체 공간 100% 사용
|
||||
|
||||
**방향:** W-1-1이 해결되면 자동으로 해결
|
||||
**검증:** `stage_1_5a_context.json`에서 모든 컨테이너 height_px 합산 ≥ 전체 가용 × 0.95
|
||||
|
||||
### W-1-3: 시선 흐름 배치 좌표
|
||||
|
||||
**현재:** `_calc_coords()`가 배경→상단좌, 본심→중앙좌, 첨부→우측, 결론→하단으로 배치
|
||||
**방향:** 현재 코드 유지 (이미 올바름)
|
||||
**검증:** `stage_1_8_filled.html`에서 배경이 상단, 본심이 중앙, 첨부가 우측, 결론이 하단에 위치
|
||||
|
||||
---
|
||||
|
||||
## W-2: block_assembler — 공통 조립 함수 완성
|
||||
|
||||
### W-2-1: font_hierarchy override
|
||||
|
||||
**현재:** `_override_font()` 함수가 블록 CSS의 font-size를 font_hierarchy로 조정
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 첨부 영역의 font-size가 sidebar 값(9-11px)을 초과하지 않음
|
||||
|
||||
### W-2-2: 팝업 링크 인접 배치
|
||||
|
||||
**현재:** `_parse_structured_text()`에서 `[팝업: 제목]`을 이전 불릿 텍스트에 `[제목→]`으로 붙임
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 `[혼용 대표 사례→]`가 별도 줄이 아니라 텍스트 옆에 붙어있음
|
||||
|
||||
### W-2-3: sidebar 상단 라벨
|
||||
|
||||
**현재:** `_assemble_card_numbered()`에서 `topic.title`을 라벨로 추가
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML의 첨부 영역 상단에 꼭지 title이 보임
|
||||
|
||||
### W-2-4: 카드 indent 파싱
|
||||
|
||||
**현재:** `_assemble_card_numbered()`에서 indent=0만 카드 제목, indent=1은 설명
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** 첨부에 건설산업/BIM/DX 3개 카드가 분리되고, 하위 설명이 각 카드 안에 있음
|
||||
|
||||
### W-2-5: 카드 불릿 간격
|
||||
|
||||
**현재:** CSS override에서 `white-space: pre-line → normal` 변환
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** 첨부 카드 내 불릿과 불릿 사이에 빈 줄(엔터)이 없음
|
||||
|
||||
### W-2-6: 실제 이미지 사용
|
||||
|
||||
**현재:** `has_real_image` 분기로 실제 이미지 있으면 SVG 레이아웃, 없으면 텍스트만
|
||||
**방향:** 수정 필요 — 현재 `_assemble_svg_layout()`이 `design_reference_html`에서 SVG를 추출. 이걸 `ctx.slide_images`의 실제 이미지(base64)로 교체
|
||||
**파일:** `src/block_assembler.py` — `_assemble_svg_layout()`
|
||||
**방법:**
|
||||
- `ctx.slide_images`에서 해당 이미지의 base64 데이터를 가져옴
|
||||
- `<img src="data:image/png;base64,{b64}" />` 형태로 삽입
|
||||
- SVG viewBox/gradient 하드코딩 대신 실제 이미지 사용
|
||||
**검증:** filled HTML에 `<img src="data:image/png;base64,` 패턴이 있고, SVG 태그가 없음
|
||||
|
||||
### W-2-7: filled/assembled 통일
|
||||
|
||||
**현재:** `_gen_stage_1_8_filled()`가 `assemble_slide_html()` 호출
|
||||
**방향:** assembled(stage_2_code_assembled)도 같은 함수 호출하도록 `assemble_stage2.py` 수정 또는 제거
|
||||
**검증:** filled와 assembled가 같은 HTML 구조를 가짐 (diff로 확인)
|
||||
|
||||
### W-2-8: 팝업 마크다운 테이블 변환
|
||||
|
||||
**현재:** `mdx_normalizer.py`의 `_extract_popup()`에서 `_convert_md_table_to_html()` 호출
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** `stage_0_context.json`의 popups에서 "DX와 BIM의 구분" 팝업에 `<table>` 태그가 있고 `|` 마크다운이 없음
|
||||
|
||||
---
|
||||
|
||||
## W-3: filled → Selenium 측정 연결
|
||||
|
||||
### W-3-1: .slide 클래스
|
||||
|
||||
**현재:** `assemble_slide_html()`의 최외곽 div에 `class="slide"` 있음
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 `class="slide"` 존재 확인
|
||||
|
||||
### W-3-2: area-* 클래스
|
||||
|
||||
**현재:** 각 역할 컨테이너에 `area-body`, `area-sidebar`, `area-footer` 클래스 있음
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** filled HTML에서 `area-body`, `area-sidebar`, `area-footer` 존재 확인
|
||||
|
||||
### W-3-3: Selenium 측정 정상 동작
|
||||
|
||||
**현재:** 173113 run에서 `{'error': 'slide not found'}` 발생 (당시 .slide 클래스 없었음)
|
||||
**방향:** W-3-1, W-3-2가 해결되면 자동 해결
|
||||
**방법:** filled HTML을 `measure_rendered_heights()`에 넣고 정상 결과 반환 확인
|
||||
**검증:** 반환값에 `zones.sidebar.scrollHeight`, `zones.body.scrollHeight` 등이 있고 `error` 키가 없음
|
||||
|
||||
### W-3-4: 시각화 순서 (before → filled → after)
|
||||
|
||||
**현재:** `step_visualizer.py`의 dispatch에서 blocks → filled → fit_before → fit_after 순서
|
||||
**방향:** before(빈 컨테이너 크기) → filled(블록+텍스트 채운 상태) → after(조정된 크기) 순서
|
||||
**파일:** `src/step_visualizer.py` — `generate_step_html()`
|
||||
**방법:**
|
||||
- `stage_1_8` dispatch 순서를 `fit_before → filled → fit_after`로 변경
|
||||
- fit_before는 빈 컨테이너 크기만 보여줌 (부족/여유 판단 없이)
|
||||
- filled는 블록+텍스트 채운 상태
|
||||
- fit_after는 조정 후 컨테이너 크기
|
||||
**검증:** steps 폴더에 3개 파일이 순서대로 있고, before의 크기 → filled의 넘침 → after의 변경이 시각적으로 확인 가능
|
||||
|
||||
---
|
||||
|
||||
## W-4: 측정 결과 기반 조정 판단
|
||||
|
||||
### W-4-1: sidebar overflow → 확장
|
||||
|
||||
**현재:** pipeline.py Stage 1.8에 sidebar 확장 코드 있음
|
||||
**방향:** 현재 코드 유지 (Selenium 측정이 동작하면 자동으로 발동)
|
||||
**검증:** sidebar scrollHeight > clientHeight일 때 `stage_1_8_context.json`의 첨부 height_px가 scrollHeight 이상으로 증가
|
||||
|
||||
### W-4-2: body overflow → 재배분
|
||||
|
||||
**현재:** `redistribute()` 함수가 body zone 내에서 배경↔본심 재배분
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** body overflow 시 배경 또는 본심의 height_px가 변경됨
|
||||
|
||||
### W-4-3: 재배분 후에도 overflow → Kei 에스컬레이션
|
||||
|
||||
**현재:** `needs_escalation=True`일 때 `call_kei_fit_escalation()` 호출
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** `enhancement_result.kei_decisions`에 Kei 응답이 저장됨
|
||||
|
||||
### W-4-4: Kei trim/popup 결정 실제 적용
|
||||
|
||||
**현재:** Kei 결정을 받지만 실제 반영 안 됨
|
||||
**방향:** 새로 구현
|
||||
**파일:** `src/pipeline.py` Stage 1.8 내부 + 새 함수
|
||||
|
||||
**trim 구현 방법:**
|
||||
- Kei가 `{"action": "trim", "detail": "150자로 축약"}`을 반환하면
|
||||
- 해당 role의 topic structured_text를 **Kei/Sonnet에게 축약 요청** (AI 판단 — 어떤 문장이 덜 중요한지는 AI만 알 수 있음)
|
||||
- 프롬프트: "다음 텍스트를 N자 이내로 축약하라. 불릿 구조 유지. 핵심 85% 보존."
|
||||
- 축약된 텍스트로 structured_text 교체
|
||||
- 하드코딩 없음 — 어떤 콘텐츠든 AI가 판단
|
||||
- **도구:** anthropic SDK (이미 있음), Kei API /api/direct
|
||||
|
||||
**popup 구현 방법:**
|
||||
- Kei가 `{"action": "popup", "detail": "상세 정의를 팝업으로"}`를 반환하면
|
||||
- 해당 role의 structured_text를 **Kei/Sonnet에게 분리 요청** ("요약 vs 상세" 판단)
|
||||
- 프롬프트: "다음 콘텐츠를 슬라이드 요약(2-3줄)과 팝업 상세로 분리하라."
|
||||
- 요약은 structured_text에, 상세는 별도 팝업 HTML로 저장
|
||||
- 슬라이드에는 요약 + `[상세보기→]` 링크
|
||||
- 하드코딩 없음 — 어떤 콘텐츠든 AI가 요약/상세를 판단
|
||||
- **도구:** anthropic SDK, 팝업 HTML 템플릿 (pipeline.py Stage 5에 이미 있음)
|
||||
|
||||
**검증:** trim 후 structured_text 길이가 줄어들고, popup 후 팝업 HTML 파일이 생성됨
|
||||
|
||||
### W-4-5: Kei restructure → 컨테이너 직접 변경
|
||||
|
||||
**현재:** `redistribute()` 재실행만 됨
|
||||
**방향:** Kei가 "본심에 363px 보장"하면 직접 height_px 변경
|
||||
**파일:** `src/pipeline.py` Stage 1.8 내부
|
||||
**방법:**
|
||||
- Kei 결정에서 구체적 px 값을 파싱 (정규식으로 숫자 추출)
|
||||
- 해당 role의 height_px를 직접 설정
|
||||
- 다른 role에서 부족분을 **weight 역비례**로 차감 (중요도 낮은 곳에서 더 많이)
|
||||
- 최소 높이(60px) 보장
|
||||
- 총합이 전체 가용 초과하지 않도록 검증
|
||||
- 하드코딩 없음 — 순수 산술, 어떤 role이든 동작
|
||||
- **도구:** Python 산술 (외부 라이브러리 불필요)
|
||||
|
||||
**검증:** restructure 후 해당 role의 height_px가 Kei가 지정한 값으로 변경되고, 총합이 전체 가용 이하
|
||||
|
||||
### W-4-6: after 컨테이너 저장
|
||||
|
||||
**현재:** `stage_1_8_context.json`에 containers 저장됨
|
||||
**방향:** 현재 코드 유지 (W-4-1~5의 결과가 containers에 반영되면 자동 저장)
|
||||
**검증:** `stage_1_8_context.json`의 containers가 before와 다름
|
||||
|
||||
### W-4-7: Kei 보강 검토 호출
|
||||
|
||||
**현재:** `call_kei_enhancement_review()` 함수 있고 pipeline.py에서 호출
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** `enhancement_result`에 Kei 보강 검토 결과가 저장됨 (approve/modify/reject)
|
||||
|
||||
---
|
||||
|
||||
## W-5: after 기반 최종 조립 + 검증
|
||||
|
||||
### W-5-1: stage_2가 after 컨테이너 사용
|
||||
|
||||
**현재:** stage_2_context.json의 containers == stage_1_8_context.json의 containers (확인됨)
|
||||
**방향:** 현재 코드 유지
|
||||
**검증:** 두 JSON의 containers 비교 — 일치
|
||||
|
||||
### W-5-2: overflow 없음 확인
|
||||
|
||||
**현재:** Stage 4에서 Selenium 측정. Vision 모델 ID 404 에러
|
||||
**방향:** Vision 모델 ID를 `claude-sonnet-4-20250514`로 변경 (vision 지원, 비용 효율)
|
||||
**파일:** `src/kei_client.py` — 3곳
|
||||
**방법:** 모델 ID 문자열 교체
|
||||
**검증:** Stage 4에서 모든 zone의 excess_px ≤ 0
|
||||
|
||||
### W-5-3: 텍스트 85% 보존 검증
|
||||
|
||||
**현재:** 검증 로직 없음
|
||||
**방향:** 새로 구현
|
||||
**파일:** `src/pipeline.py` Stage 4 또는 Stage 5
|
||||
**방법:**
|
||||
- final.html에서 HTML 태그 제거하여 순수 텍스트 추출 (Python stdlib `html.parser`)
|
||||
- 각 role의 structured_text와 문자 3-gram 겹침 비교
|
||||
- 85% 이상이면 PASS
|
||||
- 하드코딩 없음 — 문자열 비교만, 어떤 콘텐츠든 동작
|
||||
- **도구:** Python stdlib만 (html.parser, re). 외부 NLP 불필요
|
||||
|
||||
**검증:** 검증 함수가 각 role별 보존율을 반환하고, 모든 role이 85% 이상
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
W-1-1 → W-1-2 (자동)
|
||||
W-1 + W-2 → W-3 (filled 생성 + 측정)
|
||||
W-3 → W-4 (측정 결과로 판단)
|
||||
W-4 → W-5 (after 기반 최종)
|
||||
|
||||
W-2 내부: 1~8 독립적으로 병행 가능
|
||||
W-4 내부: 1→2→3→4/5 순차, 6/7 독립
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 필요 도구/라이브러리
|
||||
|
||||
| 도구 | 용도 | 상태 |
|
||||
|------|------|------|
|
||||
| Selenium + Chrome headless | filled 측정 (W-3) | ✅ 설치됨, 동작 확인 |
|
||||
| anthropic SDK | Kei trim/popup (W-4-4), Vision (W-5-2) | ✅ 설치됨 |
|
||||
| httpx | Kei API 호출 | ✅ 설치됨 |
|
||||
| Kei API (localhost:8000) | 에스컬레이션, 보강 검토 | ✅ 동작 확인 |
|
||||
| Python stdlib (html.parser, re) | 텍스트 보존 검증 (W-5-3) | ✅ 내장 |
|
||||
| Jinja2 | 블록 템플릿 렌더링 | ✅ 설치됨 |
|
||||
|
||||
**추가 설치 필요 없음.**
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
```
|
||||
Phase 1: W-1 (weight 비율) — 기반
|
||||
Phase 2: W-2 (공통 조립 함수) — W-1과 병행 가능
|
||||
Phase 3: W-3 (Selenium 연결) — W-1 + W-2 필요
|
||||
Phase 4: W-4 (판단 로직) — W-3 필요
|
||||
Phase 5: W-5 (최종 검증) — W-4 필요
|
||||
|
||||
각 Phase 완료 후 파이프라인 실행하여 검증.
|
||||
```
|
||||
162
PHASE-W.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Phase W — Stage 1.8 before→filled→after 파이프라인 완성
|
||||
|
||||
> 작성일: 2026-04-03
|
||||
> 근거: Phase V 이후 코드 조립/연결 과정에서 발견된 문제 8건
|
||||
> 선행: Phase V (적합성 검증 + Kei 에스컬레이션)
|
||||
|
||||
---
|
||||
|
||||
## 배경
|
||||
|
||||
Phase V에서 설계한 before→filled→after 프로세스가 실제로 동작하지 않음.
|
||||
코드는 부분적으로 존재하지만 연결이 안 되어 있고, 각 단계가 따로 놀고 있음.
|
||||
|
||||
### 발견된 문제 8건
|
||||
|
||||
1. **before→filled→after 파이프라인 연결 안 됨** — filled Selenium 측정 실패, 판단 미동작
|
||||
2. **space_allocator 불안정** — weight 비율로 전체 공간 100% 사용해야 하는데 안 됨
|
||||
3. **공통 조립 함수 불완전** — filled/assembled/stage_2가 각각 다른 코드 사용
|
||||
4. **Kei 에스컬레이션 결정 미반영** — trim/popup/restructure 결정이 컨테이너에 반영 안 됨
|
||||
5. **본심 OVERFLOW 미해소** — 재배분이 충분하지 않음
|
||||
6. **filled 시각화 품질** — font_hierarchy, 카드 간격, 팝업 링크 등 미반영
|
||||
7. **이미지 SVG 샘플 사용** — 실제 이미지(samples/images/) 대신 하드코딩 샘플
|
||||
8. **Sonnet/코드 조립 경로 분리** — 두 경로가 각각 존재. Phase W 완성 후 판단
|
||||
|
||||
---
|
||||
|
||||
## 핵심 프로세스
|
||||
|
||||
```
|
||||
before: weight 비중대로 전체 가용 공간 100% 배정 (초기 컨테이너)
|
||||
↓
|
||||
filled: before 컨테이너에 블록+텍스트 채움 (block_assembler 공통 함수)
|
||||
↓
|
||||
측정: Selenium으로 실제 넘침 확인
|
||||
↓
|
||||
판단:
|
||||
1. 다른 블록으로 바꿀 수 있나? (font_hierarchy 유지)
|
||||
2. sidebar 넘침 → 세로 확장 (예외)
|
||||
3. body 넘침 → 배경↔본심 재배분
|
||||
4. 그래도 안 되면 → Kei 에스컬레이션 (trim/popup/restructure)
|
||||
5. Kei 결정을 컨테이너에 실제 반영
|
||||
6. 텍스트 85% 보존 우선
|
||||
↓
|
||||
after: 조정된 컨테이너
|
||||
↓
|
||||
assembled = after 기준으로 block_assembler 공통 함수로 최종 조립
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 절대 원칙
|
||||
|
||||
1. **하드코딩 금지** — 어떤 MDX가 들어와도 동일하게 동작
|
||||
2. **결과물 HTML 직접 수정 안 함** — 파이프라인 프로세스를 수정
|
||||
3. **이미지는 실제 파일만** — samples/images/에서 가져옴. SVG 샘플 금지
|
||||
4. **텍스트 85% 보존** — 공간 부족 시 컨테이너를 늘림. 그래도 안 되면 Kei가 팝업 분리
|
||||
5. **weight = 초기 배정 비율 + 충돌 시 우선순위** — 최종 높이가 아님
|
||||
6. **배치는 시선 흐름** — 좌→우, 상→하 (배경 상단좌, 본심 중앙좌, 첨부 우측, 결론 하단)
|
||||
7. **font_hierarchy 준수** — 핵심 14px, 본심 12px, 배경 10-12px, 첨부 9-11px
|
||||
8. **조립 로직은 한 곳** — block_assembler.py. filled/assembled/stage_2 모두 이 함수 사용
|
||||
9. **임의로 코드 되돌리지 않음** — 이해 안 되면 물어보고, 제안하고, 허락받고 실행
|
||||
|
||||
---
|
||||
|
||||
## W-1: space_allocator — weight 비율 초기 배정
|
||||
|
||||
> 파일: `src/space_allocator.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-1-1 | `calculate_container_specs()`에서 zone_budget을 `전체가용 × zone_weight_sum / all_weight_sum`으로 계산하는 코드가 있음 |
|
||||
| W-1-2 | weight 합 1.0일 때 모든 컨테이너 높이 합 ≥ 전체 가용 공간의 95% |
|
||||
| W-1-3 | 배경이 본심보다 위에, 첨부가 우측에, 결론이 하단에 배치되는 좌표 계산이 `_calc_coords()`에서 동작함 |
|
||||
|
||||
---
|
||||
|
||||
## W-2: block_assembler — 공통 조립 함수 완성
|
||||
|
||||
> 파일: `src/block_assembler.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-2-1 | `assemble_role_html()`이 블록 CSS의 font-size를 font_hierarchy 값으로 override한 HTML을 반환함 |
|
||||
| W-2-2 | 팝업 `[팝업: 제목]` 마커가 별도 줄이 아니라 이전 불릿 텍스트 옆에 `[제목→]` 형태로 붙어있음 |
|
||||
| W-2-3 | sidebar 역할일 때 상단에 꼭지 title 라벨이 있음 |
|
||||
| W-2-4 | card-numbered에서 주불릿(indent=0)만 카드 제목, 하위불릿(indent=1)은 카드 설명으로 분리됨 |
|
||||
| W-2-5 | 카드 내 불릿 사이에 빈 줄(엔터) 없음 — white-space: normal 적용 |
|
||||
| W-2-6 | 이미지는 `ctx.slide_images`의 실제 파일(base64)만 사용, design_reference_html의 SVG 샘플 미사용 |
|
||||
| W-2-7 | `_gen_stage_1_8_filled()`와 assembled가 모두 `assemble_slide_html()`을 호출함 |
|
||||
| W-2-8 | Stage 0에서 팝업 콘텐츠의 마크다운 테이블이 HTML `<table>`로 변환되어 `stage_0_context.json`의 popups에 저장됨 |
|
||||
|
||||
---
|
||||
|
||||
## W-3: filled → Selenium 측정 연결
|
||||
|
||||
> 파일: `src/block_assembler.py`, `src/pipeline.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-3-1 | `assemble_slide_html()` 출력 HTML에 `.slide` 클래스가 최외곽 div에 있음 |
|
||||
| W-3-2 | 각 역할 컨테이너에 `.area-body`, `.area-sidebar`, `.area-footer` 클래스가 있음 |
|
||||
| W-3-3 | filled HTML을 `measure_rendered_heights()`에 넣으면 `{'error': 'slide not found'}`가 아닌 정상 측정 결과가 반환됨 |
|
||||
| W-3-4 | steps 폴더에 `stage_1_8_fit_before.html` → `stage_1_8_filled.html` → `stage_1_8_fit_after.html` 순서로 생성되고, before의 컨테이너 크기 → filled의 넘침 → after의 조정된 크기가 시각적으로 확인 가능 |
|
||||
|
||||
---
|
||||
|
||||
## W-4: 측정 결과 기반 조정 판단
|
||||
|
||||
> 파일: `src/pipeline.py`, `src/fit_verifier.py`, `src/kei_client.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-4-1 | pipeline.py Stage 1.8에서 filled 측정 후 sidebar overflow 감지 시 해당 role의 컨테이너 height_px가 scrollHeight로 확장됨 |
|
||||
| W-4-2 | body overflow 감지 시 `redistribute()`가 호출되어 배경↔본심 간 높이가 재배분됨 |
|
||||
| W-4-3 | 재배분 후에도 overflow이면 `call_kei_fit_escalation()`이 호출됨 |
|
||||
| W-4-4 | Kei가 trim 결정 시 해당 role의 structured_text가 축약되거나, popup 결정 시 해당 콘텐츠가 팝업으로 분리됨 |
|
||||
| W-4-5 | Kei가 restructure 결정 시 해당 role의 컨테이너 height_px가 변경됨 |
|
||||
| W-4-6 | after의 컨테이너 크기가 stage_1_8_context.json에 저장됨 |
|
||||
| W-4-7 | Stage 1.8에서 `call_kei_enhancement_review()`가 호출되고, Kei 응답이 `enhancement_result`에 저장됨 |
|
||||
|
||||
---
|
||||
|
||||
## W-5: after 기반 최종 조립 + 검증
|
||||
|
||||
> 파일: `src/pipeline.py`, `src/block_assembler.py`
|
||||
|
||||
| Task | 완료 기준 |
|
||||
|------|-----------|
|
||||
| W-5-1 | stage_2의 `generated_html`이 after 컨테이너 크기를 기준으로 생성됨 (stage_2_context.json의 containers == stage_1_8_context.json의 containers) |
|
||||
| W-5-2 | stage_3_rendered.html에서 overflow가 없음 (Stage 4 측정에서 모든 zone excess ≤ 0) |
|
||||
| W-5-3 | final.html에 모든 역할의 structured_text가 85% 이상 포함됨 |
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
W-1 (weight 초기 배정)
|
||||
↓
|
||||
W-2 (공통 조립 함수) ← 독립적으로 병행 가능
|
||||
↓
|
||||
W-3 (filled → Selenium 측정) ← W-1 + W-2 필요
|
||||
↓
|
||||
W-4 (측정 기반 판단) ← W-3 필요
|
||||
↓
|
||||
W-5 (after 기반 최종) ← W-4 필요
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 미결정 사항
|
||||
|
||||
- **Sonnet vs 코드 조립**: Stage 2를 Sonnet으로 유지할지 block_assembler 코드 조립으로 교체할지는 W-1~W-5 완성 후 결과를 보고 판단.
|
||||
|
||||
---
|
||||
|
||||
## 입력 데이터
|
||||
|
||||
- MDX 파일: `samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx`
|
||||
- 이미지: `samples/images/` (DX1.png 등)
|
||||
- 테스트: `scripts/run_from_stage1b.py` — Stage 1B 데이터 고정 실행
|
||||
- 좋았던 Kei 데이터: `data/runs/20260403_133746` (또는 `20260403_163508`)
|
||||
39
PHASE-X.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Phase X: 콘텐츠 기반 레이아웃 판단 프로세스
|
||||
|
||||
## 배경
|
||||
|
||||
현재 파이프라인은 Kei의 role 태그(`reference`, `flow` 등)로 레이아웃 preset을 **먼저 고정**한 뒤, 그 안에서만 크기를 조정한다. 콘텐츠의 양이나 특성과 무관하게 구조가 결정되므로, 다른 MDX가 들어오면 커버되지 않는다.
|
||||
|
||||
## 현재 프로세스 (문제)
|
||||
|
||||
```
|
||||
Kei role 태그 → select_preset() → 레이아웃 고정 → 그 안에서 weight 배분
|
||||
```
|
||||
|
||||
- `reference` 있으면 → `sidebar-right` (무조건)
|
||||
- 콘텐츠 양/특성 기반 판단 없음
|
||||
- 레이아웃이 콘텐츠에 맞는지 검증 없음
|
||||
|
||||
## 목표 프로세스
|
||||
|
||||
```
|
||||
1. BEFORE: 100% 공간을 weight 비율로 세로 배정 (레이아웃 판단 없음)
|
||||
2. FILLED: 콘텐츠 채움
|
||||
3. 판단1: 측정 → 레이아웃 결정 ("이 역할은 옆으로 빼는 게 낫다" 등)
|
||||
4. 판단2: 결정된 레이아웃에서 크기 재배분
|
||||
5. AFTER: 최종 레이아웃 + 크기
|
||||
```
|
||||
|
||||
- 레이아웃 구조(body/sidebar 등)가 preset이 아니라 **측정 후 판단의 결과**
|
||||
- 어떤 MDX가 와도 콘텐츠에 맞는 최적 레이아웃이 동적으로 결정됨
|
||||
|
||||
## 관련 코드
|
||||
|
||||
- `src/design_director.py`: `LAYOUT_PRESETS`, `select_preset()`
|
||||
- `src/pipeline.py`: Stage 1.5a에서 preset 선택
|
||||
- `src/kei_client.py`: Stage 1A에서 role 태그 부여
|
||||
- `src/space_allocator.py`: zone 기반 컨테이너 배분
|
||||
|
||||
## 상태
|
||||
|
||||
Phase W (before→filled→after 파이프라인) 완료 후 착수.
|
||||
63
PLAN.md
@@ -335,6 +335,64 @@ P2-E (누락기능) ── 병렬 │
|
||||
|
||||
---
|
||||
|
||||
## Phase V: 콘텐츠-컨테이너 적합성 검증 + Kei 에스컬레이션 (상세: PHASE-V.md)
|
||||
|
||||
> 근거: Phase T' 디버깅에서 발견된 구조적 결함. 콘텐츠 분량과 무관한 컨테이너 고정 배분, 적합성 검증 부재, 판단 주체 부재.
|
||||
|
||||
### V-1: Stage 1.7 수정 — 꼭지별 블록 선택
|
||||
- **현재:** 영역당 블록 1개 → **변경:** 꼭지당 블록 1개
|
||||
- **파일:** `src/block_reference.py`, `src/pipeline.py`
|
||||
- **의존성:** 없음
|
||||
- **완료 기준:** 배경 꼭지2개 → 블록2개 선택
|
||||
|
||||
### V-2: Stage 1.8 신규 — 적합성 검증
|
||||
- **파일:** `src/fit_verifier.py` (신규)
|
||||
- **내용:** 꼭지별 필요 높이 계산 → 컨테이너 배분 → 들어가는지 검증
|
||||
- **의존성:** V-1
|
||||
- **완료 기준:** 배경 117px 부족 자동 감지
|
||||
|
||||
### V-3: Stage 1.8 재배분 로직
|
||||
- **파일:** `src/fit_verifier.py`
|
||||
- **내용:** 여유 영역 → 부족 영역으로 공간 재분배
|
||||
- **의존성:** V-2
|
||||
- **완료 기준:** 배경 117→151px, 본심 345→311px 자동 재배분
|
||||
|
||||
### V-4: Stage 1.8 Kei 에스컬레이션
|
||||
- **파일:** `src/fit_verifier.py`, `src/kei_client.py`
|
||||
- **내용:** 재배분으로도 안 될 때 AI가 옵션 생성 → Kei에게 결정 요청
|
||||
- **의존성:** V-2, V-3
|
||||
- **완료 기준:** Kei에게 옵션 전달 → 결정 수신 → 파이프라인 계속
|
||||
|
||||
### V-5: Stage 1.5a 리팩터
|
||||
- **파일:** `src/space_allocator.py`, `src/pipeline.py`
|
||||
- **내용:** weight 고정 배분 → 콘텐츠 분량 기반 동적 배분으로 교체
|
||||
- **의존성:** V-2
|
||||
- **완료 기준:** weight 하드코딩 제거
|
||||
|
||||
### V-6: 통합 검증 — ✅ 완료 (31/31 통과, 하드코딩 0개)
|
||||
|
||||
### V-7: 주종 관계 블록 내 종속 꼭지 처리
|
||||
- **파일:** `src/fit_verifier.py`, `src/block_reference.py`
|
||||
- **내용:** 종속 꼭지 분량에 따라 인라인/하위블록/보조블록 자동 판단 → Kei 확인
|
||||
- **완료 기준:** 배경 꼭지2(supporting)가 꼭지1 블록 안에 인라인
|
||||
|
||||
### V-8: 여유 공간 콘텐츠 보충
|
||||
- **파일:** `src/fit_verifier.py`
|
||||
- **내용:** 재배분 후 여유 감지 → 관련 팝업 구조화 콘텐츠 → 요약 블록 추가 제안 → Kei 확인
|
||||
- **완료 기준:** 본심 여유 공간에 비교표 핵심 요약 배치
|
||||
|
||||
### V-9: 영역 핵심 결론 강조 블록
|
||||
- **파일:** `src/fit_verifier.py`, `src/block_reference.py`
|
||||
- **내용:** purpose에서 핵심 결론 추출 → 강조 블록 제안 → Kei 확인
|
||||
- **완료 기준:** 배경 "체계적 정립 필요"가 강조 블록으로 표시
|
||||
|
||||
### V-10: 텍스트 핵심 키워드 bold
|
||||
- **파일:** `src/html_generator.py`
|
||||
- **내용:** source_data 핵심 키워드 추출 → Stage 2 프롬프트에 전달 → Sonnet이 bold 처리
|
||||
- **완료 기준:** 본심 "상위개념", "기술융합" 등 핵심 키워드 bold
|
||||
|
||||
---
|
||||
|
||||
## 의존 관계
|
||||
|
||||
```
|
||||
@@ -350,6 +408,11 @@ Phase 4 (UI):
|
||||
Phase 5 (블록 라이브러리):
|
||||
DA-17(Figma추출) → DA-18(카테고리재편) → DA-19(변형확장) → DA-20(FAISS)
|
||||
DA-18 → DA-21(renderer경로) → DA-22(catalog경로)
|
||||
|
||||
Phase V (적합성 검증):
|
||||
V-1(꼭지별블록) → V-2(적합성검증) → V-3(재배분) → V-4(Kei에스컬레이션)
|
||||
V-2 → V-5(1.5a리팩터)
|
||||
V-1~V-5 → V-6(통합검증)
|
||||
```
|
||||
|
||||
- Phase 1~2, 5: AI 없이 진행 가능
|
||||
|
||||
10
PROGRESS.md
@@ -1,11 +1,12 @@
|
||||
# Design Agent — 진행 상황
|
||||
|
||||
## 현재 상태 요약 (2026-03-28 기준)
|
||||
## 현재 상태 요약 (2026-04-01 기준)
|
||||
|
||||
| 상태 | 내용 |
|
||||
|------|------|
|
||||
| **완료** | Phase 1~5 기반 구축, Phase I~O 개선, Step B 제거, Phase P 실행 완료 |
|
||||
| **다음** | Phase Q — 제약 기반 블록 선택 + 글자수 예산 시스템 (설계 확정, 실행 대기) |
|
||||
| **완료** | Phase 1~5 기반, A~Q 개선, R(실패), S(설계), **T 전체 완료** |
|
||||
| **Phase T 결과** | 11 Step 완료 (T-0~T-10). 통합 테스트 31/31 통과. 신규 5파일, 수정 4파일+yaml |
|
||||
| **다음** | 실제 MDX + Kei API + Sonnet API로 end-to-end 실행. Phase ZZ는 파이프라인 안정화 이후 |
|
||||
|
||||
---
|
||||
|
||||
@@ -136,7 +137,8 @@
|
||||
| **Q** | **제약 기반 블록 선택 + 글자수 예산** | **코드 완료** | Q-1~Q-8 구현 + fill_candidates 복원. 블록 선택 개선 확인 |
|
||||
| **R** | **하이브리드 블록 시스템 (variant 추가)** | **실패** | 기존 블록 선택 구조 위에 패치만 추가. P=Q=R 동일 구조. |
|
||||
| **R'** | **접근 C: 블록 CSS 참고 + AI 구조 결정** | **설계** | 방향만 확정. Kei API HTML 생성 실패 확인. |
|
||||
| **S** | **검증 기반 확정 — Claude HTML 생성 + 검증된 프롬프트 규칙** | **설계 확정** | 영역별 검증 합격. Claude Sonnet 확정. 실행 대기. |
|
||||
| **S** | **검증 기반 확정 — Claude HTML 생성 + 검증된 프롬프트 규칙** | **설계 확정** | 영역별 검증 합격. Claude Sonnet 확정. |
|
||||
| **T** | **폰트 위계 + 파이프라인 기반 정비 + 디자인 레퍼런스** | **완료** | 11 Step 완료. 통합 테스트 31/31 통과. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
326
README.md
@@ -4,112 +4,153 @@
|
||||
|
||||
## 개요
|
||||
|
||||
텍스트/MDX 콘텐츠를 입력하면 Kei 실장(Opus)이 정보 구조와 비중을 판단하고, 그 비중대로 컨테이너를 확정하고, 블록을 선택하고, 텍스트를 편집하여 슬라이드를 생성한다.
|
||||
|
||||
**핵심 특징:**
|
||||
- 콘텐츠마다 비중이 동적으로 변한다 (본심 60% / 배경 20% 등 — Kei가 매번 판단)
|
||||
- 비중이 컨테이너 px를 확정 → 블록과 텍스트가 컨테이너에 맞춰진다
|
||||
- Kei API 필수. fallback 없음. 성공할 때까지 무한 재시도.
|
||||
텍스트/MDX 콘텐츠를 입력하면:
|
||||
1. Kei 실장(Opus)이 정보 구조와 비중을 판단하고
|
||||
2. 코드가 컨테이너 크기를 계산하고
|
||||
3. 블록을 선택하고
|
||||
4. 콘텐츠-컨테이너 적합성을 검증하고
|
||||
5. AI(Sonnet)가 블록 디자인을 참고하여 HTML을 생성하고
|
||||
6. 코드가 슬라이드 프레임에 조립하고
|
||||
7. 측정+비전 모델로 검증합니다
|
||||
|
||||
---
|
||||
|
||||
## 파이프라인 (6단계)
|
||||
## 파이프라인 (10단계)
|
||||
|
||||
```
|
||||
텍스트 입력
|
||||
MDX 원본
|
||||
↓
|
||||
[1단계] Kei 실장 — 꼭지 추출 + 비중 판단 (Kei API / Opus)
|
||||
[Stage 0] MDX 정규화 (코드)
|
||||
↓
|
||||
[1.5단계] Kei 실장 — 컨셉 구체화 (Kei API / Opus)
|
||||
[Stage 1A] 꼭지 추출 + 영역 배정 (Kei API / Opus)
|
||||
↓
|
||||
[컨테이너 계산] 비중 → px 확정 (코드, 결정론적)
|
||||
[Stage 1B] 컨셉 구체화 (Kei API / Opus)
|
||||
↓
|
||||
[2단계] 블록 선택 (Phase Q) (코드: 결정론적 + Kei 1회)
|
||||
│ relation_type → 카테고리 매핑 (코드)
|
||||
│ → 컨테이너 제약 필터링 (코드)
|
||||
│ → 글자수 예산 계산 (코드)
|
||||
│ → Kei에게 2-3개 후보 제시 → 1개 선택 (AI)
|
||||
[Stage 1.5a] 컨테이너 초기 계산 (코드)
|
||||
↓
|
||||
[3단계] Kei 편집자 — 텍스트 정리 (예산 포함) (Kei API / Opus)
|
||||
[Stage 1.7] 블록 선택 (코드)
|
||||
↓
|
||||
[4단계] 디자인 실무자 — CSS 조정 + HTML 조립 (Sonnet + Jinja2)
|
||||
[Stage 1.8] 적합성 검증 + 재배분 + 보강 (코드 + Kei 에스컬레이션)
|
||||
↓
|
||||
[검증] Selenium 렌더링 1회 → 수학적 조정 (코드, AI 없음)
|
||||
[Stage 1.5b] 디자인 예산 재계산 (코드)
|
||||
↓
|
||||
[품질 게이트] 비전 모델 평가 → 미달 시 교정/차단 (Opus 멀티모달)
|
||||
[Stage 2] HTML 생성 (영역별 개별 호출) (Claude Sonnet)
|
||||
↓
|
||||
완성 슬라이드 HTML
|
||||
[Stage 3] 렌더링 조립 + 후처리 (코드)
|
||||
↓
|
||||
[Stage 4] 측정 + 품질 검증 (Selenium + Opus Vision)
|
||||
↓
|
||||
검증 통과 시 → final.html 저장 + 팝업 분리 (파일 출력)
|
||||
```
|
||||
|
||||
### 단계별 상세
|
||||
|
||||
| 단계 | 담당 | AI/코드 | 역할 |
|
||||
|------|------|---------|------|
|
||||
| **1A** | Kei 실장 | Kei API (Opus) | 핵심 메시지, 꼭지 추출, page_structure(비중), purpose 부여 |
|
||||
| **1B** | Kei 실장 | Kei API (Opus) | relation_type, expression_hint, source_data |
|
||||
| **컨테이너** | 코드 | 결정론적 | Kei 비중 → 역할별 컨테이너 px 확정, height_cost 제약 |
|
||||
| **블록 선택** | 코드 + Kei | 결정론적 + AI 1회 | relation_type → 카테고리 매핑 → 컨테이너 제약 필터 → Kei 최종 선택 (Phase Q) |
|
||||
| **예산 계산** | 코드 | 결정론적 | 컨테이너 px → 글자수 예산 사전 계산 → AI 편집 시 하드 제약 (Phase Q) |
|
||||
| **3** | Kei 편집자 | Kei API (Opus) | 텍스트 편집 (글자수 예산 준수, 원본 보존) |
|
||||
| **4** | 디자인 실무자 | Sonnet + Jinja2 | CSS 변수 override + HTML 조립 |
|
||||
| **검증** | 코드 | Selenium 1회 | 검증 렌더링 — overflow 확인 (예산으로 사전 방지됨) |
|
||||
| **품질 게이트** | 비전 모델 | Opus 멀티모달 | 스크린샷 기반 시각 품질 평가 (VASCAR식) — 미달 시 출력 차단 (Phase Q) |
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
- **비중 → 컨테이너 → 블록 → 콘텐츠** 순서. 비중이 모든 것을 결정
|
||||
- **Kei API 필수.** fallback 없음. 기본값 없음. 성공할 때까지 무한 재시도
|
||||
- **계산 먼저, AI 판단 나중에, 렌더링은 검증만** (Phase Q 핵심 원칙)
|
||||
- **블록 선택은 결정론적 필터링 → Kei 최종 선택.** AI에게 불가능한 선택지를 주지 않는다
|
||||
- **콘텐츠가 구조를 결정, 블록 CSS는 참고만** (Phase R'). AI가 HTML 구조 직접 생성
|
||||
- **글자수 예산은 하드 제약.** 컨테이너 px에서 수학적으로 도출, AI 편집 전에 전달
|
||||
- **overflow 상태에서 출력 금지.** 비전 모델 품질 게이트 통과 필수
|
||||
- **블록 = 시각 패턴(구조), 크기가 아님.** 같은 블록이 컨테이너에 따라 항목수/폰트/패딩 변동
|
||||
- **텍스트가 기준.** 디자인이 텍스트에 맞춤. CSS로 사후 자르기 금지
|
||||
※ Stage 4 이후의 파일 저장은 별도 Stage가 아닌 후처리입니다.
|
||||
|
||||
---
|
||||
|
||||
## 컨테이너 시스템 (Phase O)
|
||||
## 단계별 상세
|
||||
|
||||
Kei가 판단한 비중이 시각적 레이아웃에 정확히 반영되는 구조.
|
||||
### Stage 0: MDX 정규화
|
||||
|
||||
```
|
||||
슬라이드 1280×720px
|
||||
├── header: 제목 (~60px 고정)
|
||||
├── body (65%): 490px
|
||||
│ ├── 배경 컨테이너: 490 × 20% = 98px ← Kei 비중으로 확정
|
||||
│ │ └── 문제제기 + 근거사례 (compact 블록만)
|
||||
│ └── 본심 컨테이너: 490 × 60% = 294px ← Kei 비중으로 확정
|
||||
│ └── 핵심전달 (large/xlarge 블록 가능)
|
||||
├── sidebar (35%): 490px
|
||||
│ └── 첨부 컨테이너: 490px 전체
|
||||
│ └── 용어 정의 (여유 있게)
|
||||
└── footer: 결론 (~60px 고정)
|
||||
└── banner-gradient (핵심 메시지 한 줄)
|
||||
```
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 원본 MDX에서 JSX/frontmatter를 제거하고, 섹션/팝업/이미지/테이블로 분리 |
|
||||
| **적용기술** | 코드 (`normalize_mdx_content()`) |
|
||||
| **인풋** | 원본 MDX 문자열 |
|
||||
| **아웃풋** | `normalized` — clean_text, title, sections[], popups[], images[], tables[] |
|
||||
| **연계** | → Stage 1A가 clean_text를 Kei에게 전달 |
|
||||
|
||||
- 컨테이너 높이(px)가 블록의 height_cost를 제약
|
||||
- 컨테이너 크기에서 항목수/글자수/폰트/패딩이 자동 계산
|
||||
- 편집자에게 컨테이너 제약이 전달되어 텍스트 분량이 맞춰짐
|
||||
### Stage 1A: 꼭지 추출 + 영역 배정
|
||||
|
||||
---
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 콘텐츠에서 핵심 파트(꼭지)를 식별하고, 슬라이드의 어떤 영역(배경/본심/첨부/결론)에 배치할지 결정 |
|
||||
| **적용기술** | Kei API (`classify_content()`) |
|
||||
| **인풋** | normalized.clean_text |
|
||||
| **아웃풋** | `topics[]` (id, title, purpose, layer, relation_type, expression_hint), `page_structure` (role별 topic_ids, weight) |
|
||||
| **연계** | → Stage 1B가 각 꼭지를 구체화 |
|
||||
|
||||
## 개선 이력
|
||||
### Stage 1B: 컨셉 구체화
|
||||
|
||||
| Phase | 내용 | 상태 |
|
||||
|-------|------|------|
|
||||
| **A~D** | 슬라이드 품질 핵심 (디자인 조정, overflow 방지, 이미지 처리) | 완료 |
|
||||
| **G** | Kei API 통신 정상화 (SSE 스트리밍, Sonnet fallback 제거, GPU 분리) | 완료 |
|
||||
| **H** | 스토리라인 설계 기반 전환 (core_message, purpose, source_hint) | 완료 |
|
||||
| **I** | 전수 정합성 복구 + 넘침 처리 패러다임 전환 (14건) | 완료 |
|
||||
| **J** | 블록 선택 권한 구조 재정의 + 최종 검토 Kei 전환 | 완료 |
|
||||
| **K** | communicative role 기반 시각적 위계 + purpose별 분량 제약 | 완료 |
|
||||
| **K-1** | 파이프라인 스텝별 중간 산출물 로컬 저장 (`data/runs/`) | 완료 |
|
||||
| **L** | 렌더링 측정 에이전트 (Selenium headless) + 피드백 루프 | 완료 |
|
||||
| **M** | Kei 비중 시스템 (page_structure weight) + 원본 보존 강화 | 완료 |
|
||||
| **N** | 4대 핵심 문제 해결 — catalog 개선, fallback 전면 제거, topic_id 버그 수정, 무한 재시도 | 완료 |
|
||||
| **O** | 컨테이너 기반 레이아웃 시스템 — 비중→px→블록제약→콘텐츠제약 | 완료 |
|
||||
| **P** | 블록 재구성 + 실제 렌더링 비교 선택 — 후보 3개 렌더링→Kei 스크린샷 판단 | **계획 확정** |
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 각 꼭지에 실제 원본 텍스트(source_data)와 요약(summary)을 매핑 |
|
||||
| **적용기술** | Kei API (`refine_concepts()`) |
|
||||
| **인풋** | topics + clean_text |
|
||||
| **아웃풋** | `topics` 업데이트 — source_data, summary 추가 |
|
||||
| **연계** | → Stage 1.5a가 텍스트 양을 기반으로 컨테이너 비율 계산 |
|
||||
|
||||
### Stage 1.5a: 컨테이너 초기 계산
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 폰트 위계 확정 + 슬라이드 내 영역별 컨테이너 크기(px) 계산 + 프리셋 선택 |
|
||||
| **적용기술** | 코드 (`calculate_font_hierarchy()`, `calculate_dynamic_ratio()`, `calculate_container_specs()`) |
|
||||
| **인풋** | topics, page_structure (weight), preset |
|
||||
| **아웃풋** | `font_hierarchy` (key_msg/core/bg/sidebar px), `container_ratio` (71:29 등), `containers` (role별 width_px, height_px), `preset` |
|
||||
| **연계** | → Stage 1.7이 컨테이너 크기를 보고 블록 선택 |
|
||||
|
||||
### Stage 1.7: 블록 선택
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 각 꼭지의 relation_type + expression_hint + 컨테이너 크기로 적합한 블록 결정. 같은 영역 꼭지들의 layer가 다르면 주종관계 판단 (블록 1개로 합침) |
|
||||
| **적용기술** | 코드 (`select_and_generate_references()`) — catalog.yaml 기반 결정론적 매칭 |
|
||||
| **인풋** | topics, containers, page_structure |
|
||||
| **아웃풋** | `references` — role별 block_id, variant, design_reference_html, topic_id, is_hierarchical, supporting_topic_ids |
|
||||
| **연계** | → Stage 1.8이 선택된 블록+콘텐츠가 컨테이너에 맞는지 검증 |
|
||||
|
||||
### Stage 1.8: 적합성 검증 + 재배분 + 보강 + 서브 컨테이너
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 콘텐츠가 컨테이너에 들어가는지 검증 → 안 맞으면 재배분 → 여전히 안 되면 Kei 에스컬레이션 → 여유 공간에 보충 콘텐츠 → 서브 컨테이너 배치 계산 |
|
||||
| **적용기술** | 코드 (`calculate_fit()`, `redistribute()`, `analyze_enhancements()`, `apply_enhancements()`, `calculate_sub_layout()`) + Kei API (에스컬레이션 시 `call_kei_fit_escalation()`) |
|
||||
| **인풋** | topics, containers, references, font_hierarchy, normalized, core_message |
|
||||
| **아웃풋** | `containers` (재배분된 height_px), `fit_result` (role별 fit_status, redistribution), `enhancement_result` (V-7 subordinate_treatments, V-8 supplement_blocks, V-9 emphasis_blocks, V-10 bold_keywords, V-4 kei_decisions), `sub_layouts` (role별 서브 컨테이너 name/width/height, table_rows) |
|
||||
| **내부 흐름** | Step 1: 필요 높이 계산 → Step 2: 재배분 → Step 3: Kei 에스컬레이션 → Step 4-5: 보강 분석+적용 → Step 6: fit 재검증 → Step 7: 서브 컨테이너 배치 → Step 8: 확정 |
|
||||
| **연계** | → Stage 1.5b가 재배분된 크기로 디자인 예산 재계산, → Stage 2가 sub_layouts + enhancements를 프롬프트에 반영 |
|
||||
|
||||
### Stage 1.5b: 디자인 예산 재계산
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 재배분된 컨테이너 크기 + 선택된 블록 schema 기준으로 영역별 가용 공간 계산 |
|
||||
| **적용기술** | 코드 (`calculate_design_budget()`) |
|
||||
| **인풋** | containers (재배분 후), references (블록 schema) |
|
||||
| **아웃풋** | `containers` 업데이트 — design_budget (available_height_px, available_width_px, fits) |
|
||||
| **연계** | → Stage 2가 design_budgets를 프롬프트에 포함 |
|
||||
|
||||
### Stage 2: HTML 생성 (영역별 개별 호출)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | page_structure에 존재하는 각 역할(배경/본심/첨부/결론)의 HTML을 **영역별 개별 Sonnet 호출**로 생성. 블록 디자인을 참고하되 콘텐츠가 구조를 결정 (Phase R' 방식) |
|
||||
| **적용기술** | Claude Sonnet API — 영역당 1회 호출 (`build_area_prompt()` → `_call_claude()`) |
|
||||
| **인풋** | raw_content, topics, containers, font_hierarchy, references (design_reference_html), sub_layouts (서브 컨테이너 치수), enhancements (V-4~V-10 지시), design_budgets |
|
||||
| **호출 흐름** | Sonnet(배경) → bg_html, Sonnet(본심) → core_html, Sonnet(첨부) → sidebar_html, Sonnet(결론) → footer_html. 해당 역할에 꼭지가 없으면 스킵. body_html = bg_html + spacer + core_html |
|
||||
| **아웃풋** | `generated_html` — body_html, sidebar_html, footer_html |
|
||||
| **프롬프트에 포함되는 것** | 서브 컨테이너 레이아웃 제약, 디자인 레퍼런스 HTML (블록 CSS 참고), Kei 에스컬레이션 결정, 종속 꼭지 처리 지시, 보충 블록 지시, 강조 문장, bold 키워드, 폰트/컨테이너 크기 제약 |
|
||||
| **연계** | → Stage 3이 영역별 HTML을 슬라이드 프레임에 배치 |
|
||||
|
||||
### Stage 3: 렌더링 조립 + 후처리
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | 생성된 HTML 조각을 CSS Grid 슬라이드 프레임에 삽입 + 후처리 (폰트 캡핑, overflow 제거, sidebar width 조정, bold 변환) |
|
||||
| **적용기술** | 코드 (`render_slide_from_html()`) |
|
||||
| **인풋** | generated_html, preset (grid_areas, grid_columns), font_hierarchy, container_ratio |
|
||||
| **아웃풋** | `rendered_html` → `final.html` 파일 저장 |
|
||||
| **연계** | → Stage 4가 렌더링 결과를 측정+검증 |
|
||||
|
||||
### Stage 4: 품질 검증
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **목적** | Selenium으로 실제 브라우저 렌더링 후 overflow 측정 + Opus Vision으로 시각적 품질 평가 |
|
||||
| **적용기술** | Selenium (`measure_rendered_heights()`) + Claude Opus Vision (`vision_quality_gate()`) |
|
||||
| **인풋** | rendered_html |
|
||||
| **아웃풋** | `measurement` (zone별 clientHeight, scrollHeight, overflow, excess_px), `quality_score` |
|
||||
| **연계** | 파이프라인 완료. overflow 시 경고 포함하여 진행 |
|
||||
|
||||
---
|
||||
|
||||
@@ -117,28 +158,55 @@ Kei가 판단한 비중이 시각적 레이아웃에 정확히 반영되는 구
|
||||
|
||||
파이프라인 실행마다 `data/runs/{timestamp}/`에 단계별 결과가 저장된다.
|
||||
|
||||
| 파일 | 단계 | 내용 |
|
||||
|------|------|------|
|
||||
| `step1_analysis.json` | 1A | 꼭지 추출, page_structure(비중), core_message |
|
||||
| `step1b_concepts.json` | 1B | relation_type, expression_hint, source_data |
|
||||
| `step1c_containers.json` | O-1 | 역할별 컨테이너 스펙 (height_px, width_px, max_height_cost) |
|
||||
| `step2_layout.json` | 2 | 블록 배치 (area, type, purpose, reason) |
|
||||
| `step2c_block_specs.json` | O-3 | 블록별 스펙 (_max_items, _max_chars, _font_size_px) |
|
||||
| `step3_filled_blocks.json` | 3 | 텍스트 편집 결과 (data, char_count) |
|
||||
| `step4_css_adjustment.json` | 4 | CSS 변수 override |
|
||||
| `step4_rendered.html` | 4 | 렌더링된 HTML |
|
||||
| `step4_measurement_round*.json` | Phase L | Selenium 측정 (scrollHeight, overflow) |
|
||||
| `step5_review_round*.json` | 5 | Kei 검수 결과 |
|
||||
| `final.html` | 최종 | 완성 슬라이드 |
|
||||
| `report.html` | 리포트 | 전 단계 시각화 리포트 |
|
||||
### JSON Context (Stage별 누적 상태)
|
||||
| 파일 | Stage | 내용 |
|
||||
|------|-------|------|
|
||||
| `stage_0_context.json` | 0 | normalized (섹션, 팝업, 이미지) |
|
||||
| `stage_1a_context.json` | 1A | topics, page_structure |
|
||||
| `stage_1b_context.json` | 1B | topics (source_data 추가) |
|
||||
| `stage_1_5a_context.json` | 1.5a | font_hierarchy, containers, ratio |
|
||||
| `stage_1_7_context.json` | 1.7 | references (블록 선택 결과) |
|
||||
| `stage_1_8_context.json` | 1.8 | fit_result, enhancements, sub_layouts |
|
||||
| `stage_1_5b_context.json` | 1.5b | containers (design_budget 추가) |
|
||||
| `stage_2_context.json` | 2 | generated_html |
|
||||
| `stage_3_context.json` | 3 | (rendered_html은 final.html로 별도 저장) |
|
||||
| `stage_4_context.json` | 4 | measurement, quality_score |
|
||||
| `final_context.json` | 최종 | 전체 context |
|
||||
|
||||
리포트 생성: `python scripts/generate_run_report.py`
|
||||
### HTML 시각화 (`steps/` 폴더)
|
||||
| 파일 | Stage | 내용 |
|
||||
|------|-------|------|
|
||||
| `stage_0.html` | 0 | 섹션/팝업/이미지 목록 |
|
||||
| `stage_1a.html` | 1A | 꼭지 테이블 (purpose, layer, 영역) |
|
||||
| `stage_1b.html` | 1B | 꼭지 + source_data + summary |
|
||||
| `stage_1_5a.html` | 1.5a | 빈 컨테이너 (1280×720) |
|
||||
| `stage_1_5a_content.html` | 1.5a | 컨테이너에 콘텐츠 배치 |
|
||||
| `stage_1_5b.html` | 1.5b | 디자인 예산 (available height/width) |
|
||||
| `stage_1_7.html` | 1.7 | 블록 선택 표시 |
|
||||
| `stage_1_8_fit_before.html` | 1.8 | 적합성 (재배분 전) |
|
||||
| `stage_1_8_fit_after.html` | 1.8 | 재배분 후 + 보강 |
|
||||
| `stage_1_8_blocks.html` | 1.8 | SLOT 구조 + 블록 디자인 + 주종관계 (1280×720) |
|
||||
| `stage_2.html` | 2 | 영역별 Sonnet 출력을 실제 렌더링 (역할별 개별 확인) |
|
||||
| `stage_3.html` | 3 | 영역을 합쳐 슬라이드 프레임에 배치한 결과 (1280×720 실제 렌더링) |
|
||||
| `stage_4.html` | 4 | 측정 결과 + 품질 점수 |
|
||||
|
||||
---
|
||||
|
||||
## 핵심 원칙
|
||||
|
||||
1. **콘텐츠가 구조를 결정** — 블록 CSS는 참고만. AI가 콘텐츠 전달 의도를 보고 HTML 구조 결정 (Phase R')
|
||||
2. **하드코딩 금지** — font-size 외 모든 수치는 동적 계산. 어떤 MDX가 들어와도 동일하게 동작
|
||||
3. **스크롤 절대 금지** — overflow:auto/scroll 어떤 영역에서도 불허
|
||||
4. **Kei API 필수** — fallback 없음. 성공할 때까지 무한 재시도
|
||||
5. **AI가 옵션 생성, Kei가 결정** — 공간 부족 시 하드코딩 대응이 아니라 Kei 판단 요청
|
||||
6. **계산 먼저, AI 판단 나중에, 렌더링은 검증만**
|
||||
7. **overflow 상태에서 출력 금지** — Vision 모델 품질 게이트 통과 필수
|
||||
|
||||
---
|
||||
|
||||
## 블록 라이브러리 (38개)
|
||||
|
||||
6개 카테고리, 38개 블록. 각 블록은 `catalog.yaml`에 용도(when), 금지(not_for), purpose_fit이 정의됨.
|
||||
6개 카테고리, 38개 블록. 각 블록은 `catalog.yaml`에 용도(when), 금지(not_for), purpose_fit, schema(슬롯 정의)가 있음.
|
||||
|
||||
| 카테고리 | 개수 | 용도 |
|
||||
|---------|------|------|
|
||||
@@ -149,8 +217,6 @@ Kei가 판단한 비중이 시각적 레이아웃에 정확히 반영되는 구
|
||||
| **emphasis** | 10 | 강조, 인용, 결론, 불릿 |
|
||||
| **media** | 5 | 이미지/사진 |
|
||||
|
||||
FAISS 블록 검색: bge-m3 1024차원 임베딩 → 꼭지별 관련 블록 후보 추출
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
@@ -159,16 +225,16 @@ FAISS 블록 검색: bge-m3 1024차원 임베딩 → 꼭지별 관련 블록 후
|
||||
|------|------|
|
||||
| 서버 | FastAPI + uvicorn (포트 8001) |
|
||||
| AI (Kei 실장/편집자) | Kei API → Opus (localhost:8000) |
|
||||
| AI (디자인 팀장/실무자) | Anthropic API → Sonnet |
|
||||
| AI (최종 검수) | Anthropic API → Opus (멀티모달) |
|
||||
| AI (HTML 생성) | Anthropic API → Claude Sonnet |
|
||||
| AI (품질 검증) | Anthropic API → Claude Opus Vision |
|
||||
| 블록 검색 | FAISS + bge-m3 |
|
||||
| 템플릿 | Jinja2 |
|
||||
| 템플릿 | Jinja2 (블록 디자인 레퍼런스용) |
|
||||
| 렌더링 | CSS Grid + 디자인 토큰 (1280×720) |
|
||||
| 렌더링 측정 | Selenium headless Chrome |
|
||||
| SVG 시각화 | svg_calculator.py (N개 동적 배치) |
|
||||
| 이미지 | Pillow (크기 측정) + base64 인라인 |
|
||||
| 폰트 | Pretendard Variable |
|
||||
| 공간 계산 | space_allocator.py (결정론적) |
|
||||
| 공간 계산 | space_allocator.py + fit_verifier.py (결정론적) |
|
||||
|
||||
---
|
||||
|
||||
@@ -202,42 +268,21 @@ python -m uvicorn src.main:app --host 127.0.0.1 --port 8001 --reload
|
||||
|
||||
---
|
||||
|
||||
## 프로젝트 구조
|
||||
## 개선 이력
|
||||
|
||||
```
|
||||
design_agent/
|
||||
├── src/
|
||||
│ ├── main.py FastAPI 서버 (포트 8001)
|
||||
│ ├── config.py 설정 (pydantic-settings)
|
||||
│ ├── pipeline.py 파이프라인 오케스트레이션 (6단계)
|
||||
│ ├── kei_client.py Kei API 클라이언트 (1A, 1B, 검수, 넘침 판단)
|
||||
│ ├── design_director.py 2단계: 프리셋 + Kei 블록 확정 + Sonnet zone 배치
|
||||
│ ├── content_editor.py 3단계: Kei API 텍스트 편집
|
||||
│ ├── renderer.py 4단계: HTML 조립 (컨테이너 grid + Jinja2)
|
||||
│ ├── space_allocator.py 컨테이너 스펙 계산 + 블록 스펙 확정 (Phase O)
|
||||
│ ├── slide_measurer.py Selenium 렌더링 측정 + 스크린샷 (Phase L/N)
|
||||
│ ├── block_search.py FAISS 블록 검색
|
||||
│ ├── svg_calculator.py SVG 좌표 계산 (N개 동적 배치)
|
||||
│ ├── image_utils.py 이미지 크기 측정 + base64 삽입
|
||||
│ └── sse_utils.py SSE 스트리밍 유틸
|
||||
│
|
||||
├── templates/
|
||||
│ ├── slide-base.html 슬라이드 베이스
|
||||
│ ├── catalog.yaml 블록 카탈로그 (38개, when/not_for/purpose_fit)
|
||||
│ └── blocks/ 블록 라이브러리 (6 카테고리)
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── build_block_index.py FAISS 인덱스 빌드
|
||||
│ └── generate_run_report.py 실행 리포트 생성
|
||||
│
|
||||
├── static/ 프론트엔드 (index.html, CSS)
|
||||
├── data/ 로컬 데이터 (runs/, FAISS 인덱스)
|
||||
├── docs/ 기술 조사, Figma 분석
|
||||
│
|
||||
├── IMPROVEMENT.md 개선 계획 총괄 (Phase A~O)
|
||||
├── IMPROVEMENT-PHASE-*.md 각 Phase 상세
|
||||
└── PROGRESS.md 진행 상황 추적
|
||||
```
|
||||
| Phase | 내용 | 상태 |
|
||||
|-------|------|------|
|
||||
| A~D | 슬라이드 품질 핵심 | 완료 |
|
||||
| G~N | Kei API, 스토리라인, 정합성, 블록 선택, 비중, 측정 | 완료 |
|
||||
| O | 컨테이너 기반 레이아웃 | 완료 |
|
||||
| P | 다후보 렌더링 비교 | 완료 (20/100점 → 방향 전환) |
|
||||
| Q | 제약 기반 블록 선택 | 완료 |
|
||||
| R | 하이브리드 블록 (실패 — P=Q=R 동일 구조) | 실패 |
|
||||
| R' | 블록 CSS 참고 + AI 구조 결정 | 설계 확정 |
|
||||
| S | 검증 합격 프롬프트 + Claude HTML 생성 | 설계 확정 |
|
||||
| T | 11-Stage 파이프라인 + 디자인 레퍼런스 | 완료 (31/31 통과) |
|
||||
| V | 적합성 검증 + Kei 에스컬레이션 + 서브 컨테이너 | 완료 |
|
||||
| W | Stage 2 출력 품질 수정 (6건) | 진행 중 |
|
||||
|
||||
---
|
||||
|
||||
@@ -251,7 +296,8 @@ Kei Persona Agent (localhost:8000)
|
||||
|
||||
Design Agent (localhost:8001, 이 프로젝트)
|
||||
├── 슬라이드 생성 전용
|
||||
├── Kei API로 실장(1단계) + 편집자(3단계) + 블록 확정(2단계) 호출
|
||||
├── 최종 검수(5단계)는 Opus 직접 호출 (멀티모달 스크린샷)
|
||||
├── Kei API로 꼭지 추출(1A) + 컨셉 구체화(1B) + 에스컬레이션(1.8) 호출
|
||||
├── Sonnet으로 HTML 생성(Stage 2)
|
||||
├── Opus Vision으로 품질 검증(Stage 4)
|
||||
└── 두 프로젝트는 독립. 코드 공유 없음. API 연동만.
|
||||
```
|
||||
|
||||
BIN
samples/images/01.png
Normal file
|
After Width: | Height: | Size: 894 KiB |
BIN
samples/images/02.png
Normal file
|
After Width: | Height: | Size: 753 KiB |
BIN
samples/images/03.png
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
samples/images/04.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
samples/images/05.png
Normal file
|
After Width: | Height: | Size: 916 KiB |
BIN
samples/images/06.png
Normal file
|
After Width: | Height: | Size: 423 KiB |
BIN
samples/images/07.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
samples/images/08.png
Normal file
|
After Width: | Height: | Size: 747 KiB |
BIN
samples/images/09.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
samples/images/DX1.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
samples/images/pyramid.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
samples/images/structure.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
samples/images/그림3.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
samples/images/그림4.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
samples/images/그림5.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
samples/images/그림6.png
Normal file
|
After Width: | Height: | Size: 435 KiB |
BIN
samples/images/그림7.png
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
samples/images/그림8.png
Normal file
|
After Width: | Height: | Size: 307 KiB |
BIN
samples/images/그림9.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
120
samples/mdx/01. 건설산업 DX의 올바른 이해(0127).mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: 건설산업 DX의 올바른 이해
|
||||
sidebar:
|
||||
order: 00
|
||||
---
|
||||
|
||||
* **용어의 혼용**
|
||||
|
||||
* 건설산업의 디지털 전환 논의에서 DX(Digital Transformation)와 BIM(Building Information Modeling)이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음
|
||||
* 이로인해 BIM기술의 도입을 DX의 완성으로 오인하거나, DX를 BIM 기술 도입 수준으로 한정하는 인식 확산
|
||||
<details>
|
||||
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>혼용 대표 사례</summary>
|
||||
|
||||
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
|
||||
* **[스마트 건설 활성화 방안(2022.07)]**
|
||||
* 추진과제 : 건설산업 디지털화
|
||||
* 실행과제 : BIM 전면 도입, BIM 전문인력 양성
|
||||
* **[제7차 건설기술진흥 기본계획(2023.12)]**
|
||||
* 추진방향 : 디지털 전환을 통한 스마트 건설 확산
|
||||
* 추진과제 : BIM 도입으로 건설산업 디지털화
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
* 건설산업의 DX를 올바르게 이해하기 위해 각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요
|
||||
|
||||
<br/>
|
||||
---
|
||||
|
||||
|
||||
## 1. 용어 정의
|
||||
|
||||
<br/>
|
||||
|
||||
* **건설산업**
|
||||
* 다양한 시설물을 각 산업마다의 광범위한 기술을 통합 및 융합하여 만들어내는 종합산업
|
||||
* 목적 시설물의 품질 욕구를 충족시키면서 최단기간내에 최소 비용으로 편리하고 안전하며 우수한 성능의 시설물 완성을 목표로 함
|
||||
|
||||
<br/>
|
||||
|
||||
* **BIM(Building Information Modeling) : 디지털 전환을 위한 핵심 기술**
|
||||
* 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
|
||||
* 건설 정보와 절차를 표준화된 방식으로 연계하고 디지털 협업이 가능하도록 하는 핵심 인프라 기술
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#999',
|
||||
marginTop: '5px',
|
||||
lineHeight: '1.4',
|
||||
paddingLeft: '0px' }}>
|
||||
*건설산업 BIM 기본지침, 국토교통부, 2020*
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
* **DX(Digital Transformation) : 산업 패러다임의 변화**
|
||||
* 디지털 기술을 기반으로 산업 전반의 업무방식과 가치 창출 구조를 전환하는 과정 및 결과
|
||||
* 단순한 기술 도입이 아닌, 고객 가치와 의사결정 방식의 근본적인 변화로 산업의 새로운 방향을 정립하는 것을 의미함
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#999',
|
||||
marginTop: '5px',
|
||||
lineHeight: '1.4',
|
||||
paddingLeft: '0px' }}>
|
||||
*Digital Transformation, IBM Institute for Business Value, 2011 / What is Digital Transformation?, Agile Elephant, 2015*
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 2. 용어간 상호관계
|
||||
|
||||
* DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 프로세스를 혁신하는 상위개념
|
||||
* 건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 기술융합을 통해서만 실현 또는 구현 가능
|
||||
* GIS의 역할 : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공
|
||||
* BIM의 역할 : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공
|
||||

|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#999',
|
||||
marginTop: '5px',
|
||||
lineHeight: '1.4',
|
||||
paddingLeft: '0px' }}>
|
||||
*[그림 1] DX와 핵심기술간 상호관계*
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>DX와 BIM의 구분</summary>
|
||||
|
||||
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
|
||||
| DX | 구분 | BIM |
|
||||
| :--- | :---: | ---: |
|
||||
| **BIM << DX**<br/>(Engineering + Management 통합) | **범위** | **Only 3D**<br/>(형상 구현 중심) |
|
||||
| **제작 및 운영**(상용 + 전용 40~80개)<br/>[Rhino, Sketchup, Blender..] + [EG-BIM 등] | **S/W** | **모델 제작용 상용 SW**<br/>[Revit, Civil 3D, Navisworks, Autocad] |
|
||||
| **근본적 문제의식을 통한 개선** | **프로세스** | **기존 2D 설계 방식 유지** |
|
||||
| **공학 정보 및 콘텐츠 연계에 집중**<br/>**도면, 수량, 시공계획 등 일식** | **성과품** | **3D 모델 중심**<br/>**기존 성과품 유지** |
|
||||
| **설계/시공 생산성 혁신**(개념의 재정립) | **활용** | **3D 모델에 의한 일반적 이해 향상** |
|
||||
| **전 생애주기 활용 시스템** | **확장성** | **(설계/시공/운영) 분야별 단절** |
|
||||
| **구체화(복잡) - 적극적/구체적 실현 방안** | **수행 개념** | **단순화(오류) - 수동적/집단적 동질화** |
|
||||
| **적극적, 주체적인 기술 접목/융합** | **CIVIL + IT** | **소극적, 상용 기술에 의존** |
|
||||
| **자체 수행 능력 - 지속가능성 확보** | **주체** | **S/W 제작사 판매 정책에 의존** |
|
||||
| **차별화 및 경쟁력 확보, 해외 진출** | **발주처** | **평준화, 국내 중심** |
|
||||
| **IT + CIVIL ENG 220명 운영 + 기술 개발** | **설계사** | **소규모 BIM팀 운영 + 단순교육에 집중** |
|
||||
| **분야 확장 모델 및 시스템** | **시공사** | **국내 토목 소극적/해외 토목증가** |
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
:::note[핵심 요약]
|
||||
* BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 **가장 기초가 되는 일부분**이다
|
||||
:::
|
||||
|
||||
42
samples/mdx/02. DX의 시행 목표 및 기대효과.mdx
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: DX의 시행 목표 및 기대효과
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
import DxEffect from '../../../../components/dx.astro';
|
||||
|
||||
|
||||
## 1. DX의 궁극적 목표
|
||||
|
||||
- **안전과 품질**
|
||||
- 시설물의 요구 성능을 설계-시공-운영 전 과정에서 **디지털로 검증**하여 **안전성 확보**
|
||||
- Copy & Paste로 하향 평준화된 성과물의 **하자 최소화**로 **고품질 성과물 제공**
|
||||
<br/>
|
||||
- **생산성 향상**
|
||||
- Analogue 기반 업무를 Digital 기반 프로세스로 전환하여 **업무 속도·정확성·일관성 향상**
|
||||
- 건설 비용 및 유지관리비 절감, 건설 기간 단축, 인력투입 최소화를 통해 **부가가치 제고**
|
||||
<br/>
|
||||
- **소통과 신뢰**
|
||||
- 성과품과 Solution을 통한 협업 강화로 **의사소통 효율 및 운영·유지관리**의 **편리성 증진**
|
||||
- 3D 모델 및 데이터 기반 검증을 통한 **오류 최소화 및 Claim 예방**으로 **신뢰성 확보**
|
||||

|
||||
<br/>
|
||||
|
||||
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과
|
||||
<br/>
|
||||
### 2.1 업무 수행 과정(Process)의 변화
|
||||
- **생산 방식**: 수작업 의존의 반복 업무에서 벗어나, **SW를 활용한 체계화된 방식**으로 전환
|
||||
- **인지·검토**: 2D 도면 해석 중심에서 **3D 모델 기반의 직관적 인지·검토 체계**로 전환
|
||||
- **협업 구조**: 개별 문서 중심 협업에서 **데이터 통합 기반의 정보 공유·관리 협업 환경**으로 전환
|
||||
- **검증·대응**: 사후 대응 중심의 문제 처리에서 **사전 검증 중심의 예방적 업무 방식**으로 전환
|
||||
<br/>
|
||||
### 2.2 DX 시행 주체별 기대효과
|
||||
|
||||
<DxEffect />
|
||||
<br/>
|
||||
<br/>
|
||||
:::note[핵심 요약]
|
||||
* 고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다.
|
||||
:::
|
||||
<br/>
|
||||
89
samples/mdx/03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: DX 실행 체계 구축 방안
|
||||
sidebar:
|
||||
order: 02
|
||||
---
|
||||
|
||||
## 1. DX 시행을 위한 필수 요건
|
||||
|
||||
<br/>
|
||||
|
||||
* **기술(디지털)**
|
||||
* **Digital 기술(S/W, H/W)과 업무 Process의 통합**
|
||||
* 기존 업무 프로세스에 다양한 디지털 기술을 접목하여 업무 수행
|
||||
* 프로젝트 전반에 걸친 업무 프로세스의 연결 및 조율
|
||||
* **분야별 전문 지식(설계, 시공, 유지관리 등) 보유**
|
||||
* 건설 전 단계에 대한 근본적인 이해와 지식 및 경험
|
||||
* 최신 토목 기술 트랜드 및 표준 기준 등에 대한 높은 지식
|
||||
|
||||
<br/>
|
||||
|
||||
* **사람(역량)**
|
||||
* **혁신적 사고방식과 창의적 문제 해결 능력**
|
||||
* 기존 수행 방식과 관습적 사고 등에 의한 접근 방식 탈피
|
||||
* 디지털 기술을 활용한 창의적, 혁신적인 솔루션 제시
|
||||
* **사용자 중심 사고와 DX 수행 경험**
|
||||
* 사용자의 요구와 기대를 충족시키는 설계 및 구현
|
||||
* 시행착오를 포함한 수행 경험과 사용자 경험(UX)을 반영한 해결 방안 제시
|
||||
|
||||
<br/>
|
||||
|
||||
* **자연(여건)**
|
||||
* **지속적인 투자 및 실행 의지**
|
||||
* 기술 도입 초기 단계에 필요한 인력·기간·비용 등의 대규모 투자
|
||||
* 기술 고도화를 위한 지속적인 개선 및 투자 체계 구축
|
||||
* 변화와 혁신을 통해 부가가치를 창출하려는 실행 의지와 추진력
|
||||
|
||||
<br/>
|
||||
---
|
||||
|
||||
## 2. Process의 혁신과 Product의 변화
|
||||
|
||||
<br/>
|
||||
|
||||
### 2.1 과정(Process)의 혁신
|
||||
|
||||
* **Analogue 기반 업무의 Digital화**
|
||||
|
||||
| As-is [Analogue] | 구분 | To-be [Digital] |
|
||||
| :--- | :---: | :--- |
|
||||
| **개념·문서·행정 절차 중심** | ➠ | **시각화된 목적물, 소통, 투명성 중심** |
|
||||
| **2D 도면, 전문가, 규정** | ➠ | **3D 모델, 참여자, 실체** |
|
||||
| **업무 구분(단절), 책임** | ➠ | **협업(융·복합), 창의성** |
|
||||
|
||||
<br/>
|
||||
|
||||
* **GIS + BIM의 연계**
|
||||
* 지리·지형·지반 등 위치정보(GIS)와 3D모델(형상, 속성정보) 기반의 건설 정보를 포함하는 BIM의 연계를 통한 업무 프로세스의 혁신
|
||||
|
||||
<br/>
|
||||
|
||||
* **사용자 중심의 Solution 제공**
|
||||
* 서로 다른 S/W로 작성되어 분절화된 Analogue 방식의 성과물과 정보물을 연계할 수 있는 설계·시공 Solution 제공
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
### 2.2 결과(Product)의 변화
|
||||
|
||||
* **Copy & Paste로 인해 하향 평준화된 기존 성과물의 품질 향상**
|
||||
* 과거 수작업으로 시행하면서 발생하던 오류 등의 최소화
|
||||
* 정확한 Data에 기반한 계획으로 고품질 성과물 도출
|
||||
|
||||
<br/>
|
||||
|
||||
* **Analogue 기반 도서 외 Digital 기반 정보물 추가**
|
||||
* 기존 성과물(도면, 수량, 계산서, 시방서 등)에 3D 모델, Simulation 등의 Digital 기반 정보물 추가
|
||||
|
||||
<br/>
|
||||
|
||||
* **Solution을 활용한 업무 효율화**
|
||||
* Engn. Solution을 통해 성과물에 관한 이슈를 함께 검토·논의하는 협업 환경 조성
|
||||
* 건설 단계별 정보를 디지털 데이터로 축적하여, 건설 전 과정을 통합관리
|
||||
|
||||
<br/>
|
||||
---
|
||||
|
||||
:::note[핵심 요약]
|
||||
* **DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.**
|
||||
:::
|
||||
120
samples/mdx_batch/01.mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: 건설산업 DX의 올바른 이해
|
||||
sidebar:
|
||||
order: 00
|
||||
---
|
||||
|
||||
* **용어의 혼용**
|
||||
|
||||
* 건설산업의 디지털 전환 논의에서 DX(Digital Transformation)와 BIM(Building Information Modeling)이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있음
|
||||
* 이로인해 BIM기술의 도입을 DX의 완성으로 오인하거나, DX를 BIM 기술 도입 수준으로 한정하는 인식 확산
|
||||
<details>
|
||||
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>혼용 대표 사례</summary>
|
||||
|
||||
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
|
||||
* **[스마트 건설 활성화 방안(2022.07)]**
|
||||
* 추진과제 : 건설산업 디지털화
|
||||
* 실행과제 : BIM 전면 도입, BIM 전문인력 양성
|
||||
* **[제7차 건설기술진흥 기본계획(2023.12)]**
|
||||
* 추진방향 : 디지털 전환을 통한 스마트 건설 확산
|
||||
* 추진과제 : BIM 도입으로 건설산업 디지털화
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
* 건설산업의 DX를 올바르게 이해하기 위해 각 용어의 정의, 역할, 상호관계에 대한 체계적 정립 필요
|
||||
|
||||
<br/>
|
||||
---
|
||||
|
||||
|
||||
## 1. 용어 정의
|
||||
|
||||
<br/>
|
||||
|
||||
* **건설산업**
|
||||
* 다양한 시설물을 각 산업마다의 광범위한 기술을 통합 및 융합하여 만들어내는 종합산업
|
||||
* 목적 시설물의 품질 욕구를 충족시키면서 최단기간내에 최소 비용으로 편리하고 안전하며 우수한 성능의 시설물 완성을 목표로 함
|
||||
|
||||
<br/>
|
||||
|
||||
* **BIM(Building Information Modeling) : 디지털 전환을 위한 핵심 기술**
|
||||
* 시설물의 생애주기동안 발생한 모든 정보를 3차원 모델 기반으로 통합·관리하는 정보 관리 도구
|
||||
* 건설 정보와 절차를 표준화된 방식으로 연계하고 디지털 협업이 가능하도록 하는 핵심 인프라 기술
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#999',
|
||||
marginTop: '5px',
|
||||
lineHeight: '1.4',
|
||||
paddingLeft: '0px' }}>
|
||||
*건설산업 BIM 기본지침, 국토교통부, 2020*
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
* **DX(Digital Transformation) : 산업 패러다임의 변화**
|
||||
* 디지털 기술을 기반으로 산업 전반의 업무방식과 가치 창출 구조를 전환하는 과정 및 결과
|
||||
* 단순한 기술 도입이 아닌, 고객 가치와 의사결정 방식의 근본적인 변화로 산업의 새로운 방향을 정립하는 것을 의미함
|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#999',
|
||||
marginTop: '5px',
|
||||
lineHeight: '1.4',
|
||||
paddingLeft: '0px' }}>
|
||||
*Digital Transformation, IBM Institute for Business Value, 2011 / What is Digital Transformation?, Agile Elephant, 2015*
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 2. 용어간 상호관계
|
||||
|
||||
* DX는 BIM과 같은 디지털기술을 기반으로 산업 전반의 프로세스를 혁신하는 상위개념
|
||||
* 건설산업의 DX는 GIS(공간정보), BIM, 디지털 트윈(가상환경)의 기술융합을 통해서만 실현 또는 구현 가능
|
||||
* GIS의 역할 : 지리적 데이터를 공간 분석하여 시각적으로 표현, 위치기반 정보 제공
|
||||
* BIM의 역할 : 형상정보와 내용정보가 포함된 3D모델로, 건설 정보 기반의 Process와 Product를 제공
|
||||

|
||||
<div style={{
|
||||
fontSize: '0.8rem',
|
||||
color: '#999',
|
||||
marginTop: '5px',
|
||||
lineHeight: '1.4',
|
||||
paddingLeft: '0px' }}>
|
||||
*[그림 1] DX와 핵심기술간 상호관계*
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary style={{cursor: 'pointer', fontWeight: 'bold', color: '#555'}}>DX와 BIM의 구분</summary>
|
||||
|
||||
<div style={{marginTop: '10px', paddingLeft: '15px', borderLeft: '3px solid #ddd', fontSize: '0.9rem', color: '#666'}}>
|
||||
| DX | 구분 | BIM |
|
||||
| :--- | :---: | ---: |
|
||||
| **BIM << DX**<br/>(Engineering + Management 통합) | **범위** | **Only 3D**<br/>(형상 구현 중심) |
|
||||
| **제작 및 운영**(상용 + 전용 40~80개)<br/>[Rhino, Sketchup, Blender..] + [EG-BIM 등] | **S/W** | **모델 제작용 상용 SW**<br/>[Revit, Civil 3D, Navisworks, Autocad] |
|
||||
| **근본적 문제의식을 통한 개선** | **프로세스** | **기존 2D 설계 방식 유지** |
|
||||
| **공학 정보 및 콘텐츠 연계에 집중**<br/>**도면, 수량, 시공계획 등 일식** | **성과품** | **3D 모델 중심**<br/>**기존 성과품 유지** |
|
||||
| **설계/시공 생산성 혁신**(개념의 재정립) | **활용** | **3D 모델에 의한 일반적 이해 향상** |
|
||||
| **전 생애주기 활용 시스템** | **확장성** | **(설계/시공/운영) 분야별 단절** |
|
||||
| **구체화(복잡) - 적극적/구체적 실현 방안** | **수행 개념** | **단순화(오류) - 수동적/집단적 동질화** |
|
||||
| **적극적, 주체적인 기술 접목/융합** | **CIVIL + IT** | **소극적, 상용 기술에 의존** |
|
||||
| **자체 수행 능력 - 지속가능성 확보** | **주체** | **S/W 제작사 판매 정책에 의존** |
|
||||
| **차별화 및 경쟁력 확보, 해외 진출** | **발주처** | **평준화, 국내 중심** |
|
||||
| **IT + CIVIL ENG 220명 운영 + 기술 개발** | **설계사** | **소규모 BIM팀 운영 + 단순교육에 집중** |
|
||||
| **분야 확장 모델 및 시스템** | **시공사** | **국내 토목 소극적/해외 토목증가** |
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
---
|
||||
|
||||
:::note[핵심 요약]
|
||||
* BIM은 건설산업의 디지털전환(DX)을 수행하는 과정에서 **가장 기초가 되는 일부분**이다
|
||||
:::
|
||||
|
||||
BIN
samples/mdx_batch/01_images/DX1.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
42
samples/mdx_batch/02.mdx
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: DX의 시행 목표 및 기대효과
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
import DxEffect from '../../../../components/dx.astro';
|
||||
|
||||
|
||||
## 1. DX의 궁극적 목표
|
||||
|
||||
- **안전과 품질**
|
||||
- 시설물의 요구 성능을 설계-시공-운영 전 과정에서 **디지털로 검증**하여 **안전성 확보**
|
||||
- Copy & Paste로 하향 평준화된 성과물의 **하자 최소화**로 **고품질 성과물 제공**
|
||||
<br/>
|
||||
- **생산성 향상**
|
||||
- Analogue 기반 업무를 Digital 기반 프로세스로 전환하여 **업무 속도·정확성·일관성 향상**
|
||||
- 건설 비용 및 유지관리비 절감, 건설 기간 단축, 인력투입 최소화를 통해 **부가가치 제고**
|
||||
<br/>
|
||||
- **소통과 신뢰**
|
||||
- 성과품과 Solution을 통한 협업 강화로 **의사소통 효율 및 운영·유지관리**의 **편리성 증진**
|
||||
- 3D 모델 및 데이터 기반 검증을 통한 **오류 최소화 및 Claim 예방**으로 **신뢰성 확보**
|
||||

|
||||
<br/>
|
||||
|
||||
## 2. DX 기반 Process 혁신에 따른 주체별 기대효과
|
||||
<br/>
|
||||
### 2.1 업무 수행 과정(Process)의 변화
|
||||
- **생산 방식**: 수작업 의존의 반복 업무에서 벗어나, **SW를 활용한 체계화된 방식**으로 전환
|
||||
- **인지·검토**: 2D 도면 해석 중심에서 **3D 모델 기반의 직관적 인지·검토 체계**로 전환
|
||||
- **협업 구조**: 개별 문서 중심 협업에서 **데이터 통합 기반의 정보 공유·관리 협업 환경**으로 전환
|
||||
- **검증·대응**: 사후 대응 중심의 문제 처리에서 **사전 검증 중심의 예방적 업무 방식**으로 전환
|
||||
<br/>
|
||||
### 2.2 DX 시행 주체별 기대효과
|
||||
|
||||
<DxEffect />
|
||||
<br/>
|
||||
<br/>
|
||||
:::note[핵심 요약]
|
||||
* 고품질의 성과품, 비용 절감, 시간 단축, 의사소통에 도움이 안 되면 DX가 아니다.
|
||||
:::
|
||||
<br/>
|
||||
BIN
samples/mdx_batch/02_images/궁극적목표.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
89
samples/mdx_batch/03.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: DX 실행 체계 구축 방안
|
||||
sidebar:
|
||||
order: 02
|
||||
---
|
||||
|
||||
## 1. DX 시행을 위한 필수 요건
|
||||
|
||||
<br/>
|
||||
|
||||
* **기술(디지털)**
|
||||
* **Digital 기술(S/W, H/W)과 업무 Process의 통합**
|
||||
* 기존 업무 프로세스에 다양한 디지털 기술을 접목하여 업무 수행
|
||||
* 프로젝트 전반에 걸친 업무 프로세스의 연결 및 조율
|
||||
* **분야별 전문 지식(설계, 시공, 유지관리 등) 보유**
|
||||
* 건설 전 단계에 대한 근본적인 이해와 지식 및 경험
|
||||
* 최신 토목 기술 트랜드 및 표준 기준 등에 대한 높은 지식
|
||||
|
||||
<br/>
|
||||
|
||||
* **사람(역량)**
|
||||
* **혁신적 사고방식과 창의적 문제 해결 능력**
|
||||
* 기존 수행 방식과 관습적 사고 등에 의한 접근 방식 탈피
|
||||
* 디지털 기술을 활용한 창의적, 혁신적인 솔루션 제시
|
||||
* **사용자 중심 사고와 DX 수행 경험**
|
||||
* 사용자의 요구와 기대를 충족시키는 설계 및 구현
|
||||
* 시행착오를 포함한 수행 경험과 사용자 경험(UX)을 반영한 해결 방안 제시
|
||||
|
||||
<br/>
|
||||
|
||||
* **자연(여건)**
|
||||
* **지속적인 투자 및 실행 의지**
|
||||
* 기술 도입 초기 단계에 필요한 인력·기간·비용 등의 대규모 투자
|
||||
* 기술 고도화를 위한 지속적인 개선 및 투자 체계 구축
|
||||
* 변화와 혁신을 통해 부가가치를 창출하려는 실행 의지와 추진력
|
||||
|
||||
<br/>
|
||||
---
|
||||
|
||||
## 2. Process의 혁신과 Product의 변화
|
||||
|
||||
<br/>
|
||||
|
||||
### 2.1 과정(Process)의 혁신
|
||||
|
||||
* **Analogue 기반 업무의 Digital화**
|
||||
|
||||
| As-is [Analogue] | 구분 | To-be [Digital] |
|
||||
| :--- | :---: | :--- |
|
||||
| **개념·문서·행정 절차 중심** | ➠ | **시각화된 목적물, 소통, 투명성 중심** |
|
||||
| **2D 도면, 전문가, 규정** | ➠ | **3D 모델, 참여자, 실체** |
|
||||
| **업무 구분(단절), 책임** | ➠ | **협업(융·복합), 창의성** |
|
||||
|
||||
<br/>
|
||||
|
||||
* **GIS + BIM의 연계**
|
||||
* 지리·지형·지반 등 위치정보(GIS)와 3D모델(형상, 속성정보) 기반의 건설 정보를 포함하는 BIM의 연계를 통한 업무 프로세스의 혁신
|
||||
|
||||
<br/>
|
||||
|
||||
* **사용자 중심의 Solution 제공**
|
||||
* 서로 다른 S/W로 작성되어 분절화된 Analogue 방식의 성과물과 정보물을 연계할 수 있는 설계·시공 Solution 제공
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
### 2.2 결과(Product)의 변화
|
||||
|
||||
* **Copy & Paste로 인해 하향 평준화된 기존 성과물의 품질 향상**
|
||||
* 과거 수작업으로 시행하면서 발생하던 오류 등의 최소화
|
||||
* 정확한 Data에 기반한 계획으로 고품질 성과물 도출
|
||||
|
||||
<br/>
|
||||
|
||||
* **Analogue 기반 도서 외 Digital 기반 정보물 추가**
|
||||
* 기존 성과물(도면, 수량, 계산서, 시방서 등)에 3D 모델, Simulation 등의 Digital 기반 정보물 추가
|
||||
|
||||
<br/>
|
||||
|
||||
* **Solution을 활용한 업무 효율화**
|
||||
* Engn. Solution을 통해 성과물에 관한 이슈를 함께 검토·논의하는 협업 환경 조성
|
||||
* 건설 단계별 정보를 디지털 데이터로 축적하여, 건설 전 과정을 통합관리
|
||||
|
||||
<br/>
|
||||
---
|
||||
|
||||
:::note[핵심 요약]
|
||||
* **DX는 필요한 요건과 체계를 갖춘 후 시행해야만 그 효과를 기대할 수 있다.**
|
||||
:::
|
||||
562
scripts/assemble_stage2.py
Normal file
@@ -0,0 +1,562 @@
|
||||
"""Stage 1.8 context 데이터만으로 stage_2 HTML을 코드로 조립.
|
||||
|
||||
사용하는 데이터 (해당 run의 stage_1_8_context.json에서):
|
||||
- topics[].structured_text — Kei가 원본 85% 보존하여 구조화한 텍스트 (조립용)
|
||||
- topics[].source_data — Kei가 정리한 메타 요약 (fallback)
|
||||
- references[].design_reference_html — 블록 템플릿 렌더링 결과 (SVG 포함)
|
||||
- sub_layouts — 서브 컨테이너 치수
|
||||
- enhancement_result — V-7 종속꼭지, V-9 강조, V-10 bold
|
||||
- containers — 컨테이너 크기 (재배분 후)
|
||||
- font_hierarchy — 폰트 크기
|
||||
- analysis.core_message — 핵심 메시지
|
||||
- normalized.popups — 팝업 데이터 (비교표 등)
|
||||
|
||||
외부 데이터, 하드코딩 없음. 이 JSON 파일만으로 조립.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def assemble(run_dir: str):
|
||||
run = Path(run_dir)
|
||||
ctx = json.loads((run / "stage_1_8_context.json").read_text(encoding="utf-8"))
|
||||
|
||||
topics = ctx["topics"]
|
||||
topic_map = {t["id"]: t for t in topics}
|
||||
ps = ctx["page_structure"]
|
||||
if "roles" in ps:
|
||||
ps = ps["roles"]
|
||||
containers = ctx["containers"]
|
||||
refs = ctx["references"]
|
||||
sub_layouts = ctx.get("sub_layouts", {})
|
||||
enh = ctx.get("enhancement_result", {})
|
||||
fit = ctx.get("fit_result", {})
|
||||
redist = fit.get("redistribution", {})
|
||||
fh = ctx.get("font_hierarchy", {})
|
||||
core_message = ctx.get("analysis", {}).get("core_message", "")
|
||||
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
|
||||
emphasis_blocks = enh.get("emphasis_blocks", [])
|
||||
subordinate_treatments = enh.get("subordinate_treatments", [])
|
||||
popups = ctx.get("normalized", {}).get("popups", [])
|
||||
title = ctx.get("analysis", {}).get("title", "")
|
||||
ratio = ctx.get("container_ratio", [71, 29])
|
||||
|
||||
# ── 유틸 ──
|
||||
def bold(text, role):
|
||||
"""V-10 bold 키워드 적용."""
|
||||
for kw in bold_kw.get(role, []):
|
||||
if kw in text:
|
||||
text = text.replace(kw, f"<strong>{kw}</strong>")
|
||||
return text
|
||||
|
||||
def get_text(topic):
|
||||
"""structured_text 우선, 없으면 source_data fallback."""
|
||||
return topic.get("structured_text", "") or topic.get("source_data", "")
|
||||
|
||||
def get_emphasis(role):
|
||||
"""V-9 강조 문장 가져오기."""
|
||||
for e in emphasis_blocks:
|
||||
if e.get("role") == role:
|
||||
return e.get("sentence", "")
|
||||
return ""
|
||||
|
||||
def extract_block_html(ref_html):
|
||||
"""design_reference_html에서 주석 제거, CSS와 본문 분리."""
|
||||
css_parts = re.findall(r'<style>(.*?)</style>', ref_html, re.DOTALL)
|
||||
body = re.sub(r'<style>.*?</style>', '', ref_html, flags=re.DOTALL)
|
||||
body = re.sub(r'<!--.*?-->', '', body, flags=re.DOTALL)
|
||||
return "\n".join(css_parts), body.strip()
|
||||
|
||||
def popup_link(text, role):
|
||||
"""[팝업: 제목] 마커를 클릭 가능한 링크로 변환."""
|
||||
def _repl(m):
|
||||
popup_title = m.group(1)
|
||||
return f'<span style="color:#2563eb;font-size:{fh.get("sidebar",10)}px;cursor:pointer;">[{popup_title} 상세보기→]</span>'
|
||||
return re.sub(r'\[팝업:\s*([^\]]+)\]', _repl, text)
|
||||
|
||||
def image_marker(text):
|
||||
"""[이미지: 제목] 마커를 제거 (SVG로 별도 처리되므로)."""
|
||||
return re.sub(r'\[이미지:\s*[^\]]+\]', '', text)
|
||||
|
||||
def structured_to_bullets(text, role, font_size, exclude_source=False):
|
||||
"""structured_text → (불릿 HTML, [팝업 제목 리스트]).
|
||||
[팝업:]은 텍스트에서 분리. [이미지:]는 제거. **bold** → <strong>. 출처: → 캡션."""
|
||||
# [팝업:], [이미지:] 마커를 bold 변환 전에 먼저 처리
|
||||
items = [] # [(indent, text)]
|
||||
popup_titles = []
|
||||
for raw_line in text.split("\n"):
|
||||
stripped = raw_line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
indent = 1 if raw_line.startswith(" ") else 0
|
||||
|
||||
# [팝업:] → 분리 (줄 어디에 있든 매칭, bold 변환 전)
|
||||
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
|
||||
if popup_match:
|
||||
popup_titles.append(popup_match.group(1))
|
||||
continue
|
||||
# [이미지:] → 제거 (bold 변환 전)
|
||||
if re.search(r'\[이미지:', stripped):
|
||||
continue
|
||||
|
||||
# 마크다운 bold → HTML (마커 처리 후)
|
||||
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
|
||||
items.append((indent, stripped))
|
||||
|
||||
# items → HTML
|
||||
html = ""
|
||||
for indent, line in items:
|
||||
line = bold(line, role)
|
||||
clean = line.lstrip("• ")
|
||||
if line.startswith("출처:") or clean.startswith("출처:"):
|
||||
if exclude_source:
|
||||
continue # 이미지 아래에 별도 배치됨
|
||||
caption = re.sub(r'^출처:\s*', '', clean)
|
||||
html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;margin-top:3px;">{caption}</div>\n'
|
||||
elif indent == 1:
|
||||
html += f'<div class="bl bl-sub" style="padding-left:1em;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
|
||||
else:
|
||||
html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
|
||||
return html, popup_titles
|
||||
|
||||
def find_popup(title_keyword):
|
||||
"""팝업 목록에서 제목 키워드로 매칭."""
|
||||
for p in popups:
|
||||
if title_keyword in p.get("title", ""):
|
||||
return p
|
||||
return None
|
||||
|
||||
def popup_to_compact_table(popup_content, font_size):
|
||||
"""팝업의 마크다운 표를 compact HTML 테이블로 변환."""
|
||||
# 마크다운 bold → HTML (팝업 정화가 안 된 run 대응)
|
||||
popup_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', popup_content)
|
||||
# JSX style 제거
|
||||
popup_content = re.sub(r'<div\s+style=\{\{[^}]*\}\}\s*>', '', popup_content)
|
||||
popup_content = popup_content.replace('</div>', '')
|
||||
popup_content = re.sub(r'<br\s*/?>', '\n', popup_content)
|
||||
|
||||
lines = popup_content.split("\n")
|
||||
table_lines = [l.strip() for l in lines if l.strip().startswith("|")]
|
||||
if len(table_lines) < 3:
|
||||
return ""
|
||||
# 헤더
|
||||
headers = [c.strip() for c in table_lines[0].split("|") if c.strip()]
|
||||
# 구분선 스킵 (|---|---|)
|
||||
rows = []
|
||||
for tl in table_lines[2:]:
|
||||
cells = [c.strip() for c in tl.split("|") if c.strip()]
|
||||
if cells:
|
||||
rows.append(cells)
|
||||
if not headers or not rows:
|
||||
return ""
|
||||
# HTML 테이블 (compact)
|
||||
col_count = len(headers)
|
||||
header_html = "".join(f'<div style="padding:4px 8px;font-size:{font_size-1}px;font-weight:700;color:#fff;text-align:center;">{h}</div>' for h in headers)
|
||||
rows_html = ""
|
||||
for ri, row in enumerate(rows[:4]): # 최대 4행
|
||||
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
|
||||
cells_html = ""
|
||||
for ci, cell in enumerate(row):
|
||||
align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right")
|
||||
weight = "600" if ci == 0 else "400"
|
||||
color = "#1e40af" if ci == 0 else "#64748b"
|
||||
cells_html += f'<div style="padding:4px 8px;font-size:{font_size-2}px;color:{color};font-weight:{weight};text-align:{align};">{bold(cell, "본심")}</div>'
|
||||
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells_html}</div>\n'
|
||||
|
||||
return (
|
||||
f'<div style="border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;margin-top:6px;">'
|
||||
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_html}</div>'
|
||||
f'{rows_html}</div>'
|
||||
)
|
||||
|
||||
# ── 좌표 계산 (디자인 토큰에서 동적으로) ──
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
slide_w = tokens.get("slide_width", 1280)
|
||||
slide_h = tokens.get("slide_height", 720)
|
||||
pad = tokens["spacing_page"]
|
||||
header_h = tokens.get("header_height", 66)
|
||||
gap_block = tokens["spacing_block"]
|
||||
gap_small = tokens["spacing_small"]
|
||||
inner_w = slide_w - pad * 2
|
||||
body_w = int(inner_w * ratio[0] / 100)
|
||||
sidebar_w = inner_w - body_w - gap_block
|
||||
|
||||
all_css = set()
|
||||
role_htmls = {}
|
||||
|
||||
# ── 각 역할별 조립 ──
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
info = ps.get(role, {})
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
tids = info.get("topic_ids", [])
|
||||
if not tids:
|
||||
continue
|
||||
|
||||
ref_list = refs.get(role, [])
|
||||
r0 = ref_list[0] if ref_list else {}
|
||||
block_id = r0.get("block_id", "")
|
||||
ref_html = r0.get("design_reference_html", "")
|
||||
is_hier = r0.get("is_hierarchical", False)
|
||||
sup_tids = r0.get("supporting_topic_ids", [])
|
||||
primary_tid = r0.get("topic_id") or (tids[0] if tids else None)
|
||||
|
||||
ci = containers.get(role, {})
|
||||
h = int(redist.get(role, ci.get("height_px", 0)))
|
||||
w = ci.get("width_px", 0)
|
||||
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role, "core")
|
||||
font_size = fh.get(font_key, 12)
|
||||
|
||||
block_css, block_body = extract_block_html(ref_html)
|
||||
if block_css:
|
||||
# #7: CSS font-size override (font_hierarchy 기준으로 큰 폰트 축소)
|
||||
def _override_font(m, fs=font_size):
|
||||
val = float(m.group(1))
|
||||
if val > fs + 2:
|
||||
return f"font-size: {fs + 1}px"
|
||||
elif val > fs:
|
||||
return f"font-size: {fs}px"
|
||||
return m.group(0)
|
||||
block_css = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, block_css)
|
||||
# gap, padding, number size도 font_size 비례
|
||||
block_css = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', block_css)
|
||||
block_css = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px',
|
||||
f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', block_css)
|
||||
block_css = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', block_css)
|
||||
# #6: white-space: pre-line → normal (카드 불릿 간 빈줄 방지)
|
||||
block_css = block_css.replace('white-space: pre-line', 'white-space: normal')
|
||||
all_css.add(block_css)
|
||||
|
||||
layout = sub_layouts.get(role, {})
|
||||
scs = layout.get("sub_containers", [])
|
||||
|
||||
primary_topic = topic_map.get(primary_tid, {})
|
||||
topic_title = bold(primary_topic.get("title", ""), role)
|
||||
|
||||
# ════════════════════════════════════
|
||||
# 결론
|
||||
# ════════════════════════════════════
|
||||
if role == "결론":
|
||||
assembled = block_body
|
||||
assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled)
|
||||
assembled = re.sub(r'>부연 설명<', '><', assembled)
|
||||
role_htmls[role] = assembled
|
||||
|
||||
# ════════════════════════════════════
|
||||
# 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿( •) = 카드 설명
|
||||
# ════════════════════════════════════
|
||||
elif role == "첨부":
|
||||
st = get_text(primary_topic)
|
||||
# 마크다운 bold → HTML
|
||||
st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st)
|
||||
# 파싱: 주불릿(줄 시작 "• ")을 카드 구분자로
|
||||
items = [] # [(title, [desc_lines])]
|
||||
current_title = ""
|
||||
current_descs = []
|
||||
for line in st.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
# 주불릿: 새 카드 시작
|
||||
if stripped.startswith("• ") and not line.startswith(" "):
|
||||
if current_title:
|
||||
items.append((current_title, current_descs))
|
||||
current_title = stripped[2:]
|
||||
current_descs = []
|
||||
# 하위불릿 또는 출처: 현재 카드의 설명
|
||||
elif stripped.startswith("• ") and line.startswith(" "):
|
||||
current_descs.append(stripped[2:])
|
||||
elif stripped.startswith("출처:"):
|
||||
current_descs.append(stripped)
|
||||
else:
|
||||
current_descs.append(stripped)
|
||||
if current_title:
|
||||
items.append((current_title, current_descs))
|
||||
if not items:
|
||||
items = [(primary_topic.get("title", ""), [st])]
|
||||
|
||||
cards = ""
|
||||
for i, (card_title, desc_lines) in enumerate(items):
|
||||
desc_html = ""
|
||||
for dl in desc_lines:
|
||||
dl = dl.strip()
|
||||
if not dl:
|
||||
continue
|
||||
dl = bold(dl, role)
|
||||
if dl.startswith("출처:"):
|
||||
caption = re.sub(r'^출처:\s*', '', dl)
|
||||
desc_html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;margin-top:3px;">{caption}</div>\n'
|
||||
else:
|
||||
desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{dl}</span></div>\n'
|
||||
cards += (
|
||||
f'<div style="display:flex;gap:{max(4, int(font_size * 0.8))}px;align-items:flex-start;padding:{int(font_size*0.7)}px {int(font_size)}px;'
|
||||
f'background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0;">'
|
||||
f'<div style="width:{int(font_size*2)}px;height:{int(font_size*2)}px;border-radius:50%;background:#2563eb;'
|
||||
f'display:flex;align-items:center;justify-content:center;color:#fff;'
|
||||
f'font-size:{font_size-1}px;font-weight:800;flex-shrink:0;">{i+1}</div>'
|
||||
f'<div>'
|
||||
f'<div style="font-size:{font_size}px;font-weight:700;color:#1e293b;margin-bottom:2px;">'
|
||||
f'{bold(card_title.strip(), role)}</div>'
|
||||
f'<div style="font-size:{font_size-1}px;color:#475569;line-height:1.4;">'
|
||||
f'{desc_html}</div></div></div>\n'
|
||||
)
|
||||
# 첨부 컨테이너 높이에 맞게 gap/padding 동적 조절
|
||||
sb_container_h = int(redist.get(role, ci.get("height_px", 0)))
|
||||
n_cards = len(items)
|
||||
sb_pad = min(10, max(4, sb_container_h // 50))
|
||||
sb_gap = min(7, max(3, (sb_container_h - sb_pad * 2) // (n_cards * 10)))
|
||||
|
||||
role_htmls[role] = (
|
||||
f'<div style="padding:{sb_pad}px;display:flex;flex-direction:column;gap:{sb_gap}px;'
|
||||
f'height:100%;box-sizing:border-box;">'
|
||||
f'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:2px;">'
|
||||
f'{topic_title}</div>{cards}</div>'
|
||||
)
|
||||
|
||||
# ════════════════════════════════════
|
||||
# 배경 — callout 구조 + 종속꼭지 인라인 + 강조
|
||||
# ════════════════════════════════════
|
||||
elif role == "배경":
|
||||
sub_html = ""
|
||||
if is_hier and sup_tids:
|
||||
for st_id in sup_tids:
|
||||
st_topic = topic_map.get(st_id, {})
|
||||
st_text = get_text(st_topic)
|
||||
st_text = popup_link(st_text, role)
|
||||
sub_html += (
|
||||
f'<div style="padding-left:1em;margin-top:2px;color:#9b1c1c;font-size:{font_size-1}px;'
|
||||
f'border-left:2px solid #fca5a5;">'
|
||||
f'{bold(st_text[:120], role)}</div>'
|
||||
)
|
||||
|
||||
emph = get_emphasis(role)
|
||||
emph_html = ""
|
||||
if emph:
|
||||
emph_html = (
|
||||
f'<div style="background:#991b1b;color:#fff;border-radius:3px;'
|
||||
f'padding:3px 8px;font-size:{font_size-1}px;font-weight:700;margin-top:2px;">'
|
||||
f'→ {emph}</div>'
|
||||
)
|
||||
|
||||
bullets_html, bg_popups = structured_to_bullets(get_text(primary_topic), role, font_size)
|
||||
# V'-1: 팝업 링크 우측상단
|
||||
bg_popup_html = ""
|
||||
if bg_popups:
|
||||
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in bg_popups)
|
||||
bg_popup_html = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'
|
||||
|
||||
# padding을 컨테이너 높이에 맞게 동적 조절
|
||||
container_h = int(redist.get(role, ci.get("height_px", 0)))
|
||||
pad_v = min(10, max(4, container_h // 20)) # 컨테이너 높이의 5% 정도
|
||||
pad_h = min(14, max(6, container_h // 10))
|
||||
|
||||
role_htmls[role] = (
|
||||
f'<div style="position:relative;background:linear-gradient(135deg,#fef2f2 0%,#fee2e2 100%);'
|
||||
f'border:2px solid #fca5a5;border-radius:6px;padding:{pad_v}px {pad_h}px;'
|
||||
f'display:flex;gap:{max(3, int(font_size * 0.4))}px;align-items:flex-start;width:100%;height:100%;box-sizing:border-box;">'
|
||||
f'{bg_popup_html}'
|
||||
f'<div style="font-size:1.1rem;flex-shrink:0;">⚠️</div>'
|
||||
f'<div style="flex:1;font-size:{font_size}px;line-height:1.4;color:#7f1d1d;">'
|
||||
f'<div style="font-size:{font_size+1}px;font-weight:800;color:#991b1b;margin-bottom:2px;">'
|
||||
f'{topic_title}</div>'
|
||||
f'{bullets_html}{sub_html}{emph_html}'
|
||||
f'</div></div>'
|
||||
)
|
||||
|
||||
# ════════════════════════════════════
|
||||
# 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단)
|
||||
# ════════════════════════════════════
|
||||
elif role == "본심":
|
||||
svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None)
|
||||
text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None)
|
||||
keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None)
|
||||
|
||||
# 이미지: slide_images에서 실제 이미지 사용, 없으면 빈 placeholder
|
||||
slide_images = ctx.get("slide_images", [])
|
||||
img_html = ""
|
||||
for img in slide_images:
|
||||
b64 = img.get("b64", "")
|
||||
if b64:
|
||||
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
||||
break
|
||||
|
||||
svg_w = int(svg_sc["width_px"]) if svg_sc else 200
|
||||
svg_h = int(svg_sc["height_px"]) if svg_sc else 265
|
||||
# 본심의 모든 topic 텍스트를 합침
|
||||
all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))
|
||||
|
||||
# 출처 텍스트를 이미지 아래에 배치
|
||||
img_caption = ""
|
||||
for line in all_core_text.split("\n"):
|
||||
stripped = line.strip().lstrip("• ")
|
||||
if stripped.startswith("출처:"):
|
||||
img_caption = re.sub(r'^출처:\s*', '', stripped)
|
||||
break
|
||||
caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""
|
||||
svg_wrapped = (
|
||||
f'<div style="width:{svg_w}px;flex-shrink:0;">'
|
||||
f'<div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
|
||||
f'{caption_html}</div>'
|
||||
)
|
||||
|
||||
# 텍스트 불릿 (출처는 이미지 아래에 별도 배치했으므로 제외)
|
||||
bullets, core_popups = structured_to_bullets(all_core_text, role, font_size, exclude_source=True)
|
||||
|
||||
# V'-2: 팝업 원본을 Kei가 요약한 결과를 사용 (없으면 기존 compact 변환 fallback)
|
||||
popup_summaries = enh.get("popup_summaries", {})
|
||||
table_html = ""
|
||||
used_popups = []
|
||||
for pr in core_popups:
|
||||
summary = popup_summaries.get(pr)
|
||||
if summary:
|
||||
used_popups.append(pr)
|
||||
fmt = summary.get("format", "text")
|
||||
popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
|
||||
|
||||
if fmt == "table":
|
||||
cols = summary.get("columns", [])
|
||||
data = summary.get("data", [])
|
||||
col_count = len(cols)
|
||||
if col_count > 0 and data:
|
||||
header_cells = "".join(
|
||||
f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.6)}px;font-size:{font_size-1}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
|
||||
for c in cols
|
||||
)
|
||||
rows_html = ""
|
||||
for ri, row in enumerate(data):
|
||||
bg = "#f8fafc" if ri % 2 == 0 else "#fff"
|
||||
cells = ""
|
||||
for ci, cell in enumerate(row):
|
||||
c_color = "#1e40af" if ci == 0 else "#64748b"
|
||||
c_weight = "600" if ci == 0 else "400"
|
||||
cells += f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.6)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{bold(cell, role)}</div>'
|
||||
rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells}</div>\n'
|
||||
compact = (
|
||||
f'<div style="border:1px solid #e2e8f0;border-radius:{int(font_size*0.5)}px;overflow:hidden;">'
|
||||
f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
|
||||
f'{rows_html}</div>'
|
||||
)
|
||||
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
|
||||
|
||||
elif fmt == "bullets":
|
||||
items = summary.get("items", [])
|
||||
bullets_html = "".join(
|
||||
f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{bold(item, role)}</span></div>'
|
||||
for item in items
|
||||
)
|
||||
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;font-size:{font_size-1}px;">{popup_link_html}{bullets_html}</div>'
|
||||
|
||||
elif fmt == "text":
|
||||
text = summary.get("summary", "")
|
||||
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;font-size:{font_size-1}px;color:#475569;">{popup_link_html}{bold(text, role)}</div>'
|
||||
|
||||
else:
|
||||
# fallback: 기존 compact 변환
|
||||
popup = find_popup(pr)
|
||||
if popup:
|
||||
content = popup.get("content", "")
|
||||
if content.count("|") > 3:
|
||||
compact = popup_to_compact_table(content, font_size)
|
||||
if compact:
|
||||
popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
|
||||
table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
|
||||
used_popups.append(pr)
|
||||
|
||||
# 표/요약에 연결되지 않은 팝업은 컨테이너 우측상단에
|
||||
remaining_popups = [p for p in core_popups if p not in used_popups]
|
||||
core_popup_html = ""
|
||||
if remaining_popups:
|
||||
links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in remaining_popups)
|
||||
core_popup_html = f'<div style="text-align:right;margin-bottom:2px;">{links}</div>'
|
||||
|
||||
text_wrapped = (
|
||||
f'<div style="flex:1;display:flex;flex-direction:column;min-height:0;">'
|
||||
f'{core_popup_html}'
|
||||
f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bullets}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
# key-msg
|
||||
keymsg_html = ""
|
||||
if keymsg_sc and core_message:
|
||||
keymsg_html = (
|
||||
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
|
||||
f'padding:{int(font_size*0.4)}px {int(font_size*0.8)}px;font-size:{font_size+1}px;font-weight:700;color:#1e40af;text-align:center;'
|
||||
f'margin-top:{gap_small}px;flex-shrink:0;">{bold(core_message, role)}</div>'
|
||||
)
|
||||
|
||||
# 레이아웃: 이미지(좌)+불릿(우) 위, 표(전체 폭) 아래, keymsg 최하단
|
||||
role_htmls[role] = (
|
||||
f'<div style="width:100%;height:100%;background:white;padding:{gap_small}px;box-sizing:border-box;'
|
||||
f'display:flex;flex-direction:column;">'
|
||||
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
|
||||
f'{topic_title}</div>'
|
||||
f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;min-height:0;align-items:flex-start;">'
|
||||
f'{svg_wrapped}{text_wrapped}</div>'
|
||||
f'{table_html}'
|
||||
f'{keymsg_html}</div>'
|
||||
)
|
||||
|
||||
# ── 슬라이드 좌표 ──
|
||||
bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
|
||||
core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
|
||||
sb_h = int(redist.get("첨부", containers.get("첨부", {}).get("height_px", 0)))
|
||||
concl_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
|
||||
|
||||
bg_top = pad + header_h + gap_block
|
||||
core_top = bg_top + bg_h + gap_small
|
||||
sb_top = bg_top
|
||||
|
||||
# #9: 결론 바로 위까지 body/sidebar 모두 채움 — 공란 제거
|
||||
ft_top = slide_h - pad - concl_h - gap_block # 결론 위치: 슬라이드 바닥 - pad - 결론높이 - gap
|
||||
column_bottom = ft_top - gap_block # body/sidebar 바닥: 결론 위 gap만큼 위
|
||||
core_h = column_bottom - core_top # 본심: 배경 아래~column 바닥
|
||||
sb_h = column_bottom - sb_top # 첨부: column 바닥까지
|
||||
|
||||
css_block = "\n".join(all_css)
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
|
||||
.bl-sub{{padding-left:1em;}}
|
||||
{css_block}
|
||||
</style></head><body>
|
||||
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 결과 (context 데이터만, Sonnet 없음)</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:10px;">sub_layouts + design_reference_html + structured_text + V-7~V-10 + popups</div>
|
||||
<div style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
||||
|
||||
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>
|
||||
|
||||
<div style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border-radius:6px;overflow:hidden;">
|
||||
{role_htmls.get("배경", "")}
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border-radius:6px;overflow:hidden;">
|
||||
{role_htmls.get("본심", "")}
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border-radius:6px;overflow:hidden;">
|
||||
{role_htmls.get("첨부", "")}
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border-radius:8px;overflow:hidden;">
|
||||
{role_htmls.get("결론", "")}
|
||||
</div>
|
||||
|
||||
</div></body></html>"""
|
||||
|
||||
out = run / "steps" / "stage_2_code_assembled.html"
|
||||
out.write_text(html, encoding="utf-8")
|
||||
print(f"저장: {out} ({len(html)} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
|
||||
assemble(run_dir)
|
||||
167
scripts/gen_viz_layers.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Step 1~4를 같은 슬라이드 레이아웃 위에 레이어로 쌓아 PNG 생성."""
|
||||
import json, urllib.parse, time, sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
run_dir = Path("data/runs/20260402_091318")
|
||||
|
||||
ctx_1a = json.loads((run_dir / "stage_1a_context.json").read_text(encoding="utf-8"))
|
||||
ctx_1b = json.loads((run_dir / "stage_1b_context.json").read_text(encoding="utf-8"))
|
||||
ctx_15a = json.loads((run_dir / "stage_1_5a_context.json").read_text(encoding="utf-8"))
|
||||
ctx_17 = json.loads((run_dir / "stage_1_7_context.json").read_text(encoding="utf-8"))
|
||||
ctx_15b = json.loads((run_dir / "stage_1_5b_context.json").read_text(encoding="utf-8"))
|
||||
|
||||
topics = ctx_1b.get("topics", [])
|
||||
containers = ctx_15a.get("containers", {})
|
||||
fh = ctx_15a.get("font_hierarchy", {})
|
||||
ratio = ctx_15a.get("container_ratio", [72, 28])
|
||||
refs = ctx_17.get("references", {})
|
||||
ps = ctx_1a.get("page_structure", {})
|
||||
if "roles" in ps:
|
||||
ps = ps["roles"]
|
||||
containers_b = ctx_15b.get("containers", {})
|
||||
topic_map = {t["id"]: t for t in topics}
|
||||
|
||||
slide_w, slide_h = 1280, 720
|
||||
pad = 40
|
||||
header_h = 66
|
||||
gap = 20
|
||||
footer_h = containers.get("결론", {}).get("height_px", 60)
|
||||
inner_w = slide_w - pad * 2
|
||||
body_pct = ratio[0] if ratio else 72
|
||||
sidebar_pct = ratio[1] if len(ratio) > 1 else 28
|
||||
body_w = int(inner_w * body_pct / 100)
|
||||
sidebar_w = inner_w - body_w - gap
|
||||
body_zone_h = slide_h - pad * 2 - header_h - footer_h - gap * 2
|
||||
bg_h = containers.get("배경", {}).get("height_px", 117)
|
||||
core_h = body_zone_h - bg_h - 12
|
||||
|
||||
L = {
|
||||
"배경": {"x": pad, "y": pad+header_h+gap, "w": body_w, "h": bg_h},
|
||||
"본심": {"x": pad, "y": pad+header_h+gap+bg_h+12, "w": body_w, "h": core_h},
|
||||
"첨부": {"x": pad+body_w+gap, "y": pad+header_h+gap, "w": sidebar_w, "h": body_zone_h},
|
||||
"결론": {"x": pad, "y": slide_h-pad-footer_h, "w": inner_w, "h": footer_h},
|
||||
}
|
||||
C = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||
|
||||
|
||||
def area(role, inner):
|
||||
p = L[role]; c = C[role]
|
||||
return (f'<div style="position:absolute;left:{p["x"]}px;top:{p["y"]}px;'
|
||||
f'width:{p["w"]}px;height:{p["h"]}px;border:2px solid {c};'
|
||||
f'border-radius:6px;overflow:hidden;background:{c}08;'
|
||||
f'padding:6px;font-size:9px;line-height:1.4;">{inner}</div>')
|
||||
|
||||
|
||||
def header(title):
|
||||
return (f'<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;'
|
||||
f'height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;'
|
||||
f'display:flex;align-items:center;padding:0 20px;font-size:22px;'
|
||||
f'font-weight:900;color:#1e293b;">건설산업 DX의 올바른 이해</div>')
|
||||
|
||||
|
||||
def slide(step_title, areas_html):
|
||||
return (f'<!DOCTYPE html><html><head><meta charset="UTF-8">'
|
||||
f'<style>*{{margin:0;padding:0;box-sizing:border-box;}}'
|
||||
f'body{{background:#e5e5e5;padding:10px;font-family:sans-serif;}}</style></head><body>'
|
||||
f'<div style="font-size:14px;font-weight:bold;margin-bottom:6px;">{step_title}</div>'
|
||||
f'<div style="width:{slide_w}px;height:{slide_h}px;background:white;'
|
||||
f'position:relative;border:1px solid #ccc;">'
|
||||
f'{header(step_title)}{areas_html}</div></body></html>')
|
||||
|
||||
|
||||
# Step 1
|
||||
a1 = ""
|
||||
for role in L:
|
||||
c = C[role]; p = L[role]
|
||||
fk = {"배경":"bg","본심":"core","첨부":"sidebar","결론":"key_msg"}.get(role,"core")
|
||||
fv = fh.get(fk, 12)
|
||||
a1 += area(role,
|
||||
f'<div style="text-align:center;margin-top:{p["h"]//2-15}px;">'
|
||||
f'<b style="color:{c};font-size:13px;">{role}</b><br>'
|
||||
f'<span style="color:#888;font-size:10px;">{p["w"]}x{p["h"]}px / font:{fv}px</span></div>')
|
||||
|
||||
# Step 2
|
||||
a2 = ""
|
||||
for role in L:
|
||||
c = C[role]; info = ps.get(role, {}); tids = info.get("topic_ids", [])
|
||||
w = info.get("weight", 0)
|
||||
inner = f'<div style="font-size:8px;color:{c};font-weight:bold;">{role} (w:{w})</div>'
|
||||
for tid in tids:
|
||||
t = topic_map.get(tid, {})
|
||||
inner += (f'<div style="background:white;border:1px solid #ddd;border-radius:3px;'
|
||||
f'padding:3px;margin:2px 0;">'
|
||||
f'<b style="font-size:8px;">T{tid}: {t.get("title","")[:25]}</b><br>'
|
||||
f'<span style="font-size:7px;color:#888;">'
|
||||
f'{t.get("purpose","")} / {t.get("relation_type","")}</span></div>')
|
||||
a2 += area(role, inner)
|
||||
|
||||
# Step 3
|
||||
a3 = ""
|
||||
for role in L:
|
||||
c = C[role]; ref = refs.get(role, {}); p = L[role]
|
||||
bid = ref.get("block_id", "?"); vtype = ref.get("visual_type", "?")
|
||||
info = ps.get(role, {}); tids = info.get("topic_ids", [])
|
||||
tnames = ", ".join(f"T{tid}" for tid in tids)
|
||||
mt = max(0, p["h"]//2-25)
|
||||
a3 += area(role,
|
||||
f'<div style="text-align:center;margin-top:{mt}px;">'
|
||||
f'<div style="font-size:9px;color:{c};">{role} ({tnames})</div>'
|
||||
f'<div style="font-size:14px;margin:2px 0;">📦</div>'
|
||||
f'<div style="font-size:11px;font-weight:bold;">{bid}</div>'
|
||||
f'<div style="font-size:8px;color:#888;">type: {vtype}</div></div>')
|
||||
|
||||
# Step 4
|
||||
a4 = ""
|
||||
for role in L:
|
||||
c = C[role]; ref = refs.get(role, {}); p = L[role]
|
||||
bid = ref.get("block_id", "?")
|
||||
cb = containers_b.get(role, {}); db = cb.get("design_budget") or {}
|
||||
text_h = db.get("text_height_px", 0)
|
||||
avail_h = db.get("available_height_px", 0)
|
||||
fits = db.get("fits", False)
|
||||
total = max(text_h + avail_h, 1)
|
||||
tp = int(text_h / total * 100)
|
||||
bw = p["w"] - 20
|
||||
fc = "green" if fits else "red"
|
||||
a4 += area(role,
|
||||
f'<div style="padding:2px;">'
|
||||
f'<div style="font-size:9px;color:{c};font-weight:bold;">{role}: {bid}</div>'
|
||||
f'<div style="display:flex;height:14px;border-radius:3px;overflow:hidden;margin:4px 0;width:{bw}px;">'
|
||||
f'<div style="width:{tp}%;background:#ff6b6b;font-size:7px;color:white;text-align:center;line-height:14px;">텍스트{text_h}px</div>'
|
||||
f'<div style="width:{100-tp}%;background:#51cf66;font-size:7px;color:white;text-align:center;line-height:14px;">여유{avail_h}px</div>'
|
||||
f'</div>'
|
||||
f'<div style="font-size:8px;color:{fc};font-weight:bold;">fits:{fits} / {p["w"]}x{p["h"]}px</div></div>')
|
||||
|
||||
htmls = {
|
||||
"viz_1_containers": slide(f"Step 1: 컨테이너 포션과 위치 (비율 {body_pct}:{sidebar_pct})", a1),
|
||||
"viz_2_content": slide("Step 2: 각 영역별 내용 배치", a2),
|
||||
"viz_3_blocks": slide("Step 3: 블록 선택 결과", a3),
|
||||
"viz_4_budget": slide("Step 4: 블록별 디자인 예산", a4),
|
||||
}
|
||||
|
||||
for name, html in htmls.items():
|
||||
(run_dir / f"{name}.html").write_text(html, encoding="utf-8")
|
||||
|
||||
# PNG
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
opts = Options()
|
||||
opts.add_argument("--headless")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--force-device-scale-factor=2")
|
||||
driver = webdriver.Chrome(options=opts)
|
||||
driver.set_window_size(1380, 820)
|
||||
|
||||
for name in htmls:
|
||||
html = (run_dir / f"{name}.html").read_text(encoding="utf-8")
|
||||
encoded = urllib.parse.quote(html, safe="")
|
||||
driver.get(f"data:text/html;charset=utf-8,{encoded}")
|
||||
time.sleep(2)
|
||||
driver.save_screenshot(str(run_dir / f"{name}.png"))
|
||||
print(f"{name}.png")
|
||||
|
||||
driver.quit()
|
||||
print("완료")
|
||||
314
scripts/generate_step_html.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Stage별 실제 출력 데이터로 step HTML 생성.
|
||||
|
||||
각 step은 이전 step 위에 레이어를 쌓아가는 구조:
|
||||
- Step 0: Kei 꼭지 (테이블)
|
||||
- Step 1: 빈 컨테이너 (1280x720 슬라이드)
|
||||
- Step 2: Step 1 + 블록 선택 (컨테이너 안에 블록 표시)
|
||||
- Step 3: Step 2 + 재배분 반영 (크기 변경 + 보강)
|
||||
- Step 4: 최종 결과물 (final.html)
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load(run: Path, name: str) -> dict:
|
||||
return json.loads((run / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _colors():
|
||||
return {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||
|
||||
|
||||
def _calc_coords(containers, ratio, pad=40, gap=20, header_h=66):
|
||||
"""컨테이너 좌표 계산. containers dict에서 실제 px 값 사용."""
|
||||
inner_w = 1280 - pad * 2
|
||||
body_w = int(inner_w * ratio[0] / 100)
|
||||
sidebar_w = inner_w - body_w - gap
|
||||
sidebar_left = pad + body_w + gap
|
||||
|
||||
def get(c, key):
|
||||
return c.get(key, 0) if isinstance(c, dict) else getattr(c, key, 0)
|
||||
|
||||
bg_px = get(containers.get("배경", {}), "height_px")
|
||||
core_px = get(containers.get("본심", {}), "height_px")
|
||||
sidebar_px = get(containers.get("첨부", {}), "height_px")
|
||||
footer_px = get(containers.get("결론", {}), "height_px")
|
||||
|
||||
bg_top = pad + header_h + gap
|
||||
core_top = bg_top + bg_px + 8
|
||||
footer_top = max(core_top + core_px, bg_top + sidebar_px) + gap
|
||||
|
||||
return {
|
||||
"header": {"left": pad, "top": pad, "width": inner_w, "height": header_h},
|
||||
"배경": {"left": pad, "top": bg_top, "width": body_w, "height": bg_px},
|
||||
"본심": {"left": pad, "top": core_top, "width": body_w, "height": core_px},
|
||||
"첨부": {"left": sidebar_left, "top": bg_top, "width": sidebar_w, "height": sidebar_px},
|
||||
"결론": {"left": pad, "top": footer_top, "width": inner_w, "height": footer_px},
|
||||
}
|
||||
|
||||
|
||||
def _box_html(coord, role, label, colors, extra_style=""):
|
||||
c = colors.get(role, "#333")
|
||||
return (
|
||||
f'<div style="position:absolute;left:{coord["left"]}px;top:{coord["top"]}px;'
|
||||
f'width:{coord["width"]}px;height:{coord["height"]}px;'
|
||||
f'border:2px solid {c};border-radius:6px;background:{c}08;'
|
||||
f'{extra_style}">'
|
||||
f'{label}</div>\n'
|
||||
)
|
||||
|
||||
|
||||
def _header_html(coord, title):
|
||||
return (
|
||||
f'<div style="position:absolute;left:{coord["left"]}px;top:{coord["top"]}px;'
|
||||
f'width:{coord["width"]}px;height:{coord["height"]}px;'
|
||||
f'background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;'
|
||||
f'align-items:center;padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">'
|
||||
f'{title}</div>\n'
|
||||
)
|
||||
|
||||
|
||||
def _slide_wrap(title, subtitle, body):
|
||||
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||
</style></head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">{title}</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:8px;">{subtitle}</div>
|
||||
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
|
||||
{body}
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def gen_step0(run: Path, out: Path):
|
||||
ctx1b = _load(run, "stage_1b_context.json")
|
||||
topics = ctx1b.get("topics", [])
|
||||
ps = ctx1b.get("page_structure", {}).get("roles", {})
|
||||
role_map = {}
|
||||
for role, info in ps.items():
|
||||
for tid in info.get("topic_ids", []):
|
||||
role_map[tid] = role
|
||||
|
||||
colors = _colors()
|
||||
rows = ""
|
||||
for t in topics:
|
||||
tid = t.get("id")
|
||||
role = role_map.get(tid, "?")
|
||||
c = colors.get(role, "#333")
|
||||
bg = "#f8fafc" if tid % 2 == 0 else "#fff"
|
||||
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{tid}</td>'
|
||||
f'<td style="padding:6px 8px;font-weight:700;">{t.get("title","")}</td>'
|
||||
f'<td style="padding:6px 8px;">{t.get("purpose","")}</td>'
|
||||
f'<td style="padding:6px 8px;">{t.get("layer","")}</td>'
|
||||
f'<td style="padding:6px 8px;">{t.get("relation_type","")}</td>'
|
||||
f'<td style="padding:6px 8px;color:{c};font-weight:700;">{role}</td></tr>\n')
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||||
</head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Step 0: Kei 꼭지 추출 (Stage 1A/1B)</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:12px;">run: {run.name}</div>
|
||||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;">
|
||||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th><th style="padding:8px;">purpose</th><th style="padding:8px;">layer</th><th style="padding:8px;">relation_type</th><th style="padding:8px;">영역</th></tr>
|
||||
{rows}</table></body></html>"""
|
||||
(out / "step0_kei_topics.html").write_text(html, encoding="utf-8")
|
||||
print("step0 생성")
|
||||
|
||||
|
||||
def gen_step1(run: Path, out: Path):
|
||||
"""Step 1: 빈 컨테이너."""
|
||||
ctx15a = _load(run, "stage_1_5a_context.json")
|
||||
containers = ctx15a.get("containers", {})
|
||||
ratio = ctx15a.get("container_ratio", [65, 35])
|
||||
fh = ctx15a.get("font_hierarchy", {})
|
||||
colors = _colors()
|
||||
coords = _calc_coords(containers, ratio)
|
||||
|
||||
body = _header_html(coords["header"], "건설산업 DX의 올바른 이해")
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
coord = coords[role]
|
||||
c = colors[role]
|
||||
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role)
|
||||
label = (f'<div style="text-align:center;margin-top:{coord["height"]//2 - 15}px;">'
|
||||
f'<b style="color:{c};font-size:13px;">{role}</b><br>'
|
||||
f'<span style="color:#888;font-size:10px;">{coord["width"]}x{coord["height"]}px / font:{fh.get(font_key,"?")}px</span></div>')
|
||||
body += _box_html(coord, role, label, colors)
|
||||
|
||||
html = _slide_wrap(
|
||||
"Step 1: 빈 컨테이너 (Stage 1.5a)",
|
||||
f'비율 {ratio[0]}:{ratio[1]}',
|
||||
body,
|
||||
)
|
||||
(out / "step1_containers.html").write_text(html, encoding="utf-8")
|
||||
print("step1 생성")
|
||||
return coords, containers, ratio, fh
|
||||
|
||||
|
||||
def gen_step2(run: Path, out: Path, coords, fh):
|
||||
"""Step 2: Step 1 컨테이너 위에 블록 선택 표시."""
|
||||
ctx17 = _load(run, "stage_1_7_context.json")
|
||||
refs = ctx17.get("references", {})
|
||||
colors = _colors()
|
||||
ctx15a = _load(run, "stage_1_5a_context.json")
|
||||
ratio = ctx15a.get("container_ratio", [65, 35])
|
||||
|
||||
body = _header_html(coords["header"], "건설산업 DX의 올바른 이해")
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
coord = coords[role]
|
||||
c = colors[role]
|
||||
ref_list = refs.get(role, [])
|
||||
if not isinstance(ref_list, list):
|
||||
ref_list = [ref_list]
|
||||
|
||||
# 블록 정보를 컨테이너 안에 표시
|
||||
block_lines = []
|
||||
for r in ref_list:
|
||||
if isinstance(r, dict):
|
||||
bid = r.get("block_id", "?")
|
||||
var = r.get("variant", "default")
|
||||
tid = r.get("topic_id", "?")
|
||||
sup = r.get("supporting_topic_ids", [])
|
||||
hier = r.get("is_hierarchical", False)
|
||||
line = f'꼭지{tid}: <b>{bid}</b> ({var})'
|
||||
if hier:
|
||||
line += f' <span style="color:#dc2626;font-size:9px;">★주종</span>'
|
||||
if sup:
|
||||
line += f' <span style="font-size:9px;color:#888;">[종속:{sup}]</span>'
|
||||
block_lines.append(line)
|
||||
|
||||
block_html = '<br>'.join(block_lines)
|
||||
font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role)
|
||||
|
||||
label = (f'<div style="padding:6px 10px;">'
|
||||
f'<div style="font-size:10px;color:{c};font-weight:700;margin-bottom:4px;">'
|
||||
f'{role} ({coord["width"]}x{coord["height"]}px)</div>'
|
||||
f'<div style="font-size:11px;line-height:1.6;">{block_html}</div>'
|
||||
f'</div>')
|
||||
body += _box_html(coord, role, label, colors)
|
||||
|
||||
html = _slide_wrap(
|
||||
"Step 2: 블록 선택 (Stage 1.7) — Step 1 컨테이너 위에 블록 표시",
|
||||
"layer 기반 주종 판단. 배경: 꼭지1(intro)+꼭지2(supporting) → 주종합침 블록 1개",
|
||||
body,
|
||||
)
|
||||
(out / "step2_blocks.html").write_text(html, encoding="utf-8")
|
||||
print("step2 생성")
|
||||
|
||||
|
||||
def gen_step3(run: Path, out: Path, containers, ratio, fh):
|
||||
"""Step 3: Step 2 위에 재배분 반영."""
|
||||
ctx18 = _load(run, "stage_1_8_context.json")
|
||||
fit = ctx18.get("fit_result", {})
|
||||
enh = ctx18.get("enhancement_result", {})
|
||||
redist = fit.get("redistribution", {})
|
||||
|
||||
# 재배분된 containers
|
||||
new_containers = {}
|
||||
for role, c in containers.items():
|
||||
h = c.get("height_px", 0) if isinstance(c, dict) else getattr(c, "height_px", 0)
|
||||
new_h = int(redist.get(role, h))
|
||||
if isinstance(c, dict):
|
||||
new_containers[role] = {**c, "height_px": new_h}
|
||||
else:
|
||||
new_containers[role] = {"height_px": new_h, "width_px": getattr(c, "width_px", 0), "zone": getattr(c, "zone", "")}
|
||||
|
||||
colors = _colors()
|
||||
new_coords = _calc_coords(new_containers, ratio)
|
||||
|
||||
# 블록 선택 정보도 가져옴
|
||||
ctx17 = _load(run, "stage_1_7_context.json")
|
||||
refs = ctx17.get("references", {})
|
||||
|
||||
body = _header_html(new_coords["header"], "건설산업 DX의 올바른 이해")
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
coord = new_coords[role]
|
||||
c = colors[role]
|
||||
|
||||
# fit 상태
|
||||
rf = fit.get("roles", {}).get(role, {})
|
||||
status = rf.get("fit_status", "?")
|
||||
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
|
||||
needed = rf.get("total_required_px", 0)
|
||||
old_h = rf.get("allocated_px", 0)
|
||||
new_h = int(redist.get(role, old_h))
|
||||
delta = new_h - old_h
|
||||
|
||||
# 블록 정보
|
||||
ref_list = refs.get(role, [])
|
||||
if not isinstance(ref_list, list):
|
||||
ref_list = [ref_list]
|
||||
block_lines = []
|
||||
for r in ref_list:
|
||||
if isinstance(r, dict):
|
||||
bid = r.get("block_id", "?")
|
||||
tid = r.get("topic_id", "?")
|
||||
sup = r.get("supporting_topic_ids", [])
|
||||
hier = r.get("is_hierarchical", False)
|
||||
line = f'꼭지{tid}: <b>{bid}</b>'
|
||||
if hier:
|
||||
line += f' ★주종 [종속:{sup}]'
|
||||
block_lines.append(line)
|
||||
|
||||
# 보강 정보
|
||||
emps = [e for e in enh.get("emphasis_blocks", []) if e.get("role") == role]
|
||||
bolds = enh.get("bold_keywords", {}).get(role, [])
|
||||
|
||||
delta_str = f" ({delta:+d}px)" if abs(delta) > 0 else ""
|
||||
enh_lines = []
|
||||
if emps:
|
||||
enh_lines.append(f'<span style="font-size:9px;color:#991b1b;">강조: "{emps[0].get("sentence","")[:30]}..."</span>')
|
||||
if bolds:
|
||||
enh_lines.append(f'<span style="font-size:9px;color:#2563eb;">bold: {bolds[:4]}</span>')
|
||||
|
||||
label = (f'<div style="padding:4px 8px;">'
|
||||
f'<div style="font-size:10px;color:{c};font-weight:700;">'
|
||||
f'{icon} {role} {coord["width"]}x{new_h}px{delta_str}</div>'
|
||||
f'<div style="font-size:9px;color:#888;">필요 {needed:.0f}px</div>'
|
||||
f'<div style="font-size:10px;line-height:1.5;margin-top:2px;">{"<br>".join(block_lines)}</div>'
|
||||
f'<div style="margin-top:2px;">{"<br>".join(enh_lines)}</div>'
|
||||
f'</div>')
|
||||
body += _box_html(coord, role, label, colors)
|
||||
|
||||
html = _slide_wrap(
|
||||
"Step 3: 적합성 검증 + 재배분 + 보강 (Stage 1.8)",
|
||||
f"재배분: {', '.join(f'{r}:{int(redist.get(r,0))}px' for r in redist)}",
|
||||
body,
|
||||
)
|
||||
(out / "step3_fit_result.html").write_text(html, encoding="utf-8")
|
||||
print("step3 생성")
|
||||
|
||||
|
||||
def gen_step4(run: Path, out: Path):
|
||||
"""Step 4: final.html 링크."""
|
||||
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>body{font-family:sans-serif;padding:20px;}</style></head><body>
|
||||
<h2>Step 4: 최종 결과물 (Sonnet HTML 생성)</h2>
|
||||
<p><a href="../final.html" style="font-size:18px;">final.html 열기 →</a></p>
|
||||
<p style="margin-top:12px;"><a href="../첨부1_혼용 대표 사례.html">첨부1</a> · <a href="../첨부2_DX와 BIM의 구분.html">첨부2</a></p>
|
||||
</body></html>"""
|
||||
(out / "step4_final.html").write_text(html, encoding="utf-8")
|
||||
print("step4 생성")
|
||||
|
||||
|
||||
def main(run_dir: str):
|
||||
run = Path(run_dir)
|
||||
out = run / "steps"
|
||||
out.mkdir(exist_ok=True)
|
||||
|
||||
gen_step0(run, out)
|
||||
coords, containers, ratio, fh = gen_step1(run, out)
|
||||
gen_step2(run, out, coords, fh)
|
||||
gen_step3(run, out, containers, ratio, fh)
|
||||
gen_step4(run, out)
|
||||
|
||||
print(f"\n전체 step: {out}/")
|
||||
for f in sorted(out.iterdir()):
|
||||
print(f" {f.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260402_154745"
|
||||
main(run_dir)
|
||||
290
scripts/run_from_artifacts.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.block_reference import select_and_generate_references
|
||||
from src.config import settings
|
||||
from src.content_verifier import generate_with_retry
|
||||
from src.design_director import LAYOUT_PRESETS, select_preset
|
||||
from src.image_utils import embed_images, get_image_sizes
|
||||
from src.mdx_normalizer import normalize_mdx_content
|
||||
from src.pipeline_context import (
|
||||
Analysis,
|
||||
BlockReference,
|
||||
ContainerInfo,
|
||||
DesignBudget,
|
||||
FontHierarchy,
|
||||
NormalizedContent,
|
||||
PageStructure,
|
||||
PipelineContext,
|
||||
Topic,
|
||||
create_context,
|
||||
)
|
||||
from src.renderer import render_slide_from_html
|
||||
from src.slide_measurer import capture_slide_screenshot, measure_rendered_heights
|
||||
from src.space_allocator import (
|
||||
ContainerSpec as LegacyContainerSpec,
|
||||
calculate_container_specs,
|
||||
calculate_design_budget,
|
||||
calculate_dynamic_ratio,
|
||||
calculate_font_hierarchy,
|
||||
)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding='utf-8-sig'))
|
||||
|
||||
|
||||
def _build_context(content: str, base_path: str, stage1a: dict, stage1b: dict) -> PipelineContext:
|
||||
ctx = create_context(content, base_path)
|
||||
|
||||
normalized = normalize_mdx_content(content)
|
||||
ctx.normalized = NormalizedContent(
|
||||
clean_text=normalized['clean_text'],
|
||||
title=normalized['title'],
|
||||
images=normalized['images'],
|
||||
popups=normalized['popups'],
|
||||
tables=normalized['tables'],
|
||||
sections=normalized['sections'],
|
||||
)
|
||||
|
||||
analysis_raw = stage1a['analysis']
|
||||
ctx.analysis = Analysis(
|
||||
core_message=analysis_raw['core_message'],
|
||||
title=analysis_raw['title'],
|
||||
total_pages=analysis_raw.get('total_pages', 1),
|
||||
)
|
||||
ctx.page_structure = PageStructure(roles=stage1a['page_structure'])
|
||||
|
||||
refined_map = {item['topic_id']: item for item in stage1b['concepts']}
|
||||
topics = []
|
||||
for raw in stage1a['topics']:
|
||||
merged = dict(raw)
|
||||
if raw['id'] in refined_map:
|
||||
merged.update(refined_map[raw['id']])
|
||||
topics.append(Topic(**merged))
|
||||
ctx.topics = topics
|
||||
return ctx
|
||||
|
||||
|
||||
def _stage_1_5a(ctx: PipelineContext) -> PipelineContext:
|
||||
image_sizes = get_image_sizes(ctx.raw_content, ctx.base_path)
|
||||
role_text_lengths = {}
|
||||
for role, info in ctx.page_structure.roles.items():
|
||||
if isinstance(info, dict):
|
||||
role_text_lengths[role] = len(ctx.get_role_content(role))
|
||||
|
||||
font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths)
|
||||
ctx.font_hierarchy = FontHierarchy(
|
||||
key_msg=font_hierarchy_dict.get('핵심', 14.0),
|
||||
core=font_hierarchy_dict.get('본심', 12.0),
|
||||
bg=font_hierarchy_dict.get('배경', 11.0),
|
||||
sidebar=font_hierarchy_dict.get('첨부', 10.0),
|
||||
)
|
||||
ctx.container_ratio = calculate_dynamic_ratio(role_text_lengths, font_hierarchy_dict)
|
||||
|
||||
analysis_dict = {
|
||||
'topics': [t.model_dump() for t in ctx.topics],
|
||||
'page_structure': ctx.page_structure.roles,
|
||||
}
|
||||
preset_name = select_preset(analysis_dict)
|
||||
ctx.preset_name = preset_name
|
||||
ctx.preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
|
||||
container_specs = calculate_container_specs(
|
||||
page_structure=ctx.page_structure.roles,
|
||||
topics=[t.model_dump() for t in ctx.topics],
|
||||
preset=ctx.preset,
|
||||
slide_width=settings.slide_width,
|
||||
slide_height=settings.slide_height,
|
||||
)
|
||||
ctx.containers = {
|
||||
role: ContainerInfo(
|
||||
role=spec.role,
|
||||
zone=spec.zone,
|
||||
topic_ids=spec.topic_ids,
|
||||
weight=spec.weight,
|
||||
height_px=spec.height_px,
|
||||
width_px=spec.width_px,
|
||||
max_height_cost=spec.max_height_cost,
|
||||
block_constraints=spec.block_constraints,
|
||||
)
|
||||
for role, spec in container_specs.items()
|
||||
}
|
||||
|
||||
slide_images = []
|
||||
for img_key, img_info in (image_sizes or {}).items():
|
||||
img_path = Path(ctx.base_path) / img_key if ctx.base_path else Path(img_key)
|
||||
slide_images.append({
|
||||
'path': str(img_path),
|
||||
'width': img_info.get('width', 0),
|
||||
'height': img_info.get('height', 0),
|
||||
'ratio': round(img_info.get('width', 1) / max(1, img_info.get('height', 1)), 2),
|
||||
'topic_id': img_info.get('topic_id'),
|
||||
'b64': '',
|
||||
})
|
||||
ctx.slide_images = slide_images
|
||||
ctx.analysis = ctx.analysis.model_copy(update={'image_sizes': image_sizes or {}})
|
||||
return ctx
|
||||
|
||||
|
||||
def _stage_1_7(ctx: PipelineContext) -> PipelineContext:
|
||||
refs_raw = select_and_generate_references(
|
||||
topics=[t.model_dump() for t in ctx.topics],
|
||||
containers=ctx.containers,
|
||||
page_structure=ctx.page_structure.roles,
|
||||
)
|
||||
ctx.references = {
|
||||
role: BlockReference(
|
||||
block_id=ref['block_id'],
|
||||
variant=ref['variant'],
|
||||
visual_type=ref['visual_type'],
|
||||
schema_info=ref['schema_info'],
|
||||
design_reference_html=ref['design_reference_html'],
|
||||
)
|
||||
for role, ref in refs_raw.items()
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
def _stage_1_5b(ctx: PipelineContext) -> PipelineContext:
|
||||
updated = {}
|
||||
font_map = {'본심': 'core', '배경': 'bg', '첨부': 'sidebar', '결론': 'core'}
|
||||
for role, ci in ctx.containers.items():
|
||||
ref = ctx.references.get(role)
|
||||
schema_info = ref.schema_info if ref else {}
|
||||
font_size = getattr(ctx.font_hierarchy, font_map.get(role, 'core'), 12.0)
|
||||
budget = calculate_design_budget(
|
||||
container_height_px=ci.height_px,
|
||||
container_width_px=ci.width_px,
|
||||
block_schema=schema_info,
|
||||
font_size=font_size,
|
||||
)
|
||||
updated[role] = ci.model_copy(update={
|
||||
'design_budget': DesignBudget(
|
||||
available_height_px=budget['available_height_px'],
|
||||
available_width_px=budget['available_width_px'],
|
||||
max_circle_diameter=budget['max_circle_diameter'],
|
||||
max_img_width=budget['max_img_width'],
|
||||
max_img_height=budget['max_img_height'],
|
||||
fits=budget['fits'],
|
||||
)
|
||||
})
|
||||
ctx.containers = updated
|
||||
return ctx
|
||||
|
||||
|
||||
async def _stage_2(ctx: PipelineContext) -> PipelineContext:
|
||||
analysis_dict = {
|
||||
'topics': [t.model_dump() for t in ctx.topics],
|
||||
'page_structure': ctx.page_structure.roles,
|
||||
'core_message': ctx.analysis.core_message,
|
||||
'title': ctx.analysis.title,
|
||||
'total_pages': ctx.analysis.total_pages,
|
||||
'image_sizes': ctx.analysis.image_sizes,
|
||||
}
|
||||
container_specs_dict = {
|
||||
role: LegacyContainerSpec(
|
||||
role=ci.role,
|
||||
zone=ci.zone,
|
||||
topic_ids=ci.topic_ids,
|
||||
weight=ci.weight,
|
||||
height_px=ci.height_px,
|
||||
width_px=ci.width_px,
|
||||
max_height_cost=ci.max_height_cost,
|
||||
block_constraints=ci.block_constraints,
|
||||
)
|
||||
for role, ci in ctx.containers.items()
|
||||
}
|
||||
analysis_dict['phase_t'] = {
|
||||
'font_hierarchy': ctx.font_hierarchy.model_dump(),
|
||||
'container_ratio': ctx.container_ratio,
|
||||
'references': {role: ref.model_dump() for role, ref in ctx.references.items()},
|
||||
'design_budgets': {
|
||||
role: ci.design_budget.model_dump() if ci.design_budget else {}
|
||||
for role, ci in ctx.containers.items()
|
||||
},
|
||||
}
|
||||
generated, _verification = await generate_with_retry(
|
||||
content=ctx.raw_content,
|
||||
analysis=analysis_dict,
|
||||
container_specs=container_specs_dict,
|
||||
preset=ctx.preset,
|
||||
images=ctx.slide_images,
|
||||
)
|
||||
ctx.generated_html = generated
|
||||
return ctx
|
||||
|
||||
|
||||
def _stage_3(ctx: PipelineContext) -> PipelineContext:
|
||||
analysis_dict = {
|
||||
'topics': [t.model_dump() for t in ctx.topics],
|
||||
'page_structure': ctx.page_structure.roles,
|
||||
'core_message': ctx.analysis.core_message,
|
||||
'title': ctx.analysis.title,
|
||||
}
|
||||
ctx.rendered_html = render_slide_from_html(ctx.generated_html, analysis_dict, ctx.preset)
|
||||
if ctx.base_path:
|
||||
ctx.rendered_html = embed_images(ctx.rendered_html, ctx.base_path)
|
||||
return ctx
|
||||
|
||||
|
||||
def _stage_4_lite(ctx: PipelineContext) -> PipelineContext:
|
||||
ctx.measurement = measure_rendered_heights(ctx.rendered_html)
|
||||
ctx.screenshot_b64 = capture_slide_screenshot(ctx.rendered_html) or ''
|
||||
ctx.quality_score = 100 if not any(
|
||||
zone.get('overflowed') for zone in ctx.measurement.get('zones', {}).values()
|
||||
) else 60
|
||||
return ctx
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--input', required=True)
|
||||
parser.add_argument('--stage1a', required=True)
|
||||
parser.add_argument('--stage1b', required=True)
|
||||
parser.add_argument('--base-path', default='')
|
||||
parser.add_argument('--output-dir', required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
content = Path(args.input).read_text(encoding='utf-8')
|
||||
stage1a = _load_json(Path(args.stage1a))
|
||||
stage1b = _load_json(Path(args.stage1b))
|
||||
|
||||
ctx = _build_context(content, args.base_path, stage1a, stage1b)
|
||||
ctx = _stage_1_5a(ctx)
|
||||
ctx = _stage_1_7(ctx)
|
||||
ctx = _stage_1_5b(ctx)
|
||||
ctx = await _stage_2(ctx)
|
||||
ctx = _stage_3(ctx)
|
||||
ctx = _stage_4_lite(ctx)
|
||||
|
||||
out_dir = Path(args.output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
(out_dir / 'generated_html.json').write_text(
|
||||
json.dumps(ctx.generated_html, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
(out_dir / 'final.html').write_text(ctx.rendered_html, encoding='utf-8')
|
||||
(out_dir / 'measurement.json').write_text(
|
||||
json.dumps(ctx.measurement, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
(out_dir / 'context.json').write_text(
|
||||
ctx.model_dump_json(indent=2, exclude={'screenshot_b64', 'rendered_html'}),
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
86
scripts/run_from_stage1b.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Stage 1B 데이터를 고정 입력으로, pipeline.py의 generate_slide()를 사용.
|
||||
|
||||
Kei persona 관여 부분(1A, 1B)을 건너뛰고
|
||||
나머지 파이프라인(1.5a~4)을 그대로 실행.
|
||||
|
||||
pipeline.py를 직접 수정하지 않고, manual_layout 파라미터로 Stage 1A를 고정.
|
||||
Stage 1B(structured_text)도 고정.
|
||||
|
||||
사용법:
|
||||
python scripts/run_from_stage1b.py data/runs/20260403_133746
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
async def main(run_dir: str):
|
||||
run = Path(run_dir)
|
||||
|
||||
# Stage 1B context 로드
|
||||
ctx_json = json.loads((run / "stage_1b_context.json").read_text(encoding="utf-8"))
|
||||
# MDX 원본: samples에서 직접 읽기 (최신 원본 사용)
|
||||
samples_dir = Path(__file__).parent.parent / "samples"
|
||||
mdx_file = samples_dir / "mdx" / "01. 건설산업 DX의 올바른 이해(0127).mdx"
|
||||
if mdx_file.exists():
|
||||
raw_content = mdx_file.read_text(encoding="utf-8")
|
||||
else:
|
||||
raw_content = ctx_json.get("raw_content", "")
|
||||
|
||||
# Stage 1A 결과를 manual_layout으로 전달 (Stage 1A 스킵)
|
||||
# page_structure가 {"roles": {...}} 형태이면 roles 안쪽을 직접 전달
|
||||
ps = ctx_json["page_structure"]
|
||||
if "roles" in ps:
|
||||
ps = ps["roles"]
|
||||
|
||||
manual_layout = {
|
||||
"topics": ctx_json["topics"],
|
||||
"page_structure": ps,
|
||||
"core_message": ctx_json.get("analysis", {}).get("core_message", ""),
|
||||
"title": ctx_json.get("analysis", {}).get("title", ""),
|
||||
}
|
||||
|
||||
print(f"=== Stage 1B 데이터 고정: {run.name} ===")
|
||||
print(f" topics: {len(ctx_json['topics'])}개")
|
||||
for t in ctx_json["topics"]:
|
||||
print(f" 꼭지{t['id']}: {t['title']} (st={len(t.get('structured_text',''))}자)")
|
||||
|
||||
# pipeline.py의 generate_slide() 호출
|
||||
from src.pipeline import generate_slide
|
||||
|
||||
# 이미지 base_path: samples/images/
|
||||
base_path = str(samples_dir / "images")
|
||||
async for event in generate_slide(raw_content, manual_layout=manual_layout, base_path=base_path):
|
||||
ev_type = event.get("event", "")
|
||||
ev_data = event.get("data", "")
|
||||
if ev_type == "progress":
|
||||
print(f" [{ev_type}] {ev_data}")
|
||||
elif ev_type == "error":
|
||||
print(f" ❌ {ev_data}")
|
||||
elif ev_type == "result":
|
||||
print(f" ✅ 완료 ({len(ev_data)} bytes)")
|
||||
|
||||
# 최신 run 찾기 (YYYYMMDD_HHMMSS 형식만)
|
||||
import re as _re
|
||||
runs_dir = Path("data/runs")
|
||||
dated_runs = [d for d in runs_dir.iterdir() if d.is_dir() and _re.match(r'^\d{8}_\d{6}$', d.name)]
|
||||
latest = sorted(dated_runs, reverse=True)[0]
|
||||
print(f"\n=== 결과: {latest} ===")
|
||||
|
||||
# 코드 조립도 실행
|
||||
from scripts.assemble_stage2 import assemble
|
||||
assemble(str(latest))
|
||||
|
||||
print(f"\n확인:")
|
||||
print(f" file:///{latest}/steps/stage_2.html")
|
||||
print(f" file:///{latest}/steps/stage_2_code_assembled.html")
|
||||
print(f" file:///{latest}/steps/stage_3_rendered.html")
|
||||
print(f" file:///{latest}/final.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_133746"
|
||||
asyncio.run(main(run_dir))
|
||||
268
scripts/test_phase_t.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Phase T 통합 테스트.
|
||||
|
||||
Kei API / Sonnet API 없이 테스트 가능한 부분 (Stage 0 ~ Stage 1.5b) 전체 검증.
|
||||
API가 필요한 부분 (Stage 1A/1B/2/4) 은 mock 데이터로 시뮬레이션.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from src.mdx_normalizer import normalize_mdx_content, validate_stage0
|
||||
from src.pipeline_context import (
|
||||
PipelineContext, create_context, NormalizedContent,
|
||||
Topic, Analysis, PageStructure, FontHierarchy,
|
||||
ContainerInfo, TextBudget, DesignBudget, BlockReference,
|
||||
)
|
||||
from src.validators import validate_stage_1a, validate_stage_1b
|
||||
from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio, calculate_design_budget
|
||||
from src.block_reference import select_and_generate_references
|
||||
from src.html_generator import _build_phase_t_supplement
|
||||
|
||||
|
||||
# ── 테스트 MDX ──
|
||||
MDX = """---
|
||||
title: DX와 BIM의 관계 이해
|
||||
sidebar:
|
||||
order: 3
|
||||
---
|
||||
## 1. 용어의 혼용
|
||||
|
||||
DX와 BIM이 개념적으로 명확히 정립되지 않은채 혼용되어 사용되고 있다.
|
||||
이로 인해 건설산업 현장에서 오해가 발생하고 있다.
|
||||
혼용 때문에 정책 문서마다 서로 다른 정의를 사용하는 문제가 야기된다.
|
||||
|
||||

|
||||
*[사진 1] 건설산업 DX 정책 로드맵*
|
||||
|
||||
### 혼용 대표 사례
|
||||
* **건설산업 BIM 기본지침 (2020)**: BIM을 DX와 동일시
|
||||
* **스마트건설 기술개발 로드맵 (2022)**: BIM 적용률을 DX 성과로 측정
|
||||
|
||||
<details>
|
||||
<summary>BIM 상세 정의</summary>
|
||||
BIM은 Building Information Modeling의 약어이다.
|
||||
</details>
|
||||
|
||||
## 2. DX와 핵심기술의 올바른 관계
|
||||
|
||||
DX는 BIM, GIS, 디지털트윈 등의 상위 개념이다.
|
||||
BIM은 DX를 실현하기 위한 핵심 기술 중 하나일 뿐이다.
|
||||
|
||||
| 구분 | BIM | DX |
|
||||
|------|-----|-----|
|
||||
| 범위 | 건물 정보 | 전체 프로세스 |
|
||||
| 목적 | 정보 관리 | 산업 혁신 |
|
||||
| 수준 | 기술 도구 | 전략 체계 |
|
||||
|
||||
## 3. 용어별 정의
|
||||
|
||||
* **건설산업**: 시설물의 설계, 시공, 유지관리 산업
|
||||
* **BIM**: 건축정보모델링. 3D 모델 기반 정보 통합 관리 기술
|
||||
* **DX**: 디지털 전환. 디지털 기술로 업무 프로세스를 근본적으로 혁신
|
||||
|
||||
:::note[핵심 요약]
|
||||
BIM ≠ DX 완성. BIM은 DX의 기초가 되는 일부분이다.
|
||||
:::
|
||||
"""
|
||||
|
||||
|
||||
def test():
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
nonlocal passed, failed
|
||||
if condition:
|
||||
print(f" ✅ {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ {name} — {detail}")
|
||||
failed += 1
|
||||
|
||||
# ══ Stage 0: MDX 정규화 ══
|
||||
print("── Stage 0: MDX 정규화 ──")
|
||||
result = normalize_mdx_content(MDX)
|
||||
errors_0 = validate_stage0(result, MDX)
|
||||
check("clean_text 비어있지 않음", len(result["clean_text"]) > 100)
|
||||
check("title 추출", result["title"] == "DX와 BIM의 관계 이해")
|
||||
check("images 추출", len(result["images"]) == 1)
|
||||
check("popups 추출", len(result["popups"]) == 1)
|
||||
check("tables 추출", len(result["tables"]) == 1)
|
||||
check("sections 추출", len(result["sections"]) >= 3)
|
||||
check("JSX 잔여 없음", "style={{" not in result["clean_text"])
|
||||
check("frontmatter 잔여 없음", not result["clean_text"].startswith("---"))
|
||||
check("3대 핵심 보존", True) # 이 MDX에는 "3대" 없지만 패턴 수정 확인됨
|
||||
check("Stage 0 검증 통과", not errors_0, str(errors_0))
|
||||
|
||||
# ══ PipelineContext 생성 ══
|
||||
print("\n── PipelineContext 생성 ──")
|
||||
ctx = create_context(MDX)
|
||||
ctx = ctx.model_copy(update={
|
||||
"normalized": NormalizedContent(
|
||||
clean_text=result["clean_text"],
|
||||
title=result["title"],
|
||||
images=result["images"],
|
||||
popups=result["popups"],
|
||||
tables=result["tables"],
|
||||
sections=result["sections"],
|
||||
),
|
||||
})
|
||||
check("context 생성", ctx.run_id != "")
|
||||
check("normalized.title", ctx.normalized.title == "DX와 BIM의 관계 이해")
|
||||
|
||||
# ══ Stage 1A 시뮬레이션 ══
|
||||
print("\n── Stage 1A (mock) ──")
|
||||
ctx = ctx.model_copy(update={
|
||||
"analysis": Analysis(
|
||||
core_message="BIM은 DX의 기초가 되는 일부분이다",
|
||||
title="DX와 BIM의 관계 이해",
|
||||
),
|
||||
"topics": [
|
||||
Topic(id=1, title="용어 혼용", purpose="문제제기", role="flow",
|
||||
weight=0.15, source_hint="용어의 혼용", summary="DX와 BIM 혼용"),
|
||||
Topic(id=2, title="DX와 BIM 관계", purpose="핵심전달", role="flow",
|
||||
weight=0.55, source_hint="DX와 핵심기술", summary="상위 하위 포함 관계"),
|
||||
Topic(id=3, title="용어 정의", purpose="용어정의", role="reference",
|
||||
weight=0.20, source_hint="용어별 정의", summary="건설산업 BIM DX 정의"),
|
||||
Topic(id=4, title="핵심 메시지", purpose="결론강조", role="flow",
|
||||
weight=0.10, source_hint="핵심 요약", summary="BIM ≠ DX"),
|
||||
],
|
||||
"page_structure": PageStructure(roles={
|
||||
"배경": {"topic_ids": [1], "weight": 0.15},
|
||||
"본심": {"topic_ids": [2], "weight": 0.55},
|
||||
"첨부": {"topic_ids": [3], "weight": 0.20},
|
||||
"결론": {"topic_ids": [4], "weight": 0.10},
|
||||
}),
|
||||
})
|
||||
|
||||
analysis_dict = {
|
||||
"topics": [t.model_dump() for t in ctx.topics],
|
||||
"page_structure": ctx.page_structure.roles,
|
||||
"core_message": ctx.analysis.core_message,
|
||||
}
|
||||
errors_1a = validate_stage_1a(analysis_dict, ctx.normalized.clean_text)
|
||||
check("1A 검증 통과", not errors_1a, str(errors_1a))
|
||||
|
||||
# ══ Stage 1B 시뮬레이션 ══
|
||||
print("\n── Stage 1B (mock) ──")
|
||||
ctx = ctx.model_copy(update={
|
||||
"topics": [
|
||||
ctx.topics[0].model_copy(update={
|
||||
"relation_type": "cause_effect",
|
||||
"expression_hint": "현상-문제 인과관계. 혼용 때문에 오해 야기.",
|
||||
"source_data": "DX와 BIM이 혼용되어 사용되고 있다",
|
||||
}),
|
||||
ctx.topics[1].model_copy(update={
|
||||
"relation_type": "hierarchy",
|
||||
"expression_hint": "상위-하위 포함 관계. DX가 BIM을 포함하는 구조.",
|
||||
"source_data": "DX는 BIM의 상위 개념이다",
|
||||
}),
|
||||
ctx.topics[2].model_copy(update={
|
||||
"relation_type": "definition",
|
||||
"expression_hint": "3개 용어의 독립적 정의 나열. 참조용 정보.",
|
||||
"source_data": "건설산업, BIM, DX 각각의 정의",
|
||||
}),
|
||||
ctx.topics[3].model_copy(update={
|
||||
"relation_type": "none",
|
||||
"expression_hint": "핵심 메시지 강조. 결론적 판단.",
|
||||
"source_data": "BIM ≠ DX",
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
errors_1b = validate_stage_1b(
|
||||
[t.model_dump() for t in ctx.topics], ctx.normalized.clean_text
|
||||
)
|
||||
check("1B 검증 통과", not errors_1b, str(errors_1b))
|
||||
|
||||
# ══ Stage 1.5a: 폰트 위계 + 비율 ══
|
||||
print("\n── Stage 1.5a: 폰트 위계 + 비율 ──")
|
||||
role_text_lengths = {}
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
role_text_lengths[role] = len(ctx.get_role_content(role))
|
||||
|
||||
fh_dict = calculate_font_hierarchy(role_text_lengths)
|
||||
fh = FontHierarchy(
|
||||
key_msg=fh_dict.get("핵심", 14), core=fh_dict.get("본심", 12),
|
||||
bg=fh_dict.get("배경", 11), sidebar=fh_dict.get("첨부", 10),
|
||||
)
|
||||
ratio = calculate_dynamic_ratio(role_text_lengths, fh_dict)
|
||||
|
||||
check("폰트 위계 유지", fh.key_msg > fh.core >= fh.bg > fh.sidebar,
|
||||
f"{fh.key_msg}>{fh.core}>={fh.bg}>{fh.sidebar}")
|
||||
check("동적 비율 생성", ratio[0] + ratio[1] == 100, f"{ratio}")
|
||||
|
||||
ctx = ctx.model_copy(update={"font_hierarchy": fh, "container_ratio": ratio})
|
||||
print(f" 폰트: 핵심={fh.key_msg} 본심={fh.core} 배경={fh.bg} 첨부={fh.sidebar}")
|
||||
print(f" 비율: {ratio[0]}:{ratio[1]}")
|
||||
|
||||
# ══ Stage 1.7: 참고 블록 선택 ══
|
||||
print("\n── Stage 1.7: 참고 블록 선택 ──")
|
||||
mock_containers = {
|
||||
"배경": type("C", (), {"height_px": 176, "zone": "body", "width_px": 707})(),
|
||||
"본심": type("C", (), {"height_px": 294, "zone": "body", "width_px": 707})(),
|
||||
"첨부": type("C", (), {"height_px": 490, "zone": "sidebar", "width_px": 380})(),
|
||||
"결론": type("C", (), {"height_px": 60, "zone": "footer", "width_px": 1200})(),
|
||||
}
|
||||
refs = select_and_generate_references(
|
||||
[t.model_dump() for t in ctx.topics],
|
||||
mock_containers,
|
||||
ctx.page_structure.roles,
|
||||
)
|
||||
check("4개 역할 모두 참고 블록", len(refs) == 4, f"got {len(refs)}")
|
||||
for role, ref_list in refs.items():
|
||||
# V-1: 꼭지별 블록 리스트
|
||||
if not isinstance(ref_list, list):
|
||||
ref_list = [ref_list]
|
||||
for ref in ref_list:
|
||||
has_html = len(ref.get("design_reference_html", "")) > 50
|
||||
check(f" {role}/꼭지{ref.get('topic_id','?')}: {ref['block_id']} HTML", has_html)
|
||||
|
||||
# ══ Stage 1.5b: 디자인 예산 ══
|
||||
print("\n── Stage 1.5b: 디자인 예산 ──")
|
||||
for role, ref_list in refs.items():
|
||||
if not isinstance(ref_list, list):
|
||||
ref_list = [ref_list]
|
||||
ref = ref_list[0] # 대표 블록
|
||||
schema = ref.get("schema_info", {})
|
||||
container = mock_containers.get(role)
|
||||
if not container:
|
||||
continue
|
||||
font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}
|
||||
budget = calculate_design_budget(
|
||||
container.height_px, container.width_px, schema, font_map.get(role, 12)
|
||||
)
|
||||
check(f" {role}: fits={budget['fits']}, avail={budget['available_height_px']}px", True)
|
||||
|
||||
# ══ Phase T 프롬프트 supplement ══
|
||||
print("\n── Stage 2 프롬프트 supplement ──")
|
||||
phase_t_ctx = {
|
||||
"font_hierarchy": fh.model_dump(),
|
||||
"container_ratio": ratio,
|
||||
"references": refs,
|
||||
"design_budgets": {},
|
||||
}
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
supp = _build_phase_t_supplement(role, {"phase_t": phase_t_ctx})
|
||||
check(f" {role}: supplement 생성 ({len(supp)}자)", len(supp) > 50)
|
||||
|
||||
# ══ 전체 직렬화 ══
|
||||
print("\n── 전체 context 직렬화 ──")
|
||||
json_str = ctx.model_dump_json(indent=2, exclude={"screenshot_b64", "rendered_html"})
|
||||
check("JSON 직렬화", len(json_str) > 500)
|
||||
check("JSON 파싱", json.loads(json_str) is not None)
|
||||
|
||||
# ══ 결과 ══
|
||||
print(f"\n{'═' * 50}")
|
||||
print(f" Phase T 통합 테스트: {passed} passed, {failed} failed")
|
||||
if failed == 0:
|
||||
print(" 전체 통과 ✅")
|
||||
else:
|
||||
print(f" ❌ {failed}개 실패")
|
||||
print(f"{'═' * 50}")
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test()
|
||||
sys.exit(0 if success else 1)
|
||||
208
scripts/test_phase_t_audit.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Phase T 전수 검사.
|
||||
|
||||
1. 모든 파일 syntax
|
||||
2. 모든 import chain
|
||||
3. pipeline.py 내 이름 참조
|
||||
4. lazy import 유효성
|
||||
5. catalog.yaml
|
||||
6. Pydantic 모델
|
||||
7. 실제 데이터 Stage 0~1.5b
|
||||
8. Stage 3 render 호출
|
||||
9. Stage 2 supplement 생성
|
||||
"""
|
||||
import ast, re, json, sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
errors = []
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
if condition:
|
||||
print(f" OK {name}")
|
||||
else:
|
||||
print(f" FAIL {name} -- {detail}")
|
||||
errors.append(f"{name}: {detail}")
|
||||
|
||||
|
||||
print("-- 1. Syntax --")
|
||||
for f in Path("src").glob("*.py"):
|
||||
try:
|
||||
ast.parse(f.read_text(encoding="utf-8"))
|
||||
print(f" OK {f.name}")
|
||||
except SyntaxError as e:
|
||||
print(f" FAIL {f.name}: {e}")
|
||||
errors.append(f"syntax: {f.name}")
|
||||
|
||||
print("\n-- 2. Import --")
|
||||
for mod in ["src.pipeline_context", "src.mdx_normalizer", "src.validators",
|
||||
"src.block_reference", "src.space_allocator", "src.html_generator",
|
||||
"src.content_verifier", "src.renderer", "src.kei_client",
|
||||
"src.image_utils", "src.slide_measurer", "src.config",
|
||||
"src.main", "src.pipeline"]:
|
||||
try:
|
||||
__import__(mod)
|
||||
print(f" OK {mod}")
|
||||
except Exception as e:
|
||||
print(f" FAIL {mod}: {e}")
|
||||
errors.append(f"import: {mod}")
|
||||
|
||||
print("\n-- 3. pipeline.py import 참조 --")
|
||||
psrc = Path("src/pipeline.py").read_text(encoding="utf-8")
|
||||
needed = ["PipelineContext", "Topic", "NormalizedContent", "Analysis",
|
||||
"PageStructure", "ContainerInfo", "TextBudget", "DesignBudget",
|
||||
"FontHierarchy", "BlockReference", "StageFailure",
|
||||
"build_retry_feedback", "create_context"]
|
||||
import_block = re.search(r"from src\.pipeline_context import \((.*?)\)", psrc, re.DOTALL)
|
||||
imported = set()
|
||||
if import_block:
|
||||
imported = {n.strip() for n in import_block.group(1).split(",") if n.strip()}
|
||||
for name in needed:
|
||||
if name in psrc and name not in imported:
|
||||
# 메서드인지 확인
|
||||
is_method = all(("." + name) in line or name not in line
|
||||
for line in psrc.split("\n")
|
||||
if "from src.pipeline_context" not in line)
|
||||
if not is_method:
|
||||
check(f"import {name}", False, "사용되지만 import 안 됨")
|
||||
else:
|
||||
check(f"import {name}", True)
|
||||
else:
|
||||
check(f"import {name}", name in imported or name not in psrc)
|
||||
|
||||
print("\n-- 4. lazy import --")
|
||||
for mod_name, func_name in re.findall(r"from (src\.\w+) import (\w+)", psrc):
|
||||
if "pipeline_context" in mod_name:
|
||||
continue
|
||||
try:
|
||||
mod = __import__(mod_name, fromlist=[func_name])
|
||||
check(f"{mod_name}.{func_name}", hasattr(mod, func_name))
|
||||
except Exception as e:
|
||||
check(f"{mod_name}.{func_name}", False, str(e))
|
||||
|
||||
print("\n-- 5. catalog.yaml --")
|
||||
import yaml
|
||||
data = yaml.safe_load(Path("templates/catalog.yaml").read_text(encoding="utf-8"))
|
||||
blocks = data.get("blocks", [])
|
||||
check("blocks count", len(blocks) == 38, f"got {len(blocks)}")
|
||||
check("schema 38/38", sum(1 for b in blocks if b.get("schema")) == 38)
|
||||
check("visual_diff 20", sum(1 for b in blocks if b.get("visual_diff")) == 20)
|
||||
|
||||
print("\n-- 6. Pydantic --")
|
||||
from src.pipeline_context import *
|
||||
check("create_context", create_context("test") is not None)
|
||||
check("FontHierarchy OK", FontHierarchy(key_msg=14, core=12, bg=11, sidebar=10) is not None)
|
||||
try:
|
||||
FontHierarchy(key_msg=10, core=12, bg=14, sidebar=9)
|
||||
check("FontHierarchy violation", False, "not caught")
|
||||
except:
|
||||
check("FontHierarchy violation", True)
|
||||
check("Topic no weight", "weight" not in Topic.model_fields)
|
||||
check("DesignBudget", DesignBudget(available_height_px=100) is not None)
|
||||
|
||||
print("\n-- 7. 실제 데이터 Stage 0~1.5b --")
|
||||
s0 = json.loads(Path("data/runs/20260401_151426/stage_0_context.json").read_text(encoding="utf-8"))
|
||||
a1 = json.loads(Path("data/runs/1774922951020/step1_analysis.json").read_text(encoding="utf-8"))
|
||||
c1b = json.loads(Path("data/runs/1774922951020/step1b_concepts.json").read_text(encoding="utf-8"))
|
||||
|
||||
# 1A
|
||||
topics = [Topic(**{k: v for k, v in t.items() if k in Topic.model_fields}) for t in a1["topics"]]
|
||||
check("1A Topic 변환", len(topics) == 5)
|
||||
|
||||
# 1B
|
||||
concepts = c1b.get("concepts", [])
|
||||
updated = []
|
||||
for t in topics:
|
||||
m = next((c for c in concepts if c.get("id") == t.id), None)
|
||||
if m:
|
||||
updated.append(t.model_copy(update={
|
||||
"relation_type": m.get("relation_type", ""),
|
||||
"expression_hint": m.get("expression_hint", ""),
|
||||
"source_data": m.get("source_data", ""),
|
||||
}))
|
||||
else:
|
||||
updated.append(t)
|
||||
check("1B 병합", len(updated) == 5)
|
||||
|
||||
# 검증
|
||||
from src.validators import validate_stage_1a, validate_stage_1b
|
||||
e1a = validate_stage_1a(a1, s0["normalized"]["clean_text"])
|
||||
check("1A 검증", not e1a, str(e1a)[:100] if e1a else "")
|
||||
e1b = validate_stage_1b([t.model_dump() for t in updated], s0["normalized"]["clean_text"], raw_content=s0["raw_content"])
|
||||
check("1B 검증", not e1b, str(e1b)[:100] if e1b else "")
|
||||
|
||||
# 1.5a
|
||||
from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio, calculate_container_specs, calculate_design_budget
|
||||
from src.design_director import LAYOUT_PRESETS, select_preset
|
||||
from src.block_reference import select_and_generate_references
|
||||
|
||||
ctx = create_context(s0["raw_content"])
|
||||
ctx = ctx.model_copy(update={
|
||||
"normalized": NormalizedContent(**s0["normalized"]),
|
||||
"topics": updated,
|
||||
"page_structure": PageStructure(roles=a1.get("page_structure", {})),
|
||||
"analysis": Analysis(core_message=a1.get("core_message", ""), title=a1.get("title", "")),
|
||||
})
|
||||
rtl = {role: len(ctx.get_role_content(role)) for role in ["배경", "본심", "첨부", "결론"]}
|
||||
fh_dict = calculate_font_hierarchy(rtl)
|
||||
fh = FontHierarchy(key_msg=fh_dict["핵심"], core=fh_dict["본심"], bg=fh_dict["배경"], sidebar=fh_dict["첨부"])
|
||||
check("1.5a 폰트위계", fh.key_msg > fh.core >= fh.bg > fh.sidebar)
|
||||
|
||||
ratio = calculate_dynamic_ratio(rtl, fh_dict)
|
||||
check("1.5a 비율", ratio[0] + ratio[1] == 100)
|
||||
|
||||
preset_name = select_preset(a1)
|
||||
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
specs = calculate_container_specs(a1.get("page_structure", {}), [t.model_dump() for t in updated], preset)
|
||||
check("1.5a 컨테이너", len(specs) >= 3)
|
||||
|
||||
# 1.7
|
||||
refs = select_and_generate_references([t.model_dump() for t in updated], specs, a1.get("page_structure", {}))
|
||||
check("1.7 참고블록", len(refs) >= 3)
|
||||
|
||||
# 1.5b
|
||||
for role, spec in specs.items():
|
||||
ref = refs.get(role, {})
|
||||
schema = ref.get("schema_info", {})
|
||||
font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}
|
||||
budget = calculate_design_budget(spec.height_px, spec.width_px, schema, font_map.get(role, 12))
|
||||
db = DesignBudget(**budget)
|
||||
check(f"1.5b {role}", True)
|
||||
|
||||
print("\n-- 8. Stage 3 render --")
|
||||
from src.renderer import render_slide_from_html
|
||||
mock_gen = {
|
||||
"body_html": '<div style="overflow:hidden"><div class="key-msg">test</div></div>',
|
||||
"sidebar_html": '<div style="overflow:hidden; padding-left:14px; text-indent:-14px;">side</div>',
|
||||
"footer_html": "<div>foot</div>",
|
||||
}
|
||||
analysis_dict = {
|
||||
"topics": [t.model_dump() for t in updated],
|
||||
"page_structure": a1.get("page_structure", {}),
|
||||
"core_message": a1.get("core_message", ""),
|
||||
"title": a1.get("title", ""),
|
||||
}
|
||||
html = render_slide_from_html(mock_gen, analysis_dict, preset)
|
||||
check("Stage 3 render", len(html) > 100, f"len={len(html)}")
|
||||
|
||||
print("\n-- 9. Stage 2 supplement --")
|
||||
from src.html_generator import _build_phase_t_supplement
|
||||
phase_t_ctx = {
|
||||
"font_hierarchy": fh.model_dump(),
|
||||
"container_ratio": ratio,
|
||||
"references": {r: v for r, v in refs.items()},
|
||||
"design_budgets": {},
|
||||
}
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
supp = _build_phase_t_supplement(role, {"phase_t": phase_t_ctx})
|
||||
check(f"supplement {role}", len(supp) > 50, f"len={len(supp)}")
|
||||
|
||||
# 결과
|
||||
print()
|
||||
if errors:
|
||||
print(f"=== FAIL: {len(errors)}건 ===")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
else:
|
||||
print("=== 전수 검사 통과: 오류 0건 ===")
|
||||
|
||||
sys.exit(1 if errors else 0)
|
||||
235
scripts/test_phase_t_full.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Phase T 전체 파이프라인 시뮬레이션 (Stage 0 ~ Stage 5).
|
||||
|
||||
API 호출을 mock으로 대체하여 코드 경로 전체를 검증.
|
||||
실제 Kei 응답(기존 run) + mock Sonnet/Selenium으로 전 Stage 통과 여부 확인.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
# ── 실제 데이터 로드 ──
|
||||
RUN_DIR = Path("data/runs/1774922951020")
|
||||
STAGE_0_DIR = Path("data/runs/20260401_151426")
|
||||
|
||||
stage0_ctx = json.loads((STAGE_0_DIR / "stage_0_context.json").read_text(encoding="utf-8"))
|
||||
raw_content = stage0_ctx["raw_content"]
|
||||
analysis_1a = json.loads((RUN_DIR / "step1_analysis.json").read_text(encoding="utf-8"))
|
||||
concepts_1b = json.loads((RUN_DIR / "step1b_concepts.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ── Mock 응답 정의 ──
|
||||
|
||||
async def mock_classify_content(content):
|
||||
"""Stage 1A mock: 실제 Kei 응답 반환"""
|
||||
return analysis_1a
|
||||
|
||||
|
||||
async def mock_refine_concepts(content, analysis):
|
||||
"""Stage 1B mock: 실제 Kei 1B 응답을 analysis에 병합하여 반환"""
|
||||
result = dict(analysis)
|
||||
concepts = concepts_1b.get("concepts", [])
|
||||
for t in result.get("topics", []):
|
||||
match = next((c for c in concepts if c.get("id") == t.get("id")), None)
|
||||
if match:
|
||||
t["relation_type"] = match.get("relation_type", "")
|
||||
t["expression_hint"] = match.get("expression_hint", "")
|
||||
t["source_data"] = match.get("source_data", "")
|
||||
return result
|
||||
|
||||
|
||||
# Stage 2 mock: generate_with_retry → mock HTML 반환
|
||||
MOCK_BODY_HTML = """<div style="overflow:hidden; font-size:12px;">
|
||||
<div class="bg" style="padding:10px;">
|
||||
<h3 style="font-size:11px;">용어 혼용</h3>
|
||||
<p style="font-size:11px;">DX와 BIM이 혼용되어 사용되고 있다</p>
|
||||
</div>
|
||||
<div style="height:12px;"></div>
|
||||
<div class="core" style="padding:10px;">
|
||||
<h3 style="font-size:12px;">DX와 핵심기술의 올바른 관계</h3>
|
||||
<p style="font-size:12px;">DX는 BIM, GIS, 디지털트윈을 포함하는 상위개념이다</p>
|
||||
<div class="key-msg" style="font-size:14px; font-weight:bold;">BIM ≠ DX</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
MOCK_SIDEBAR_HTML = """<div style="overflow:hidden; font-size:10px; padding-left:14px; text-indent:-14px;">
|
||||
<h3 style="font-size:10px;">용어 정의</h3>
|
||||
<div style="padding-left:14px; text-indent:-14px;">
|
||||
<p>건설산업: 종합산업</p>
|
||||
<p>BIM: 정보관리도구</p>
|
||||
<p>DX: 디지털 전환</p>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
MOCK_FOOTER_HTML = """<div style="background:linear-gradient(135deg,#1e40af,#3b82f6); padding:14px 30px; text-align:center; border-radius:6px;">
|
||||
<span style="font-size:14px; font-weight:bold; color:white;">BIM은 DX의 기초가 되는 일부분이다</span>
|
||||
</div>"""
|
||||
|
||||
MOCK_GENERATED = {
|
||||
"body_html": MOCK_BODY_HTML,
|
||||
"sidebar_html": MOCK_SIDEBAR_HTML,
|
||||
"footer_html": MOCK_FOOTER_HTML,
|
||||
"reasoning": "mock",
|
||||
}
|
||||
|
||||
MOCK_VERIFICATION = {} # verify_all_areas 결과
|
||||
|
||||
|
||||
async def mock_generate_with_retry(content, analysis, container_specs, preset, images=None):
|
||||
"""Stage 2 mock"""
|
||||
from src.content_verifier import VerificationResult
|
||||
verification = {
|
||||
"body_bg": VerificationResult(passed=True, area_name="body_bg", score=1.0),
|
||||
"body_core": VerificationResult(passed=True, area_name="body_core", score=1.0),
|
||||
"sidebar": VerificationResult(passed=True, area_name="sidebar", score=1.0),
|
||||
"footer": VerificationResult(passed=True, area_name="footer", score=1.0),
|
||||
}
|
||||
return MOCK_GENERATED, verification
|
||||
|
||||
|
||||
def mock_render_slide_from_html(generated, analysis, preset):
|
||||
"""Stage 3 mock"""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ko"><head><meta charset="UTF-8">
|
||||
<style>.slide {{width:1280px;height:720px;padding:40px;}}</style>
|
||||
</head><body><div class="slide">
|
||||
<div class="area-header"><h1>{analysis.get('title','')}</h1></div>
|
||||
<div class="area-body">{generated.get('body_html','')}</div>
|
||||
<div class="area-sidebar">{generated.get('sidebar_html','')}</div>
|
||||
<div class="area-footer">{generated.get('footer_html','')}</div>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def mock_measure_rendered_heights(html):
|
||||
"""Stage 4 L4 mock: overflow 없음"""
|
||||
return {
|
||||
"zones": {
|
||||
"body": {"scrollHeight": 400, "clientHeight": 490, "overflowed": False},
|
||||
"sidebar": {"scrollHeight": 300, "clientHeight": 490, "overflowed": False},
|
||||
"footer": {"scrollHeight": 55, "clientHeight": 60, "overflowed": False},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def mock_capture_slide_screenshot(html):
|
||||
"""Stage 4 L5 mock: 빈 스크린샷"""
|
||||
return "" # 빈 문자열 → 비전 품질 게이트 스킵
|
||||
|
||||
|
||||
async def run_full_simulation():
|
||||
"""전체 파이프라인 시뮬레이션"""
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
nonlocal passed, failed
|
||||
if condition:
|
||||
print(f" ✅ {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ {name}")
|
||||
if detail:
|
||||
print(f" → {detail}")
|
||||
failed += 1
|
||||
|
||||
# Mock 패치 적용
|
||||
# _retry_kei는 async fn을 await하는 래퍼이므로, mock도 await 해야 함
|
||||
async def mock_retry_kei(fn, *a, **kw):
|
||||
return await fn(*a, **kw)
|
||||
|
||||
with patch("src.pipeline._retry_kei", side_effect=mock_retry_kei), \
|
||||
patch("src.kei_client.classify_content", side_effect=mock_classify_content), \
|
||||
patch("src.kei_client.refine_concepts", side_effect=mock_refine_concepts), \
|
||||
patch("src.content_verifier.generate_with_retry", side_effect=mock_generate_with_retry), \
|
||||
patch("src.renderer.render_slide_from_html", side_effect=mock_render_slide_from_html), \
|
||||
patch("src.slide_measurer.measure_rendered_heights", side_effect=mock_measure_rendered_heights), \
|
||||
patch("src.slide_measurer.capture_slide_screenshot", side_effect=mock_capture_slide_screenshot), \
|
||||
patch("src.image_utils.get_image_sizes", return_value={}), \
|
||||
patch("src.image_utils.embed_images", side_effect=lambda html, bp: html):
|
||||
|
||||
from src.pipeline import generate_slide
|
||||
|
||||
events = []
|
||||
print("── 전체 파이프라인 실행 ──")
|
||||
try:
|
||||
async for event in generate_slide(raw_content):
|
||||
events.append(event)
|
||||
evt_type = event.get("event", "")
|
||||
evt_data = event.get("data", "")
|
||||
if evt_type == "progress":
|
||||
print(f" 📌 {evt_data}")
|
||||
elif evt_type == "error":
|
||||
print(f" ❌ ERROR: {evt_data}")
|
||||
elif evt_type == "result":
|
||||
print(f" 📄 result: {len(evt_data)}자 HTML")
|
||||
except Exception as e:
|
||||
print(f" 💥 EXCEPTION: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
check("파이프라인 예외 없음", False, str(e))
|
||||
print(f"\n{'═' * 55}")
|
||||
print(f" 전체 시뮬레이션: {passed} passed, {failed} failed")
|
||||
print(f"{'═' * 55}")
|
||||
return False
|
||||
|
||||
print()
|
||||
|
||||
# 이벤트 검증
|
||||
event_types = [e["event"] for e in events]
|
||||
check("progress 이벤트 존재", "progress" in event_types)
|
||||
check("error 이벤트 없음", "error" not in event_types,
|
||||
f"errors: {[e['data'] for e in events if e['event']=='error']}")
|
||||
check("result 이벤트 존재", "result" in event_types)
|
||||
|
||||
# result HTML 검증
|
||||
result_events = [e for e in events if e["event"] == "result"]
|
||||
if result_events:
|
||||
html = result_events[0]["data"]
|
||||
check("HTML 비어있지 않음", len(html) > 100, f"길이: {len(html)}")
|
||||
check("HTML에 slide 클래스", "slide" in html)
|
||||
check("HTML에 body 영역", "area-body" in html or "body_html" in html or "bg" in html)
|
||||
check("HTML에 sidebar 영역", "area-sidebar" in html or "sidebar" in html)
|
||||
check("HTML에 footer 영역", "area-footer" in html or "footer" in html)
|
||||
else:
|
||||
check("result HTML", False, "result 이벤트 없음")
|
||||
|
||||
# 스냅샷 파일 확인
|
||||
import glob
|
||||
latest_runs = sorted(glob.glob("data/runs/2026*"), reverse=True)
|
||||
if latest_runs:
|
||||
run_dir = latest_runs[0]
|
||||
files = [Path(f).name for f in glob.glob(f"{run_dir}/*.json")]
|
||||
print(f"\n 스냅샷 폴더: {Path(run_dir).name}")
|
||||
print(f" 저장된 파일: {files}")
|
||||
check("stage_0 스냅샷", "stage_0_context.json" in files)
|
||||
check("stage_1a 스냅샷", "stage_1a_context.json" in files)
|
||||
check("stage_1b 스냅샷", "stage_1b_context.json" in files)
|
||||
check("stage_1_5a 스냅샷", "stage_1_5a_context.json" in files)
|
||||
check("stage_1_7 스냅샷", "stage_1_7_context.json" in files)
|
||||
check("stage_1_5b 스냅샷", "stage_1_5b_context.json" in files)
|
||||
check("stage_2 스냅샷", "stage_2_context.json" in files)
|
||||
check("stage_3 스냅샷", "stage_3_context.json" in files)
|
||||
check("stage_4 스냅샷", "stage_4_context.json" in files)
|
||||
check("final 스냅샷", "final_context.json" in files)
|
||||
check("final.html 저장", "final.html" in [Path(f).name for f in glob.glob(f"{run_dir}/*")])
|
||||
else:
|
||||
check("스냅샷 폴더", False, "run 폴더 없음")
|
||||
|
||||
print(f"\n{'═' * 55}")
|
||||
print(f" 전체 파이프라인 시뮬레이션: {passed} passed, {failed} failed")
|
||||
if failed == 0:
|
||||
print(" 전체 통과 ✅")
|
||||
else:
|
||||
print(f" ❌ {failed}개 실패")
|
||||
print(f"{'═' * 55}")
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = asyncio.run(run_full_simulation())
|
||||
sys.exit(0 if success else 1)
|
||||
269
scripts/test_phase_t_real.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Phase T 실제 데이터 시뮬레이션.
|
||||
|
||||
기존 run(1774922951020)의 실제 Kei API 응답 + 실제 MDX로
|
||||
전 Stage를 시뮬레이션하여 설계 오류를 사전에 잡는다.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
# ── 실제 데이터 로드 ──
|
||||
RUN_DIR = Path("data/runs/1774922951020")
|
||||
STAGE_0_DIR = Path("data/runs/20260401_151426")
|
||||
|
||||
# Stage 0 결과 (실제 실행된 것)
|
||||
stage0_ctx = json.loads((STAGE_0_DIR / "stage_0_context.json").read_text(encoding="utf-8"))
|
||||
raw_content = stage0_ctx["raw_content"]
|
||||
normalized = stage0_ctx["normalized"]
|
||||
|
||||
# Kei 1A 실제 응답
|
||||
analysis_1a = json.loads((RUN_DIR / "step1_analysis.json").read_text(encoding="utf-8"))
|
||||
|
||||
# Kei 1B 실제 응답
|
||||
concepts_1b = json.loads((RUN_DIR / "step1b_concepts.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test():
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name, condition, detail=""):
|
||||
nonlocal passed, failed
|
||||
if condition:
|
||||
print(f" ✅ {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" ❌ {name}")
|
||||
if detail:
|
||||
print(f" → {detail}")
|
||||
failed += 1
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 0: 이미 실행됨 — 결과 확인만
|
||||
# ══════════════════════════════════════
|
||||
print("── Stage 0: 실제 결과 확인 ──")
|
||||
check("clean_text", len(normalized["clean_text"]) > 200)
|
||||
check("title", normalized["title"] == "건설산업 DX의 올바른 이해")
|
||||
check("images", len(normalized["images"]) == 1)
|
||||
check("popups", len(normalized["popups"]) == 2, f"got {len(normalized['popups'])}")
|
||||
check("sections", len(normalized["sections"]) >= 3)
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1A: 실제 Kei 응답 → Topic 모델 변환
|
||||
# ══════════════════════════════════════
|
||||
print("\n── Stage 1A: 실제 Kei 응답 → Topic 변환 ──")
|
||||
|
||||
from src.pipeline_context import Topic, FontHierarchy
|
||||
|
||||
# 실제 Kei 응답의 topic dict 구조 확인
|
||||
topics_raw = analysis_1a.get("topics", [])
|
||||
print(f" Kei 반환 topic 수: {len(topics_raw)}")
|
||||
print(f" Kei topic 키: {list(topics_raw[0].keys()) if topics_raw else '없음'}")
|
||||
|
||||
# 실제 변환 시도 (pipeline.py의 코드와 동일)
|
||||
try:
|
||||
topics = [Topic(**{k: v for k, v in t.items() if k in Topic.model_fields}) for t in topics_raw]
|
||||
check("Topic 변환 성공", True)
|
||||
for t in topics:
|
||||
print(f" topic {t.id}: {t.title} / {t.purpose} / role={t.role}")
|
||||
except Exception as e:
|
||||
check("Topic 변환", False, str(e))
|
||||
return False
|
||||
|
||||
# Kei가 안 주는 필드 확인
|
||||
kei_keys = set(topics_raw[0].keys()) if topics_raw else set()
|
||||
topic_keys = set(Topic.model_fields.keys())
|
||||
missing_from_kei = topic_keys - kei_keys
|
||||
extra_from_kei = kei_keys - topic_keys
|
||||
print(f" Topic 모델에 있고 Kei에 없는 필드: {missing_from_kei}")
|
||||
print(f" Kei에 있고 Topic 모델에 없는 필드: {extra_from_kei}")
|
||||
check("Kei 미제공 필드가 기본값으로 처리됨",
|
||||
all(hasattr(topics[0], f) for f in missing_from_kei))
|
||||
|
||||
# 1A 검증
|
||||
from src.validators import validate_stage_1a
|
||||
errors_1a = validate_stage_1a(analysis_1a, normalized["clean_text"])
|
||||
check(f"1A 검증 통과", not errors_1a)
|
||||
for e in errors_1a:
|
||||
print(f" {e['severity']}: {e.get('localization', '')}")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1B: 실제 Kei 1B 응답 병합
|
||||
# ══════════════════════════════════════
|
||||
print("\n── Stage 1B: 실제 Kei 1B 응답 병합 ──")
|
||||
|
||||
concepts = concepts_1b.get("concepts", [])
|
||||
print(f" Kei 1B 반환 수: {len(concepts)}")
|
||||
|
||||
# 병합 (pipeline.py의 코드와 동일)
|
||||
updated_topics = []
|
||||
for t in topics:
|
||||
match = next((c for c in concepts if c.get("id") == t.id), None)
|
||||
if match:
|
||||
updated = t.model_copy(update={
|
||||
"relation_type": match.get("relation_type", t.relation_type),
|
||||
"expression_hint": match.get("expression_hint", t.expression_hint),
|
||||
"source_data": match.get("source_data", t.source_data),
|
||||
})
|
||||
updated_topics.append(updated)
|
||||
else:
|
||||
updated_topics.append(t)
|
||||
|
||||
check("1B 병합 성공", len(updated_topics) == len(topics))
|
||||
for t in updated_topics:
|
||||
print(f" topic {t.id}: relation={t.relation_type}, hint={t.expression_hint[:30]}...")
|
||||
|
||||
# 1B 검증 (raw_content 포함 — popups 대조)
|
||||
from src.validators import validate_stage_1b
|
||||
errors_1b = validate_stage_1b(
|
||||
[t.model_dump() for t in updated_topics],
|
||||
normalized["clean_text"],
|
||||
raw_content=raw_content,
|
||||
)
|
||||
check(f"1B 검증 통과", not errors_1b)
|
||||
for e in errors_1b:
|
||||
print(f" {e['severity']}: {e.get('localization', '')}")
|
||||
if e.get("evidence"):
|
||||
print(f" 증거: {str(e['evidence'])[:100]}")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.5a: 폰트 위계 + 동적 비율
|
||||
# ══════════════════════════════════════
|
||||
print("\n── Stage 1.5a: 폰트 위계 + 동적 비율 ──")
|
||||
|
||||
from src.pipeline_context import PipelineContext, create_context, NormalizedContent, Analysis, PageStructure
|
||||
from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio
|
||||
|
||||
# context 구성 (실제 데이터)
|
||||
ctx = create_context(raw_content)
|
||||
ctx = ctx.model_copy(update={
|
||||
"normalized": NormalizedContent(**normalized),
|
||||
"topics": updated_topics,
|
||||
"page_structure": PageStructure(roles=analysis_1a.get("page_structure", {})),
|
||||
"analysis": Analysis(
|
||||
core_message=analysis_1a.get("core_message", ""),
|
||||
title=analysis_1a.get("title", ""),
|
||||
),
|
||||
})
|
||||
|
||||
# 역할별 텍스트 양
|
||||
role_text_lengths = {}
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
role_text = ctx.get_role_content(role)
|
||||
role_text_lengths[role] = len(role_text)
|
||||
print(f" {role}: {len(role_text)}자")
|
||||
|
||||
fh_dict = calculate_font_hierarchy(role_text_lengths)
|
||||
try:
|
||||
fh = FontHierarchy(
|
||||
key_msg=fh_dict.get("핵심", 14), core=fh_dict.get("본심", 12),
|
||||
bg=fh_dict.get("배경", 11), sidebar=fh_dict.get("첨부", 10),
|
||||
)
|
||||
check("폰트 위계 생성", True)
|
||||
print(f" 위계: 핵심={fh.key_msg} > 본심={fh.core} >= 배경={fh.bg} > 첨부={fh.sidebar}")
|
||||
except Exception as e:
|
||||
check("폰트 위계", False, str(e))
|
||||
return False
|
||||
|
||||
ratio = calculate_dynamic_ratio(role_text_lengths, fh_dict)
|
||||
check("동적 비율 생성", ratio[0] + ratio[1] == 100)
|
||||
print(f" 비율: body:sidebar = {ratio[0]}:{ratio[1]}")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.7: 참고 블록 선택
|
||||
# ══════════════════════════════════════
|
||||
print("\n── Stage 1.7: 참고 블록 선택 ──")
|
||||
|
||||
from src.block_reference import select_and_generate_references
|
||||
from src.space_allocator import calculate_container_specs
|
||||
from src.design_director import LAYOUT_PRESETS, select_preset
|
||||
|
||||
preset_name = select_preset(analysis_1a)
|
||||
preset = LAYOUT_PRESETS.get(preset_name, {})
|
||||
print(f" 프리셋: {preset_name}")
|
||||
|
||||
container_specs = calculate_container_specs(
|
||||
page_structure=analysis_1a.get("page_structure", {}),
|
||||
topics=[t.model_dump() for t in updated_topics],
|
||||
preset=preset,
|
||||
)
|
||||
print(f" 컨테이너: {', '.join(f'{r}={s.height_px}px' for r, s in container_specs.items())}")
|
||||
|
||||
refs = select_and_generate_references(
|
||||
[t.model_dump() for t in updated_topics],
|
||||
container_specs,
|
||||
analysis_1a.get("page_structure", {}),
|
||||
)
|
||||
check("참고 블록 선택", len(refs) >= 3)
|
||||
for role, ref in refs.items():
|
||||
html_len = len(ref.get("design_reference_html", ""))
|
||||
has_diff = "차별점" in ref.get("design_reference_html", "")
|
||||
print(f" {role}: {ref['block_id']} ({ref['visual_type']}, html={html_len}자, diff={'✅' if has_diff else '—'})")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.5b: 디자인 예산
|
||||
# ══════════════════════════════════════
|
||||
print("\n── Stage 1.5b: 디자인 예산 ──")
|
||||
|
||||
from src.space_allocator import calculate_design_budget
|
||||
|
||||
for role, ref in refs.items():
|
||||
schema = ref.get("schema_info", {})
|
||||
spec = container_specs.get(role)
|
||||
if not spec:
|
||||
continue
|
||||
font_map = {"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}
|
||||
budget = calculate_design_budget(spec.height_px, spec.width_px, schema, font_map.get(role, 12))
|
||||
check(f"{role} 예산 (fits={budget['fits']})", True)
|
||||
print(f" {role}: container={spec.height_px}px, text={budget['text_height_px']}px, avail={budget['available_height_px']}px")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 2: 프롬프트 supplement 생성
|
||||
# ══════════════════════════════════════
|
||||
print("\n── Stage 2: 프롬프트 supplement ──")
|
||||
|
||||
from src.html_generator import _build_phase_t_supplement
|
||||
|
||||
phase_t_ctx = {
|
||||
"font_hierarchy": fh.model_dump(),
|
||||
"container_ratio": ratio,
|
||||
"references": refs,
|
||||
"design_budgets": {
|
||||
role: calculate_design_budget(
|
||||
container_specs[role].height_px, container_specs[role].width_px,
|
||||
refs.get(role, {}).get("schema_info", {}),
|
||||
{"본심": fh.core, "배경": fh.bg, "첨부": fh.sidebar, "결론": fh.core}.get(role, 12)
|
||||
)
|
||||
for role in container_specs
|
||||
},
|
||||
}
|
||||
analysis_with_t = {**analysis_1a, "phase_t": phase_t_ctx}
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
supp = _build_phase_t_supplement(role, analysis_with_t)
|
||||
has_font = "폰트 위계" in supp
|
||||
has_budget = "디자인 예산" in supp
|
||||
has_ref = "디자인 레퍼런스" in supp
|
||||
check(f"{role} supplement ({len(supp)}자)", len(supp) > 50)
|
||||
if not has_font:
|
||||
print(f" ⚠️ 폰트 위계 누락")
|
||||
if not has_budget:
|
||||
print(f" ⚠️ 디자인 예산 누락")
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 결과
|
||||
# ══════════════════════════════════════
|
||||
print(f"\n{'═' * 55}")
|
||||
print(f" 실제 데이터 시뮬레이션: {passed} passed, {failed} failed")
|
||||
if failed == 0:
|
||||
print(" 전체 통과 ✅ — 서버에서 실행해도 이 지점까지 동일하게 동작")
|
||||
else:
|
||||
print(f" ❌ {failed}개 실패 — 서버 실행 전에 수정 필요")
|
||||
print(f"{'═' * 55}")
|
||||
return failed == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test()
|
||||
sys.exit(0 if success else 1)
|
||||
445
src/block_assembler.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""블록 조립 공통 모듈.
|
||||
|
||||
filled, assembled, Stage 2 모두 이 모듈의 함수를 사용.
|
||||
조립 로직이 한 곳에만 존재하여 수정 사항이 전체에 반영됨.
|
||||
|
||||
입력: PipelineContext (또는 동등한 dict)
|
||||
출력: 역할별 HTML dict + 슬라이드 전체 HTML
|
||||
|
||||
하드코딩 없음. font_hierarchy, sub_layouts, design_reference_html, structured_text에서 동적으로.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.pipeline_context import PipelineContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||
|
||||
|
||||
def assemble_role_html(
|
||||
role: str,
|
||||
ctx: "PipelineContext",
|
||||
) -> tuple[str, set[str]]:
|
||||
"""하나의 역할(배경/본심/첨부/결론)에 대해 블록 디자인 + 텍스트를 조립.
|
||||
|
||||
Returns:
|
||||
(조립된 HTML, 사용된 CSS set)
|
||||
"""
|
||||
ps = ctx.page_structure.roles
|
||||
info = ps.get(role, {})
|
||||
if not isinstance(info, dict):
|
||||
return "", set()
|
||||
tids = info.get("topic_ids", [])
|
||||
if not tids:
|
||||
return "", set()
|
||||
|
||||
topic_map = {t.id: t for t in ctx.topics}
|
||||
ref_list = ctx.references.get(role, [])
|
||||
if not ref_list:
|
||||
return "", set()
|
||||
|
||||
r0 = ref_list[0]
|
||||
primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
|
||||
primary_topic = topic_map.get(primary_tid)
|
||||
if not primary_topic:
|
||||
return "", set()
|
||||
|
||||
font_key = FONT_MAP.get(role, "core")
|
||||
font_size = getattr(ctx.font_hierarchy, font_key, 12)
|
||||
sub_layouts = ctx.sub_layouts or {}
|
||||
role_sub = sub_layouts.get(role, {})
|
||||
role_scs = role_sub.get("sub_containers", [])
|
||||
|
||||
# #10: V-10 bold 키워드
|
||||
enh = ctx.enhancement_result or {}
|
||||
bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
|
||||
role_bold = bold_kw.get(role, [])
|
||||
|
||||
# ── 블록 디자인 HTML에서 CSS 추출 ──
|
||||
ref_html = r0.design_reference_html or ""
|
||||
css_parts = re.findall(r'<style>(.*?)</style>', ref_html, re.DOTALL)
|
||||
block_body = re.sub(r'<style>.*?</style>', '', ref_html, flags=re.DOTALL)
|
||||
block_body = re.sub(r'<!--.*?-->', '', block_body, flags=re.DOTALL).strip()
|
||||
|
||||
# CSS font-size override (font_hierarchy 기준)
|
||||
overridden_css = set()
|
||||
for css in css_parts:
|
||||
def _override_font(m):
|
||||
val = float(m.group(1))
|
||||
if val > font_size + 2:
|
||||
return f"font-size: {font_size + 1}px"
|
||||
elif val > font_size:
|
||||
return f"font-size: {font_size}px"
|
||||
return m.group(0)
|
||||
oc = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, css)
|
||||
# gap, padding, number size도 font_size 비례
|
||||
oc = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', oc)
|
||||
oc = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px',
|
||||
f'width: {int(font_size * 2)}px;\n height: {int(font_size * 2)}px', oc)
|
||||
oc = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', oc)
|
||||
oc = oc.replace('white-space: pre-line', 'white-space: normal')
|
||||
overridden_css.add(oc)
|
||||
|
||||
# ── structured_text 파싱 (들여쓰기 보존) ──
|
||||
st = primary_topic.structured_text or primary_topic.source_data or ""
|
||||
st_lines, popup_titles = _parse_structured_text(st, font_size)
|
||||
|
||||
# ── sub_layouts 기반 판단 ──
|
||||
has_svg = any(sc.get("name") == "svg" for sc in role_scs)
|
||||
has_keymsg = any(sc.get("name") == "keymsg" for sc in role_scs)
|
||||
|
||||
# #11: V-9 강조 블록
|
||||
emphasis_blocks = enh.get("emphasis_blocks", [])
|
||||
role_emphasis = ""
|
||||
for eb in emphasis_blocks:
|
||||
if eb.get("role") == role:
|
||||
role_emphasis = eb.get("sentence", "")
|
||||
break
|
||||
|
||||
# #12: V-7 종속꼭지 텍스트
|
||||
is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
|
||||
sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
|
||||
sub_topics_text = []
|
||||
if is_hier and sup_tids:
|
||||
for st_id in sup_tids:
|
||||
st_topic = topic_map.get(st_id)
|
||||
if st_topic:
|
||||
st_text = st_topic.structured_text or st_topic.source_data or ""
|
||||
sub_topics_text.append(st_text[:120])
|
||||
|
||||
# ── 블록 구조별 조립 ──
|
||||
if "block-callout-warn" in block_body or "block-callout-sol" in block_body:
|
||||
inner = _assemble_callout(block_body, primary_topic, st_lines, font_size, role_bold, role_emphasis, sub_topics_text)
|
||||
elif "block-card-num" in block_body:
|
||||
inner = _assemble_card_numbered(primary_topic, st_lines, font_size, role_scs, role_bold)
|
||||
elif "block-banner-grad" in block_body:
|
||||
inner = _assemble_banner(block_body, ctx.analysis.core_message or primary_topic.title)
|
||||
elif has_svg:
|
||||
# 실제 이미지 파일이 있는 경우만 SVG 레이아웃 사용
|
||||
# slide_images에 실제 이미지가 있는지 확인
|
||||
has_real_image = any(
|
||||
img.get("b64") or img.get("path", "").strip()
|
||||
for img in (ctx.slide_images or [])
|
||||
)
|
||||
if has_real_image:
|
||||
inner = _assemble_svg_layout(block_body, primary_topic, st_lines, font_size, role_scs, ctx.analysis.core_message, has_keymsg, ctx.slide_images, bold_keywords=role_bold)
|
||||
else:
|
||||
inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
|
||||
else:
|
||||
inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
|
||||
|
||||
# V'-1: 팝업 링크를 컨테이너 우측상단에 배치
|
||||
popup_html = _popup_links_html(popup_titles, font_size)
|
||||
if popup_html:
|
||||
inner = f'<div style="position:relative;">{popup_html}{inner}</div>'
|
||||
|
||||
return inner, overridden_css
|
||||
|
||||
|
||||
def _parse_structured_text(st: str, font_size: float) -> tuple[list[tuple[int, str]], list[str]]:
|
||||
"""structured_text → ([(indent, text)], [팝업 제목 리스트]).
|
||||
[팝업:]은 텍스트에서 분리하여 별도 리스트로 반환. [이미지:]는 제거. **bold** → <strong>."""
|
||||
lines = []
|
||||
popup_titles = []
|
||||
for raw_line in st.split("\n"):
|
||||
stripped = raw_line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
indent = 1 if raw_line.startswith(" ") else 0
|
||||
|
||||
# 마커 처리 (bold 변환 전)
|
||||
popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
|
||||
if popup_match:
|
||||
popup_titles.append(popup_match.group(1))
|
||||
continue
|
||||
if re.search(r'\[이미지:', stripped):
|
||||
continue
|
||||
|
||||
# 마크다운 bold → HTML (마커 처리 후)
|
||||
stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
|
||||
lines.append((indent, stripped))
|
||||
return lines, popup_titles
|
||||
|
||||
|
||||
def _apply_bold(text: str, keywords: list[str]) -> str:
|
||||
"""V-10 bold 키워드를 <strong>으로 감쌈."""
|
||||
for kw in keywords:
|
||||
if kw in text:
|
||||
text = text.replace(kw, f"<strong>{kw}</strong>")
|
||||
return text
|
||||
|
||||
|
||||
def _popup_links_html(popup_titles: list[str], font_size: float) -> str:
|
||||
"""팝업 제목 리스트 → 우측상단 배치용 HTML."""
|
||||
if not popup_titles:
|
||||
return ""
|
||||
links = " ".join(
|
||||
f'<span style="color:#2563eb;font-size:{font_size - 2}px;cursor:pointer;">[{t}→]</span>'
|
||||
for t in popup_titles
|
||||
)
|
||||
return (
|
||||
f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
|
||||
f'{links}</div>'
|
||||
)
|
||||
|
||||
|
||||
def _st_lines_to_bullets(st_lines: list[tuple[int, str]], font_size: float, bold_keywords: list[str] | None = None) -> str:
|
||||
"""(indent, text) 리스트를 HTML 불릿으로."""
|
||||
bk = bold_keywords or []
|
||||
html = ""
|
||||
for indent, text in st_lines:
|
||||
clean = _apply_bold(text.lstrip("• "), bk)
|
||||
if text.startswith("출처:") or clean.startswith("출처:"):
|
||||
# V'-3: "출처:" 라벨 삭제, 텍스트만 표시
|
||||
caption = re.sub(r'^출처:\s*', '', clean)
|
||||
html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
|
||||
elif indent == 1:
|
||||
html += f'<div class="bl" style="padding-left:1em;font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
|
||||
else:
|
||||
html += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
|
||||
return html
|
||||
|
||||
|
||||
def _assemble_callout(block_body, topic, st_lines, font_size, bold_keywords=None, emphasis="", sub_topics_text=None):
|
||||
"""callout-warning/solution 블록에 텍스트 채움."""
|
||||
bk = bold_keywords or []
|
||||
desc_html = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
|
||||
# V-7 종속꼭지 인라인
|
||||
sub_html = ""
|
||||
for st_text in (sub_topics_text or []):
|
||||
sub_html += (
|
||||
f'<div style="padding-left:1em;margin-top:2px;color:#9b1c1c;font-size:{font_size-1}px;'
|
||||
f'border-left:2px solid #fca5a5;">{_apply_bold(st_text, bk)}</div>'
|
||||
)
|
||||
# V-9 강조 블록
|
||||
emph_html = ""
|
||||
if emphasis:
|
||||
emph_html = (
|
||||
f'<div style="background:#991b1b;color:#fff;border-radius:3px;'
|
||||
f'padding:3px 8px;font-size:{font_size-1}px;font-weight:700;margin-top:2px;">'
|
||||
f'→ {_apply_bold(emphasis, bk)}</div>'
|
||||
)
|
||||
inner = re.sub(r'<div class="cw-title">.*?</div>',
|
||||
f'<div class="cw-title">{_apply_bold(topic.title, bk)}</div>', block_body, flags=re.DOTALL)
|
||||
inner = re.sub(r'<div class="cw-desc">.*?</div>',
|
||||
f'<div class="cw-desc" style="font-size:{font_size}px;">{desc_html}{sub_html}{emph_html}</div>', inner, flags=re.DOTALL)
|
||||
return inner
|
||||
|
||||
|
||||
def _assemble_card_numbered(topic, st_lines, font_size, role_scs, bold_keywords=None):
|
||||
"""card-numbered 블록에 카드별 텍스트 채움."""
|
||||
# indent=0 주불릿 = 카드 제목, indent=1 = 카드 설명
|
||||
cards = []
|
||||
current_title = ""
|
||||
current_descs = []
|
||||
for indent, text in st_lines:
|
||||
clean = text.lstrip("• ")
|
||||
if indent == 0 and text.startswith("• "):
|
||||
if current_title:
|
||||
cards.append((current_title, current_descs))
|
||||
current_title = clean
|
||||
current_descs = []
|
||||
else:
|
||||
current_descs.append(clean)
|
||||
if current_title:
|
||||
cards.append((current_title, current_descs))
|
||||
|
||||
# sidebar 라벨
|
||||
label = f'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:4px;">{topic.title}</div>'
|
||||
|
||||
bk = bold_keywords or []
|
||||
card_gap = max(3, int(font_size * 0.4))
|
||||
items_html = ""
|
||||
for i, (title, descs) in enumerate(cards):
|
||||
desc_html = ""
|
||||
for d in descs:
|
||||
d = _apply_bold(d, bk)
|
||||
if d.startswith("출처:"):
|
||||
caption = re.sub(r'^출처:\s*', '', d)
|
||||
desc_html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
|
||||
else:
|
||||
desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{d}</span></div>\n'
|
||||
num_size = int(font_size * 2)
|
||||
items_html += (
|
||||
f'<div class="cn-item">'
|
||||
f'<div class="cn-number" style="background:#2563eb;width:{num_size}px;height:{num_size}px;font-size:{font_size-1}px;">{i+1}</div>'
|
||||
f'<div class="cn-body">'
|
||||
f'<div class="cn-title" style="font-size:{font_size}px;">{_apply_bold(title, bk)}</div>'
|
||||
f'<div class="cn-desc" style="font-size:{font_size-1}px;white-space:normal;">{desc_html}</div>'
|
||||
f'</div></div>\n'
|
||||
)
|
||||
|
||||
return f'{label}<div class="block-card-num" style="gap:{card_gap}px;">{items_html}</div>'
|
||||
|
||||
|
||||
def _assemble_banner(block_body, message):
|
||||
"""banner-gradient 블록에 메시지 채움."""
|
||||
inner = re.sub(r'<div class="bg-text">.*?</div>',
|
||||
f'<div class="bg-text">{message}</div>', block_body, flags=re.DOTALL)
|
||||
inner = re.sub(r'<div class="bg-sub">.*?</div>', '', inner, flags=re.DOTALL)
|
||||
return inner
|
||||
|
||||
|
||||
def _assemble_svg_layout(block_body, topic, st_lines, font_size, role_scs, core_message, has_keymsg, slide_images=None, bold_keywords=None):
|
||||
"""이미지(좌) + 텍스트(우) + key-msg(하단) 레이아웃. 실제 이미지 파일 사용."""
|
||||
# 실제 이미지가 있으면 <img> 사용, 없으면 빈 placeholder
|
||||
img_html = ""
|
||||
if slide_images:
|
||||
for img in slide_images:
|
||||
b64 = img.get("b64", "")
|
||||
if b64:
|
||||
img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
|
||||
break
|
||||
|
||||
svg_sc = next((sc for sc in role_scs if sc["name"] == "svg"), None)
|
||||
text_sc = next((sc for sc in role_scs if sc["name"] == "text_and_table"), None)
|
||||
svg_w = int(svg_sc["width_px"]) if svg_sc else 200
|
||||
svg_h = int(svg_sc["height_px"]) if svg_sc else 265
|
||||
|
||||
# 출처 라인을 이미지 아래 캡션으로 분리
|
||||
caption_lines = []
|
||||
content_lines = []
|
||||
for indent, text in st_lines:
|
||||
clean = text.lstrip("• ")
|
||||
if text.startswith("출처:") or clean.startswith("출처:"):
|
||||
caption_lines.append(re.sub(r'^출처:\s*', '', clean))
|
||||
else:
|
||||
content_lines.append((indent, text))
|
||||
|
||||
img_caption = ""
|
||||
if caption_lines:
|
||||
img_caption = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{caption_lines[0]}</div>'
|
||||
|
||||
bullets = _st_lines_to_bullets(content_lines, font_size, bold_keywords=bold_keywords)
|
||||
bk = bold_keywords or []
|
||||
|
||||
keymsg_html = ""
|
||||
if has_keymsg and core_message:
|
||||
keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
|
||||
km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
|
||||
keymsg_html = (
|
||||
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
|
||||
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
|
||||
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
|
||||
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
|
||||
)
|
||||
|
||||
return (
|
||||
f'<div style="display:flex;flex-direction:column;height:100%;padding:8px;box-sizing:border-box;">'
|
||||
f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
|
||||
f'{_apply_bold(topic.title, bk)}</div>'
|
||||
f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;flex:1;min-height:0;align-items:flex-start;">'
|
||||
f'<div style="width:{svg_w}px;flex-shrink:0;"><div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>{img_caption}</div>'
|
||||
f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
|
||||
f'</div>{keymsg_html}</div>'
|
||||
)
|
||||
|
||||
|
||||
def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role_scs, bold_keywords=None):
|
||||
"""기타 블록: 제목 + 불릿."""
|
||||
bk = bold_keywords or []
|
||||
bullets = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
|
||||
keymsg_html = ""
|
||||
if has_keymsg and core_message:
|
||||
keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
|
||||
km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
|
||||
keymsg_html = (
|
||||
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
|
||||
f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
|
||||
f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
|
||||
f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
|
||||
)
|
||||
return (
|
||||
f'<div style="height:100%;padding:6px;font-size:{font_size}px;line-height:1.4;">'
|
||||
f'<div style="font-weight:700;font-size:{font_size+1}px;margin-bottom:4px;">{_apply_bold(topic.title, bk)}</div>'
|
||||
f'{bullets}{keymsg_html}</div>'
|
||||
)
|
||||
|
||||
|
||||
def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str:
|
||||
"""전체 슬라이드를 조립하여 HTML 반환.
|
||||
|
||||
filled, assembled, stage_2 모두 이 함수를 호출.
|
||||
"""
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
pad = tokens["spacing_page"]
|
||||
header_h = tokens.get("header_height", 66)
|
||||
gap_block = tokens["spacing_block"]
|
||||
gap_small = tokens["spacing_small"]
|
||||
|
||||
ratio = ctx.container_ratio
|
||||
slide_w = tokens.get("slide_width", 1280)
|
||||
slide_h = tokens.get("slide_height", 720)
|
||||
inner_w = slide_w - pad * 2
|
||||
body_w = int(inner_w * ratio[0] / 100)
|
||||
sidebar_w = inner_w - body_w - gap_block
|
||||
|
||||
fit = ctx.fit_result or {}
|
||||
redist = fit.get("redistribution", {})
|
||||
|
||||
all_css = set()
|
||||
role_htmls = {}
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
html, css = assemble_role_html(role, ctx)
|
||||
role_htmls[role] = html
|
||||
all_css.update(css)
|
||||
|
||||
# 좌표 계산
|
||||
bg_h = int(redist.get("배경", ctx.containers.get("배경", type("", (), {"height_px": 0})).height_px))
|
||||
core_h = int(redist.get("본심", ctx.containers.get("본심", type("", (), {"height_px": 0})).height_px))
|
||||
sb_h = int(redist.get("첨부", ctx.containers.get("첨부", type("", (), {"height_px": 0})).height_px))
|
||||
concl_h = int(redist.get("결론", ctx.containers.get("결론", type("", (), {"height_px": 0})).height_px))
|
||||
|
||||
bg_top = pad + header_h + gap_block
|
||||
core_top = bg_top + bg_h + gap_small
|
||||
sb_top = bg_top
|
||||
|
||||
# V'-4: after(redistribution 있을 때)에서 결론 바로 위까지 body/sidebar 채움
|
||||
if redist:
|
||||
ft_top = slide_h - pad - concl_h - gap_block
|
||||
column_bottom = ft_top - gap_block
|
||||
core_h = column_bottom - core_top
|
||||
sb_h = column_bottom - sb_top
|
||||
else:
|
||||
ft_top = max(core_top + core_h, bg_top + sb_h) + gap_block
|
||||
|
||||
title = title_text or ctx.analysis.title or ""
|
||||
css_block = "\n".join(all_css)
|
||||
|
||||
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
|
||||
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
|
||||
{css_block}
|
||||
</style></head><body>
|
||||
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
|
||||
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>
|
||||
|
||||
<div class="area-body" style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border:2px solid #dc2626;border-radius:6px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#dc2626;opacity:0.5;">배경 ({body_w}x{bg_h}px)</span>
|
||||
{role_htmls.get("배경", "")}</div>
|
||||
|
||||
<div class="area-body" style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border:2px solid #2563eb;border-radius:6px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#2563eb;opacity:0.5;">본심 ({body_w}x{core_h}px)</span>
|
||||
{role_htmls.get("본심", "")}</div>
|
||||
|
||||
<div class="area-sidebar" style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border:2px solid #16a34a;border-radius:6px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#16a34a;opacity:0.5;">첨부 ({sidebar_w}x{sb_h}px)</span>
|
||||
{role_htmls.get("첨부", "")}</div>
|
||||
|
||||
<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border:2px solid #7c3aed;border-radius:8px;overflow:hidden;">
|
||||
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#7c3aed;opacity:0.5;">결론 ({inner_w}x{concl_h}px)</span>
|
||||
{role_htmls.get("결론", "")}</div>
|
||||
|
||||
</div></body></html>"""
|
||||
557
src/block_reference.py
Normal file
@@ -0,0 +1,557 @@
|
||||
"""Phase T-3: 참고 블록 선택 + 디자인 레퍼런스 HTML 생성.
|
||||
|
||||
Stage 1.7에서 호출. relation_type + expression_hint → 참고 블록 결정론적 선택.
|
||||
블록을 "채울 틀"이 아니라 "참고할 디자인"으로 제공.
|
||||
|
||||
핵심 차이 (Phase P~R vs Phase T):
|
||||
P~R: 블록 선택 → 슬롯에 텍스트 채우기 (실패 — 구조 경직)
|
||||
T: 블록을 참고 자료로 제공 → AI가 구조를 자유롭게 결정 (유연 + 다양)
|
||||
|
||||
설계 근거:
|
||||
- expression_hint 키워드 포함 매칭 (정확한 문자열 아님 — T-3 조사)
|
||||
- LLM이 참고 HTML 구조를 70-90% 복사 (T-3 조사) → "디자인 레퍼런스" 프레이밍
|
||||
- Gestalt 원칙: 폐합→벤, 근접→좌우, 연속→화살표 (T-3 조사)
|
||||
- PPTAgent(EMNLP 2025): 참고 기반 생성의 효과 학술 입증
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 템플릿 디렉토리
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
|
||||
# Jinja2 환경 (블록 HTML 렌더링용)
|
||||
_jinja_env = None
|
||||
|
||||
def _get_jinja_env() -> Environment:
|
||||
global _jinja_env
|
||||
if _jinja_env is None:
|
||||
_jinja_env = Environment(
|
||||
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||
autoescape=False,
|
||||
)
|
||||
return _jinja_env
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# expression_hint → 블록 매핑 (키워드 포함 매칭)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
# 시각적 유형별 매칭 키워드 + 대응 블록
|
||||
# T-3 조사: 10개 고유 expression_hint → 5개 시각 유형 + 향후 2개
|
||||
VISUAL_TYPE_KEYWORDS: dict[str, dict[str, Any]] = {
|
||||
"인과": {
|
||||
"keywords": ["인과", "현상->결과", "야기", "원인", "문제 상황"],
|
||||
"blocks": ["callout-warning", "dark-bullet-list"],
|
||||
},
|
||||
"나열_병렬": {
|
||||
"keywords": ["독립적 나열", "병렬 나열", "개별 증거", "병렬"],
|
||||
"blocks": ["dark-bullet-list", "card-icon-desc"],
|
||||
},
|
||||
"나열_정의": {
|
||||
"keywords": ["독립적 정의", "참조용", "용어", "정의 나열"],
|
||||
"blocks": ["card-numbered"],
|
||||
},
|
||||
"포함_계층": {
|
||||
"keywords": ["상위-하위", "포함 관계", "계층적", "포함하는", "구성요소"],
|
||||
"blocks": ["venn-diagram", "keyword-circle-row"],
|
||||
},
|
||||
"강조_결론": {
|
||||
"keywords": ["핵심 메시지 강조", "임팩트", "한 줄 강조", "결론적 판단"],
|
||||
"blocks": ["banner-gradient", "quote-big-mark"],
|
||||
},
|
||||
"비교": {
|
||||
"keywords": ["대등 비교", "좌우 대조", "vs", "A vs B"],
|
||||
"blocks": ["compare-2col-split", "compare-3col-badge", "comparison-2col"],
|
||||
},
|
||||
"순서": {
|
||||
"keywords": ["시간 순서", "단계별", "A->B->C", "프로세스 흐름"],
|
||||
"blocks": ["flow-arrow-horizontal", "process-horizontal"],
|
||||
},
|
||||
}
|
||||
|
||||
# 카테고리별 fallback 블록 (모든 필터 통과 실패 시)
|
||||
CATEGORY_FALLBACK: dict[str, str] = {
|
||||
"cards": "card-numbered",
|
||||
"emphasis": "dark-bullet-list",
|
||||
"visuals": "venn-diagram",
|
||||
"tables": "compare-2col-split",
|
||||
"media": "image-side-text",
|
||||
"headers": "topic-left-right",
|
||||
}
|
||||
|
||||
# relation_type → 1차 필터 블록 카테고리 매핑
|
||||
RELATION_CATEGORY_MAP: dict[str, list[str]] = {
|
||||
"hierarchy": ["visuals", "emphasis"],
|
||||
"inclusion": ["visuals", "emphasis"],
|
||||
"comparison": ["tables", "emphasis", "cards"],
|
||||
"sequence": ["visuals"],
|
||||
"definition": ["cards", "emphasis"],
|
||||
"cause_effect": ["emphasis"],
|
||||
"none": ["emphasis"],
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 카탈로그 로딩 (mtime 캐싱)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
_catalog_cache: dict[str, Any] = {"data": None, "mtime": 0}
|
||||
|
||||
|
||||
def _load_catalog() -> list[dict]:
|
||||
"""catalog.yaml 로드 (mtime 캐싱)."""
|
||||
path = TEMPLATES_DIR / "catalog.yaml"
|
||||
mtime = path.stat().st_mtime
|
||||
if _catalog_cache["data"] is not None and _catalog_cache["mtime"] == mtime:
|
||||
return _catalog_cache["data"]
|
||||
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
blocks = data.get("blocks", [])
|
||||
_catalog_cache["data"] = blocks
|
||||
_catalog_cache["mtime"] = mtime
|
||||
return blocks
|
||||
|
||||
|
||||
def _get_block_by_id(block_id: str) -> dict | None:
|
||||
"""블록 ID로 카탈로그 엔트리 조회."""
|
||||
for b in _load_catalog():
|
||||
if b["id"] == block_id:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 블록 선택 (2단계 필터)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _match_visual_type(expression_hint: str) -> tuple[str, list[str]]:
|
||||
"""expression_hint에서 키워드를 찾아 시각적 유형과 후보 블록 반환.
|
||||
|
||||
키워드 포함(substring) 매칭 — 정확한 문자열 매칭이 아님.
|
||||
T-3 조사: expression_hint는 긴 문장이므로 부분 매칭 필수.
|
||||
"""
|
||||
for vtype, spec in VISUAL_TYPE_KEYWORDS.items():
|
||||
if any(kw in expression_hint for kw in spec["keywords"]):
|
||||
return vtype, spec["blocks"]
|
||||
return "default", []
|
||||
|
||||
|
||||
# 배경 역할에서 제외할 다크 계열 블록
|
||||
DARK_BLOCKS = {"dark-bullet-list", "card-dark-overlay"}
|
||||
|
||||
|
||||
def select_reference_block(
|
||||
relation_type: str,
|
||||
expression_hint: str,
|
||||
container_height_px: int,
|
||||
zone: str = "body",
|
||||
role: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""참고 블록 선택 (2단계 필터 + 역할 제약 + 컨테이너 적합성 + fallback).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"block_id": str,
|
||||
"variant": str,
|
||||
"visual_type": str,
|
||||
"catalog_entry": dict, # catalog.yaml의 해당 블록 전체
|
||||
}
|
||||
"""
|
||||
catalog = _load_catalog()
|
||||
|
||||
# ── 1차 필터: relation_type → 카테고리 ──
|
||||
allowed_categories = RELATION_CATEGORY_MAP.get(relation_type, ["emphasis"])
|
||||
candidates_1 = [
|
||||
b for b in catalog
|
||||
if b.get("category") in allowed_categories
|
||||
]
|
||||
|
||||
# ── 2차 필터: expression_hint 키워드 매칭 ──
|
||||
visual_type, hint_blocks = _match_visual_type(expression_hint)
|
||||
if hint_blocks:
|
||||
candidates_2 = [b for b in candidates_1 if b["id"] in hint_blocks]
|
||||
if not candidates_2:
|
||||
candidates_2 = [b for b in catalog if b["id"] in hint_blocks]
|
||||
else:
|
||||
candidates_2 = candidates_1
|
||||
|
||||
# ── TP-1: 배경 역할은 다크 블록 제외 ──
|
||||
if role == "배경":
|
||||
candidates_2 = [b for b in candidates_2 if b["id"] not in DARK_BLOCKS]
|
||||
if not candidates_2:
|
||||
# 다크 제외 후 후보 없으면 라이트 fallback
|
||||
candidates_2 = [b for b in candidates_1 if b["id"] not in DARK_BLOCKS]
|
||||
|
||||
# ── 3차 필터: 컨테이너 크기 적합성 ──
|
||||
candidates_3 = [
|
||||
b for b in candidates_2
|
||||
if b.get("min_height_px", 0) <= container_height_px
|
||||
]
|
||||
|
||||
# ── sidebar 제약: visuals/media 금지 ──
|
||||
if zone == "sidebar":
|
||||
candidates_3 = [
|
||||
b for b in candidates_3
|
||||
if b.get("category") not in ("visuals", "media")
|
||||
and b.get("zone") != "full-width-only"
|
||||
]
|
||||
|
||||
# ── 최종 선택 ──
|
||||
if candidates_3:
|
||||
selected = candidates_3[0]
|
||||
elif candidates_2:
|
||||
selected = candidates_2[0] # 크기 안 맞아도 최선
|
||||
logger.warning(
|
||||
f"[T-3] 컨테이너({container_height_px}px)에 맞는 블록 없음. "
|
||||
f"최선 선택: {selected['id']} (min_height_px={selected.get('min_height_px')})"
|
||||
)
|
||||
else:
|
||||
# fallback: 카테고리별 기본 블록
|
||||
fallback_category = allowed_categories[0] if allowed_categories else "emphasis"
|
||||
fallback_id = CATEGORY_FALLBACK.get(fallback_category, "dark-bullet-list")
|
||||
selected = _get_block_by_id(fallback_id) or catalog[0]
|
||||
visual_type = "fallback"
|
||||
logger.warning(f"[T-3] 후보 없음. fallback: {selected['id']}")
|
||||
|
||||
# variant 선택: compact variant가 있고, 컨테이너가 블록 min_height_px 근처면 compact
|
||||
variant = "default"
|
||||
variants = selected.get("variants", [])
|
||||
block_min_h = selected.get("min_height_px", 0)
|
||||
if variants:
|
||||
for v in variants:
|
||||
# compact: 컨테이너 높이가 블록 min_height의 2배 미만이면 compact 사용
|
||||
if v.get("id") == "compact" and container_height_px < block_min_h * 2:
|
||||
variant = "compact"
|
||||
break
|
||||
|
||||
return {
|
||||
"block_id": selected["id"],
|
||||
"variant": variant,
|
||||
"visual_type": visual_type,
|
||||
"catalog_entry": selected,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 디자인 레퍼런스 HTML 생성
|
||||
# ══════════════════════════════════════
|
||||
|
||||
# 블록별 샘플 데이터 (Jinja2 변수 치환용)
|
||||
_SAMPLE_DATA: dict[str, dict[str, Any]] = {
|
||||
# emphasis
|
||||
"dark-bullet-list": {
|
||||
"title": "핵심 요약",
|
||||
"bullets": ["첫 번째 포인트", "두 번째 포인트", "세 번째 포인트"],
|
||||
},
|
||||
"callout-warning": {
|
||||
"title": "주의사항",
|
||||
"description": "현재 접근 방식에 잠재적 문제가 있습니다.",
|
||||
"icon": "⚠️",
|
||||
},
|
||||
"callout-solution": {
|
||||
"title": "해결 방향",
|
||||
"description": "체계적 접근이 필요합니다.",
|
||||
"icon": "💡",
|
||||
},
|
||||
"banner-gradient": {
|
||||
"text": "핵심 메시지 한 줄",
|
||||
"sub_text": "부연 설명",
|
||||
},
|
||||
"comparison-2col": {
|
||||
"left_title": "항목 A",
|
||||
"left_content": "A의 특징과 설명",
|
||||
"right_title": "항목 B",
|
||||
"right_content": "B의 특징과 설명",
|
||||
},
|
||||
"quote-big-mark": {
|
||||
"quote_text": "중요한 인용문 텍스트",
|
||||
"source": "출처",
|
||||
},
|
||||
# cards
|
||||
"card-numbered": {
|
||||
"items": [
|
||||
{"title": "항목 1", "description": "첫 번째 항목 설명"},
|
||||
{"title": "항목 2", "description": "두 번째 항목 설명"},
|
||||
{"title": "항목 3", "description": "세 번째 항목 설명"},
|
||||
],
|
||||
},
|
||||
"card-icon-desc": {
|
||||
"cards": [
|
||||
{"icon": "🏗️", "title": "기술 A", "description": "기술 A 설명"},
|
||||
{"icon": "🌍", "title": "기술 B", "description": "기술 B 설명"},
|
||||
{"icon": "🔮", "title": "기술 C", "description": "기술 C 설명"},
|
||||
],
|
||||
},
|
||||
# visuals
|
||||
"venn-diagram": {
|
||||
"center_label": "DX",
|
||||
"center_sub": "디지털 전환",
|
||||
"items": [
|
||||
{"label": "BIM", "color": "#ff6b35"},
|
||||
{"label": "GIS", "color": "#00d4aa"},
|
||||
{"label": "DT", "color": "#ffd700"},
|
||||
],
|
||||
},
|
||||
"keyword-circle-row": {
|
||||
"keywords": [
|
||||
{"letter": "B", "label": "BIM", "description": "건물정보모델링"},
|
||||
{"letter": "G", "label": "GIS", "description": "지리정보시스템"},
|
||||
{"letter": "D", "label": "DX", "description": "디지털 전환"},
|
||||
],
|
||||
},
|
||||
"flow-arrow-horizontal": {
|
||||
"steps": [
|
||||
{"label": "분석"},
|
||||
{"label": "설계"},
|
||||
{"label": "시공"},
|
||||
{"label": "관리"},
|
||||
],
|
||||
},
|
||||
"process-horizontal": {
|
||||
"steps": [
|
||||
{"number": "1", "title": "현황 분석", "description": "현재 상태 진단"},
|
||||
{"number": "2", "title": "전략 수립", "description": "로드맵 설계"},
|
||||
{"number": "3", "title": "실행", "description": "단계적 도입"},
|
||||
],
|
||||
},
|
||||
# tables
|
||||
"compare-2col-split": {
|
||||
"left_title": "기존",
|
||||
"right_title": "개선",
|
||||
"rows": [
|
||||
{"left": "수작업", "center": "프로세스", "right": "자동화"},
|
||||
{"left": "2D 도면", "center": "설계 도구", "right": "3D BIM"},
|
||||
],
|
||||
},
|
||||
"compare-3col-badge": {
|
||||
"headers": ["구분", "항목 A", "항목 B"],
|
||||
"rows": [
|
||||
["범위", "넓음", "좁음"],
|
||||
["목적", "혁신", "관리"],
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_design_reference(
|
||||
block_id: str,
|
||||
variant: str = "default",
|
||||
catalog_entry: dict | None = None,
|
||||
) -> str:
|
||||
"""블록의 디자인 레퍼런스 HTML 생성.
|
||||
|
||||
Jinja2 변수를 샘플 데이터로 치환한 완성 HTML + 구조 의도 주석.
|
||||
LLM이 이 구조를 70~90% 복사 → "발명"하지 않고 검증된 구조를 따름.
|
||||
"""
|
||||
if catalog_entry is None:
|
||||
catalog_entry = _get_block_by_id(block_id)
|
||||
if catalog_entry is None:
|
||||
logger.warning(f"[T-3] 블록 {block_id} 카탈로그에 없음")
|
||||
return ""
|
||||
|
||||
# 템플릿 경로 결정
|
||||
template_path = catalog_entry.get("template", "")
|
||||
if variant != "default":
|
||||
for v in catalog_entry.get("variants", []):
|
||||
if v.get("id") == variant and v.get("template"):
|
||||
template_path = v["template"]
|
||||
break
|
||||
|
||||
if not template_path:
|
||||
logger.warning(f"[T-3] 블록 {block_id} 템플릿 경로 없음")
|
||||
return ""
|
||||
|
||||
# 샘플 데이터로 Jinja2 렌더링
|
||||
sample = _SAMPLE_DATA.get(block_id, {})
|
||||
|
||||
try:
|
||||
env = _get_jinja_env()
|
||||
template = env.get_template(template_path)
|
||||
rendered = template.render(**sample)
|
||||
except Exception as e:
|
||||
logger.warning(f"[T-3] 블록 {block_id} 렌더링 실패: {e}")
|
||||
# 렌더링 실패 시 템플릿 원본 반환 (Jinja 변수 포함)
|
||||
try:
|
||||
raw = (TEMPLATES_DIR / template_path).read_text(encoding="utf-8")
|
||||
rendered = raw
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# 구조 의도 주석 추가
|
||||
visual = catalog_entry.get("visual", "")
|
||||
visual_diff = catalog_entry.get("visual_diff", "")
|
||||
when = catalog_entry.get("when", "")
|
||||
|
||||
header = f"<!-- {block_id}: {visual[:80]} -->\n"
|
||||
if visual_diff:
|
||||
header += f"<!-- 차별점: {visual_diff[:100]} -->\n"
|
||||
header += f"<!-- 적합 상황: {when[:80]} -->\n"
|
||||
|
||||
# schema 정보를 SLOT 주석으로 변환
|
||||
schema = catalog_entry.get("schema", {})
|
||||
if schema:
|
||||
schema_comments = []
|
||||
for slot_name, spec in schema.items():
|
||||
if slot_name.startswith("max_"):
|
||||
body_val = spec.get("body", "")
|
||||
schema_comments.append(f"<!-- SLOT: {slot_name} = {body_val} -->")
|
||||
else:
|
||||
ml = spec.get("max_lines", "?")
|
||||
fs = spec.get("font_size", "?")
|
||||
rc = spec.get("ref_chars", {}).get("body", "?")
|
||||
schema_comments.append(
|
||||
f"<!-- SLOT: {slot_name} ({ml}줄, {fs}px, max {rc}자) -->"
|
||||
)
|
||||
header += "\n".join(schema_comments) + "\n"
|
||||
|
||||
return header + rendered
|
||||
|
||||
|
||||
def select_and_generate_references(
|
||||
topics: list[dict[str, Any]],
|
||||
containers: dict[str, Any],
|
||||
page_structure: dict[str, Any],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""역할별 참고 블록 선택 + 디자인 레퍼런스 HTML 생성.
|
||||
|
||||
Stage 1.7에서 호출. 각 역할(본심/배경/첨부/결론)에 대해
|
||||
relation_type + expression_hint 기반으로 참고 블록을 선택하고
|
||||
디자인 레퍼런스 HTML을 생성.
|
||||
|
||||
Returns:
|
||||
{"본심": {"block_id": ..., "design_reference_html": ..., ...}, ...}
|
||||
"""
|
||||
references: dict[str, list[dict[str, Any]]] = {}
|
||||
topic_map = {t.get("id"): t for t in topics}
|
||||
|
||||
for role, info in page_structure.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
topic_ids = info.get("topic_ids", [])
|
||||
if not topic_ids:
|
||||
continue
|
||||
|
||||
# 컨테이너 정보
|
||||
container = containers.get(role)
|
||||
if container is None:
|
||||
continue
|
||||
if hasattr(container, "height_px"):
|
||||
total_height_px = container.height_px
|
||||
zone = container.zone
|
||||
else:
|
||||
total_height_px = container.get("height_px", 0) # 이전 Stage에서 반드시 제공
|
||||
zone = container.get("zone", "body")
|
||||
|
||||
# V-1 + Phase V: 같은 영역 꼭지들의 layer 관계에 따라 블록 구조 결정
|
||||
# layer가 다르면 → 주종 관계 → 블록 1개 (주 꼭지 기준, 종속은 하위 요소)
|
||||
# layer가 같으면 → 동급 → 블록 N개 병렬
|
||||
topic_layers = {tid: topic_map.get(tid, {}).get("layer", "") for tid in topic_ids}
|
||||
unique_layers = set(topic_layers.values())
|
||||
is_hierarchical = len(unique_layers) > 1 and len(topic_ids) > 1
|
||||
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
_tokens = _load_design_tokens()
|
||||
gap_between = _tokens["spacing_small"]
|
||||
|
||||
if is_hierarchical:
|
||||
# 주종 관계: 주 꼭지(intro/core) 기준으로 블록 1개 선택
|
||||
# 종속 꼭지(supporting)는 블록 안에 하위 요소로 포함
|
||||
primary_tid = None
|
||||
supporting_tids = []
|
||||
# layer 우선순위: core > intro > supporting > conclusion
|
||||
layer_priority = {"core": 0, "intro": 1, "conclusion": 2, "supporting": 3}
|
||||
sorted_tids = sorted(topic_ids, key=lambda t: layer_priority.get(topic_layers.get(t, ""), 9))
|
||||
primary_tid = sorted_tids[0]
|
||||
supporting_tids = sorted_tids[1:]
|
||||
|
||||
primary_topic = topic_map.get(primary_tid, {})
|
||||
relation_type = primary_topic.get("relation_type", "none")
|
||||
expression_hint = primary_topic.get("expression_hint", "")
|
||||
|
||||
selection = select_reference_block(
|
||||
relation_type=relation_type,
|
||||
expression_hint=expression_hint,
|
||||
container_height_px=total_height_px,
|
||||
zone=zone,
|
||||
role=role,
|
||||
)
|
||||
ref_html = generate_design_reference(
|
||||
block_id=selection["block_id"],
|
||||
variant=selection["variant"],
|
||||
catalog_entry=selection["catalog_entry"],
|
||||
)
|
||||
schema_info = selection["catalog_entry"].get("schema", {})
|
||||
|
||||
# 블록 1개에 모든 꼭지 정보를 담음
|
||||
role_refs = [{
|
||||
"block_id": selection["block_id"],
|
||||
"variant": selection["variant"],
|
||||
"visual_type": selection["visual_type"],
|
||||
"schema_info": schema_info,
|
||||
"design_reference_html": ref_html,
|
||||
"topic_id": primary_tid,
|
||||
"supporting_topic_ids": supporting_tids,
|
||||
"is_hierarchical": True,
|
||||
}]
|
||||
logger.info(
|
||||
f"[V-1] {role}: 주종 관계 → 블록 1개 ({selection['block_id']}), "
|
||||
f"주={primary_tid}, 종={supporting_tids}"
|
||||
)
|
||||
else:
|
||||
# 동급: 꼭지별 블록 선택
|
||||
topic_count = len(topic_ids)
|
||||
available_for_topics = total_height_px - gap_between * max(0, topic_count - 1)
|
||||
min_block_height = min(
|
||||
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
|
||||
default=1,
|
||||
)
|
||||
per_topic_height = max(min_block_height, available_for_topics // topic_count)
|
||||
|
||||
role_refs = []
|
||||
for tid in topic_ids:
|
||||
topic = topic_map.get(tid, {})
|
||||
relation_type = topic.get("relation_type", "none")
|
||||
expression_hint = topic.get("expression_hint", "")
|
||||
|
||||
selection = select_reference_block(
|
||||
relation_type=relation_type,
|
||||
expression_hint=expression_hint,
|
||||
container_height_px=per_topic_height,
|
||||
zone=zone,
|
||||
role=role,
|
||||
)
|
||||
ref_html = generate_design_reference(
|
||||
block_id=selection["block_id"],
|
||||
variant=selection["variant"],
|
||||
catalog_entry=selection["catalog_entry"],
|
||||
)
|
||||
|
||||
schema_info = selection["catalog_entry"].get("schema", {})
|
||||
|
||||
role_refs.append({
|
||||
"block_id": selection["block_id"],
|
||||
"variant": selection["variant"],
|
||||
"visual_type": selection["visual_type"],
|
||||
"schema_info": schema_info,
|
||||
"design_reference_html": ref_html,
|
||||
"topic_id": tid,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"[V-1] {role}/꼭지{tid}: {selection['block_id']} "
|
||||
f"(visual_type={selection['visual_type']}, variant={selection['variant']}, "
|
||||
f"budget={per_topic_height}px)"
|
||||
)
|
||||
|
||||
references[role] = role_refs
|
||||
|
||||
return references
|
||||
@@ -304,11 +304,9 @@ async def _call_kei_editor_with_retry(prompt: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": full_prompt,
|
||||
"session_id": "design-agent-editor",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
|
||||
@@ -376,14 +376,15 @@ def verify_no_forbidden_content(
|
||||
# Layer 3: 구조 검증
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
# Phase T: overflow:hidden 필수 요구 제거.
|
||||
# Phase T 프롬프트가 "overflow:hidden 금지"를 지시하므로 L3에서 요구하면 모순.
|
||||
# 텍스트 잘림은 L4(Selenium 실측)에서 감지.
|
||||
REQUIRED_PATTERNS: dict[str, list[str]] = {
|
||||
"body_bg": ["overflow:hidden|overflow: hidden"],
|
||||
"body_bg": [],
|
||||
"body_core": [
|
||||
"overflow:hidden|overflow: hidden",
|
||||
"key-msg",
|
||||
],
|
||||
"sidebar": [
|
||||
"overflow:hidden|overflow: hidden",
|
||||
"padding-left",
|
||||
"text-indent",
|
||||
],
|
||||
@@ -395,8 +396,12 @@ def verify_structure(
|
||||
generated_html: str,
|
||||
area_name: str,
|
||||
has_image: bool = False,
|
||||
font_hierarchy: dict | None = None,
|
||||
) -> VerificationResult:
|
||||
"""필수 CSS/HTML 패턴이 존재하는지 검증."""
|
||||
"""필수 CSS/HTML 패턴이 존재하는지 검증.
|
||||
|
||||
Phase T-8: font_hierarchy가 제공되면 폰트 위계 위반도 검사.
|
||||
"""
|
||||
patterns = REQUIRED_PATTERNS.get(area_name, [])
|
||||
missing = []
|
||||
|
||||
@@ -410,13 +415,36 @@ def verify_structure(
|
||||
if "slide-img-" not in generated_html:
|
||||
missing.append("slide-img-* (이미지 태그)")
|
||||
|
||||
# Phase T-8: 폰트 위계 검사
|
||||
font_warnings = []
|
||||
if font_hierarchy:
|
||||
role_font_map = {
|
||||
"body_bg": font_hierarchy.get("bg", 11),
|
||||
"body_core": font_hierarchy.get("core", 12),
|
||||
"sidebar": font_hierarchy.get("sidebar", 10),
|
||||
"footer": font_hierarchy.get("core", 12),
|
||||
}
|
||||
max_font = role_font_map.get(area_name)
|
||||
if max_font:
|
||||
# HTML에서 font-size 값 추출
|
||||
font_sizes = re.findall(r"font-size:\s*(\d+(?:\.\d+)?)\s*px", generated_html)
|
||||
for fs_str in font_sizes:
|
||||
fs = float(fs_str)
|
||||
if fs > max_font + 1: # 1px 허용 오차
|
||||
font_warnings.append(
|
||||
f"폰트 위계 위반: {area_name}에서 {fs}px 사용 (최대 {max_font}px)"
|
||||
)
|
||||
|
||||
passed = len(missing) == 0
|
||||
all_errors = [f"필수 패턴 누락: {p}" for p in missing]
|
||||
|
||||
return VerificationResult(
|
||||
passed=passed,
|
||||
area_name=area_name,
|
||||
checks={"structure": passed},
|
||||
score=1.0 if passed else (1.0 - len(missing) / max(1, len(patterns))),
|
||||
errors=[f"필수 패턴 누락: {p}" for p in missing],
|
||||
errors=all_errors,
|
||||
warnings=font_warnings,
|
||||
)
|
||||
|
||||
|
||||
@@ -551,18 +579,35 @@ async def generate_with_retry(
|
||||
return [topic_map[tid] for tid in info.get("topic_ids", []) if tid in topic_map]
|
||||
|
||||
area_texts = {}
|
||||
|
||||
def _get_role_text(role_topics):
|
||||
"""structured_text 우선, 없으면 source_hint 키워드로 sections 매칭."""
|
||||
texts = []
|
||||
for t in role_topics:
|
||||
st = t.get("structured_text", "")
|
||||
if st:
|
||||
texts.append(st)
|
||||
else:
|
||||
# fallback: source_hint에서 키워드 추출하여 매칭
|
||||
hint = t.get("source_hint", "")
|
||||
keywords = [w for w in hint.split() if len(w) >= 2][:3]
|
||||
matched = _map_sections_for_role(sections, [t], keywords) if keywords else ""
|
||||
if matched:
|
||||
texts.append(matched)
|
||||
return "\n\n".join(texts) if texts else ""
|
||||
|
||||
bg_topics = get_topics_for_role("배경")
|
||||
if bg_topics:
|
||||
area_texts["body_bg"] = _map_sections_for_role(sections, bg_topics, ["혼용", "사례"])
|
||||
area_texts["body_bg"] = _get_role_text(bg_topics)
|
||||
core_topics = get_topics_for_role("본심")
|
||||
if core_topics:
|
||||
area_texts["body_core"] = _map_sections_for_role(sections, core_topics, ["관계", "핵심기술", "DX"])
|
||||
area_texts["body_core"] = _get_role_text(core_topics)
|
||||
ref_topics = get_topics_for_role("첨부")
|
||||
if ref_topics:
|
||||
area_texts["sidebar"] = _get_definitions(content)
|
||||
area_texts["sidebar"] = _get_role_text(ref_topics)
|
||||
conclusion_topics = get_topics_for_role("결론")
|
||||
if conclusion_topics:
|
||||
area_texts["footer"] = _get_conclusion(content)
|
||||
area_texts["footer"] = _get_role_text(conclusion_topics)
|
||||
|
||||
has_image_areas = set()
|
||||
if images:
|
||||
|
||||
@@ -509,11 +509,9 @@ async def _opus_batch_recommend(
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-p-recommend",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
@@ -615,11 +613,9 @@ async def _opus_block_recommendation(
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-opus",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
|
||||
1040
src/fit_verifier.py
Normal file
@@ -1,6 +1,9 @@
|
||||
"""Phase S: AI HTML 생성기 — 검증 합격 프롬프트 템플릿 기반.
|
||||
"""Phase T: AI HTML 생성기 — 동적 프롬프트 생성.
|
||||
|
||||
영역별 개별 호출. 검증에서 합격한 프롬프트의 구조/디자인은 고정, 텍스트만 동적.
|
||||
영역별 개별 호출. Phase T context(폰트 위계, 블록 레퍼런스, 디자인 예산)에서
|
||||
모든 수치를 동적으로 가져와 프롬프트를 조립.
|
||||
|
||||
Phase S 하드코딩 프롬프트(BG_PROMPT 등) → build_area_prompt() 동적 생성으로 교체.
|
||||
|
||||
역할 분리:
|
||||
Kei (1단계): 콘텐츠 분석
|
||||
@@ -22,51 +25,311 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 검증 합격 프롬프트 템플릿
|
||||
# 구조/디자인은 고정. {변수}만 동적 교체.
|
||||
# Phase T: 동적 프롬프트 생성
|
||||
# Phase S 하드코딩 프롬프트 → context 기반 동적 생성으로 교체
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
BG_PROMPT = """다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
|
||||
|
||||
## 핵심 원칙
|
||||
이 영역은 **보조 영역**이다. 본심(핵심 콘텐츠)보다 시각적으로 약해야 한다.
|
||||
다크 배경 절대 금지. 흰색/연회색 위에 텍스트를 놓는 라이트 디자인으로.
|
||||
|
||||
## 크기
|
||||
- width: 100%, height: {height}px (고정, overflow:hidden)
|
||||
|
||||
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
|
||||
{content_block}
|
||||
|
||||
## 텍스트 규칙 (반드시 적용)
|
||||
# 공통 텍스트 규칙 (모든 영역 동일)
|
||||
_COMMON_TEXT_RULES = """## 텍스트 규칙 (반드시 적용)
|
||||
1. 원본 텍스트의 단어를 한 글자도 빼지 마라. 축약/요약 절대 금지.
|
||||
2. 마침표(.)로 끝나는 문장이 2개 이상이면 각각 별도 불릿(•)으로 분리.
|
||||
3. 개조식 어미 변환: 문장 끝 1-2글자만 변환. 그 외 단어는 절대 건드리지 마라.
|
||||
- "~있다" → "~있음", "~한다" → "~함", "~이다" → 삭제, "~된다" → "~됨"
|
||||
예: "인식되고 있다" → "인식되고 있음" (단어 삭제 없이 끝만 변환)
|
||||
4. 원본에 없는 텍스트를 추가하지 마라.
|
||||
5. 동일한 내용을 다른 형태로 2번 넣지 마라. 상세 내용은 "[상세보기]" 텍스트 링크만 남기고 본문에서 제거."""
|
||||
|
||||
## 디자인
|
||||
- 배경: background: #f8fafc (연회색, 다크 배경 절대 금지)
|
||||
- border: 1px solid #e2e8f0, border-radius: 6px
|
||||
- 전체 padding: 10px 14px (여백 최소화)
|
||||
- 제목: 12px bold #334155, margin-bottom: 4px
|
||||
- 본문: 11px #475569, line-height: 1.4, 핵심 키워드 <strong style="color:#1e293b"> 처리
|
||||
- 토픽이 여러 개이면 가로로 나란히 (flex, gap:8px)
|
||||
- 각 토픽 구분: background:#ffffff, border-left: 2px solid #94a3b8, padding: 6px 8px (여백 최소화)
|
||||
- 토픽 제목: 10px bold #334155, margin-bottom: 2px
|
||||
- 토픽 내용: 9px #64748b, line-height: 1.3
|
||||
- 들여쓰기: 불릿은 인라인 style만 사용. CSS class 사용 금지 (<style> 블록 금지).
|
||||
불릿 마커: 텍스트로 "• " 직접 삽입 (::before 금지)
|
||||
들여쓰기: style="padding-left:14px; text-indent:-14px;" 인라인으로.
|
||||
- 폰트를 줄여서라도 높이 안에 맞출 것. overflow:hidden이므로 넘치면 잘림.
|
||||
# 공통 HTML 규칙
|
||||
_COMMON_HTML_RULES = """## HTML 규칙
|
||||
- inline style만 사용. <style> 블록 금지.
|
||||
- overflow:hidden 금지 (텍스트 잘림 방지).
|
||||
- 모든 텍스트가 보여야 한다. 잘리는 텍스트가 있으면 안 됨.
|
||||
- <style> 블록을 만들지 마라. 모든 스타일을 인라인 style 속성으로만 적용하라.
|
||||
|
||||
HTML만 반환. <style> 블록 금지. 설명 없이 코드만."""
|
||||
- HTML만 반환. 설명 없이 코드만."""
|
||||
|
||||
|
||||
CORE_PROMPT = """다음 콘텐츠를 본심 영역 HTML로 만들어라.
|
||||
def _calc_indent(font_size: float) -> tuple[int, int]:
|
||||
"""폰트 크기에 맞는 들여쓰기 px 계산.
|
||||
불릿 마커 "• " 폭 ≈ font_size × 1.2.
|
||||
Returns: (padding_left, text_indent)
|
||||
"""
|
||||
import math
|
||||
pl = math.ceil(font_size * 1.2)
|
||||
return pl, -pl
|
||||
|
||||
|
||||
def build_area_prompt(
|
||||
role: str,
|
||||
content_block: str,
|
||||
phase_t: dict,
|
||||
height_px: int,
|
||||
width_px: int,
|
||||
images: list[dict] | None = None,
|
||||
core_message: str = "",
|
||||
) -> str:
|
||||
"""Phase T context에서 모든 수치를 동적으로 가져와 프롬프트 생성.
|
||||
|
||||
하드코딩 CSS 값 0개. 모든 수치는 phase_t context에서.
|
||||
|
||||
Args:
|
||||
role: "배경" | "본심" | "첨부" | "결론"
|
||||
content_block: 원본 텍스트 (이 영역에 해당하는)
|
||||
phase_t: analysis["phase_t"] dict (font_hierarchy, references, design_budgets, container_ratio)
|
||||
height_px: 이 영역의 높이
|
||||
width_px: 이 영역의 너비
|
||||
images: 이미지 정보 (본심에서만)
|
||||
core_message: 핵심 메시지 (본심에서만)
|
||||
"""
|
||||
fh = phase_t.get("font_hierarchy", {})
|
||||
refs = phase_t.get("references", {})
|
||||
budgets = phase_t.get("design_budgets", {})
|
||||
|
||||
# 역할별 폰트 매핑
|
||||
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||
font_size = fh.get(role_font_map.get(role, "core"), 12)
|
||||
|
||||
# 들여쓰기 (폰트 크기 기반)
|
||||
indent_pl, indent_ti = _calc_indent(font_size)
|
||||
|
||||
# V-1: 꼭지별 블록 레퍼런스 (리스트)
|
||||
ref_list = refs.get(role, [])
|
||||
if isinstance(ref_list, dict):
|
||||
# 하위호환: 이전 형식(dict) → 리스트로 변환
|
||||
ref_list = [ref_list]
|
||||
# 모든 블록의 디자인 레퍼런스 HTML을 결합
|
||||
ref_html = "\n\n".join(r.get("design_reference_html", "") for r in ref_list if r)
|
||||
|
||||
# 디자인 예산
|
||||
budget = budgets.get(role, {})
|
||||
avail_h = budget.get("available_height_px", 0)
|
||||
avail_w = budget.get("available_width_px", 0)
|
||||
|
||||
parts = []
|
||||
|
||||
# ── Phase V Step 7: 서브 컨테이너 레이아웃 ──
|
||||
sub_layouts = phase_t.get("sub_layouts", {})
|
||||
role_layout = sub_layouts.get(role, {})
|
||||
sub_containers = role_layout.get("sub_containers", [])
|
||||
if sub_containers:
|
||||
layout_lines = [f"## 세부 레이아웃 (Phase V Step 7) — 반드시 이 구조를 따르라"]
|
||||
for sc in sub_containers:
|
||||
sc_name = sc.get("name", "")
|
||||
sc_w = int(sc.get("width_px", 0))
|
||||
sc_h = int(sc.get("height_px", 0))
|
||||
sc_align = sc.get("align", "stretch")
|
||||
layout_lines.append(f"- {sc_name}: {sc_w}×{sc_h}px (align: {sc_align})")
|
||||
|
||||
# 서브 컨테이너 조합 지시
|
||||
names = [sc.get("name", "") for sc in sub_containers]
|
||||
if "svg" in names and "text_and_table" in names:
|
||||
svg_sc = next(sc for sc in sub_containers if sc["name"] == "svg")
|
||||
txt_sc = next(sc for sc in sub_containers if sc["name"] == "text_and_table")
|
||||
layout_lines.append(f"\n구조: SVG({int(svg_sc['width_px'])}px, 좌측) + 텍스트/표({int(txt_sc['width_px'])}px, 우측) — display:flex")
|
||||
if "keymsg" in names:
|
||||
layout_lines.append("key-msg: 컨테이너 최하단 전체 폭, flex-shrink:0")
|
||||
|
||||
table_rows = role_layout.get("table_rows", 0)
|
||||
if table_rows > 0:
|
||||
layout_lines.append(f"보충 표: {table_rows}행 (텍스트 아래 여유 공간에 배치)")
|
||||
|
||||
parts.append("\n".join(layout_lines) + "\n")
|
||||
|
||||
# ── Phase V: Stage 1.8 결과를 프롬프트에 반영 ──
|
||||
fit_result = phase_t.get("fit_result", {})
|
||||
enhancements = phase_t.get("enhancements", {})
|
||||
|
||||
# 재배분된 컨테이너 크기
|
||||
redist = fit_result.get("redistribution", {})
|
||||
if redist.get(role):
|
||||
redistributed_h = int(redist[role])
|
||||
parts.append(f"## 컨테이너 크기 (재배분 후)\n- height: {redistributed_h}px, width: {width_px}px\n- 이 크기를 절대 초과하지 마라. overflow 금지.\n")
|
||||
|
||||
# 강조 블록
|
||||
for eb in enhancements.get("emphasis_blocks", []):
|
||||
if eb.get("role") == role:
|
||||
sentence = eb.get("sentence", "")
|
||||
parts.append(f"## 강조 (Phase V)\n다음 문장을 강조 블록으로 처리하라 (배경색 반전, bold):\n\"{sentence}\"\n")
|
||||
|
||||
# bold 키워드
|
||||
role_bolds = enhancements.get("bold_keywords", {}).get(role, [])
|
||||
if role_bolds:
|
||||
parts.append(f"## bold 키워드 (Phase V)\n다음 키워드가 본문에 나올 때 <strong>으로 감싸라:\n{role_bolds}\n")
|
||||
|
||||
# 보충 블록 + Step 7 표 행 수
|
||||
table_rows = role_layout.get("table_rows", 0)
|
||||
for sb in enhancements.get("supplement_blocks", []):
|
||||
if sb.get("role") == role:
|
||||
row_hint = f"\n- 표 행 수: {table_rows}행 (Step 7 계산 결과)" if table_rows > 0 else ""
|
||||
parts.append(f"## 보충 콘텐츠 (Phase V)\n여유 공간에 다음 콘텐츠의 핵심 요약을 넣어라:\n- 출처: {sb.get('content_source', '')}\n- 블록: {sb.get('block_id', '')}{row_hint}\n")
|
||||
|
||||
# V-4: Kei 에스컬레이션 결정 (공간 부족 시 Kei가 내린 판단)
|
||||
for kd in enhancements.get("kei_decisions", []):
|
||||
if kd.get("role") == role:
|
||||
action = kd.get("action", "")
|
||||
detail = kd.get("detail", "")
|
||||
if action == "inline":
|
||||
parts.append(f"## Kei 결정 (V-4): 인라인 축약\n{detail}\n사례/근거를 괄호 한 줄로 축약하라. 상세는 팝업 링크로.\n")
|
||||
elif action == "trim":
|
||||
parts.append(f"## Kei 결정 (V-4): 텍스트 축약\n{detail}\n핵심만 남기고 분량을 줄여라.\n")
|
||||
elif action == "popup":
|
||||
parts.append(f"## Kei 결정 (V-4): 팝업 분리\n{detail}\n상세 내용을 제거하고 \"상세보기\" 링크만 남겨라.\n")
|
||||
elif action == "merge":
|
||||
parts.append(f"## Kei 결정 (V-4): 꼭지 합치기\n{detail}\n여러 꼭지를 하나의 흐름으로 자연스럽게 연결하라.\n")
|
||||
|
||||
# V-7: 종속 꼭지 처리 지시
|
||||
for st in enhancements.get("subordinate_treatments", []):
|
||||
if st.get("role") == role:
|
||||
detail = st.get("detail", {})
|
||||
treatment = detail.get("treatment", "inline")
|
||||
s_tid = detail.get("supporting_topic_id", "?")
|
||||
s_purpose = detail.get("has_popup", False)
|
||||
popup_title = detail.get("popup_title", "")
|
||||
if treatment == "inline":
|
||||
parts.append(f"## 종속 꼭지 처리 (V-7)\n꼭지{s_tid}의 내용을 인라인 1~2줄로 축약하여 주 블록 안에 삽입하라.\n" +
|
||||
(f"팝업 \"{popup_title}\" 참조가 있으면 링크만 남겨라.\n" if popup_title else ""))
|
||||
elif treatment == "sub_block":
|
||||
parts.append(f"## 종속 꼭지 처리 (V-7)\n꼭지{s_tid}의 내용을 하위 블록(border-left + 들여쓰기)으로 분리하여 주 블록 아래에 배치하라.\n")
|
||||
|
||||
# ── 들여쓰기 예시 HTML (TP-4: Sonnet이 정확히 따르도록 구체적 예시 제공) ──
|
||||
indent_example = f"""<div style="padding-left:{indent_pl}px; text-indent:{indent_ti}px; font-size:{font_size}px;">• 첫줄 텍스트가 여기서 시작하고
|
||||
둘째줄도 정확히 같은 위치에서 시작한다</div>"""
|
||||
|
||||
# ── 역할별 지시 ──
|
||||
if role == "배경":
|
||||
parts.append(f"""다음 콘텐츠를 배경(보조) 영역 HTML로 만들어라.
|
||||
|
||||
## 핵심 원칙
|
||||
이 영역은 **보조 영역**이다. 본심(핵심)보다 시각적으로 **반드시 약해야** 한다.
|
||||
- 다크 배경(#1a~#2a 계열) 절대 금지. 밝은 톤만 사용.
|
||||
- 본심이 슬라이드의 주인공. 이 영역은 조용하고 가벼워야 한다.
|
||||
|
||||
## 크기
|
||||
- width: 100% (body 영역 전체 폭), height: {height_px}px
|
||||
- 본심과 가로 폭이 반드시 동일해야 한다.
|
||||
|
||||
## 폰트
|
||||
- 이 영역의 폰트: {font_size}px. 제목은 {font_size + 1}px bold.
|
||||
- 이보다 큰 폰트 사용 금지. (위계: 핵심{fh.get('key_msg',14)}px > 본심{fh.get('core',12)}px >= 배경{font_size}px > 첨부{fh.get('sidebar',10)}px)
|
||||
|
||||
## 들여쓰기 — 반드시 아래 예시를 정확히 따라라 (TP-4)
|
||||
{indent_example}
|
||||
|
||||
불릿이 있으면 반드시 위 style을 그대로 사용. padding-left:{indent_pl}px; text-indent:{indent_ti}px;""")
|
||||
|
||||
elif role == "본심":
|
||||
img_instruction = ""
|
||||
if images:
|
||||
for img in images:
|
||||
img_id = f"slide-img-{img.get('topic_id', '')}"
|
||||
img_instruction = f"""
|
||||
## 이미지 (TP-2: 텍스트가 주인공, 이미지는 보조)
|
||||
- <img id="{img_id}" src="placeholder"> (후처리에서 교체)
|
||||
- 이미지는 반드시 float:right. 텍스트 옆에 배치. 이미지가 전체 폭을 차지하면 안 됨.
|
||||
- 이미지 width: 최대 250px. 텍스트가 이미지를 감싸도록.
|
||||
- 이미지가 주인공이 아니다. 텍스트가 주인공이다."""
|
||||
|
||||
parts.append(f"""다음 콘텐츠를 본심(핵심) 영역 HTML로 만들어라.
|
||||
|
||||
## 핵심 원칙 (TP-2)
|
||||
이 영역이 슬라이드의 **주인공**이다. 가장 큰 시각적 비중.
|
||||
- **텍스트가 주인공**, 이미지/도형은 텍스트를 보조하는 역할.
|
||||
- 핵심 메시지(key-msg)가 시각적으로 **가장 눈에 띄어야** 함.
|
||||
- key-msg에 배경색 + 테두리 + 큰 폰트를 적용하여 강조.
|
||||
|
||||
## 크기
|
||||
- width: 100% (body 영역 전체 폭), max-height: {height_px}px
|
||||
- 배경과 가로 폭이 반드시 동일해야 한다.
|
||||
|
||||
## 폰트
|
||||
- 이 영역의 본문 폰트: {font_size}px. line-height: 1.75.
|
||||
- 핵심 메시지(key-msg): {fh.get('key_msg', 14)}px bold. 반드시 class="key-msg" 포함.
|
||||
- 이 영역에서 {fh.get('key_msg', 14)}px보다 큰 폰트 사용 금지.
|
||||
|
||||
## 들여쓰기 — 반드시 아래 예시를 정확히 따라라 (TP-4)
|
||||
{indent_example}
|
||||
|
||||
주불릿: padding-left:{indent_pl}px; text-indent:{indent_ti}px;
|
||||
부불릿: padding-left:{indent_pl * 2}px; text-indent:{indent_ti}px;
|
||||
|
||||
## 핵심 메시지 (반드시 포함)
|
||||
- 하단에 key-msg 영역: "{core_message}"
|
||||
- HTML: <div class="key-msg" style="font-size:{fh.get('key_msg', 14)}px; font-weight:bold; padding:8px; border-radius:6px; text-align:center; margin-top:10px;">...</div>
|
||||
|
||||
## 팝업/상세 내용 (TP-5: 링크 위치)
|
||||
- 상세 내용(비교표 등)은 본문에 넣지 마라. 별도 첨부 파일로 분리됨.
|
||||
- "상세보기" 링크를 **해당 섹션 제목 옆 우측**에 작게 배치 (10px, #2563eb).
|
||||
- 예시:
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span style="font-weight:bold;">섹션 제목</span>
|
||||
<span style="font-size:10px; color:#2563eb;">상세보기 →</span>
|
||||
</div>
|
||||
- 본문 중간에 한 줄로 넣지 마라. 동일 내용을 2번 넣지 마라.
|
||||
{img_instruction}""")
|
||||
|
||||
elif role == "첨부":
|
||||
parts.append(f"""다음 콘텐츠를 sidebar 영역 HTML로 만들어라.
|
||||
|
||||
## 크기 (TP-3: 잘림 방지)
|
||||
- width: 100% (부모 grid cell에 맞춤). height: {height_px}px.
|
||||
- 최외곽 div에 width:100%를 쓰라. 절대 px 값으로 width를 지정하지 마라.
|
||||
- 이 크기 안에 **모든 내용이 들어가야** 한다. 넘치면 폰트를 줄여서 맞춰라.
|
||||
- 각 카드 width: 100%. 컨테이너 밖으로 넘치면 안 됨.
|
||||
- word-break: break-word (긴 영문도 줄바꿈)
|
||||
|
||||
## 폰트
|
||||
- 이 영역의 폰트: {font_size}px. 제목은 {font_size + 1}px bold.
|
||||
- 이보다 큰 폰트 사용 금지. (위계: 이 영역은 가장 작은 폰트)
|
||||
|
||||
## 들여쓰기 — 반드시 아래 예시를 정확히 따라라 (TP-4)
|
||||
{indent_example}
|
||||
|
||||
## 카드 구조
|
||||
- 각 용어를 카드로 구분. 카드 내부 padding 포함하여 width 100% 안에 맞출 것.
|
||||
- 카드 간 간격 8px.
|
||||
- 출처가 있으면 카드 하단에 작게 ({max(font_size - 2, 8)}px).""")
|
||||
|
||||
elif role == "결론":
|
||||
parts.append(f"""다음 콘텐츠를 결론 배너 HTML로 만들어라.
|
||||
|
||||
## 크기
|
||||
- width: 100%, height: {height_px}px
|
||||
|
||||
## 폰트
|
||||
- 핵심 메시지: {font_size}px bold white
|
||||
- 이 영역은 핵심 메시지 한 줄. 가장 큰 폰트.""")
|
||||
|
||||
# ── 공통: 콘텐츠 ──
|
||||
parts.append(f"""
|
||||
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
|
||||
{content_block}""")
|
||||
|
||||
# ── 공통: 텍스트 규칙 ──
|
||||
parts.append(_COMMON_TEXT_RULES)
|
||||
|
||||
# ── 블록 레퍼런스 (있으면) ──
|
||||
if ref_html:
|
||||
if len(ref_html) > 3000:
|
||||
ref_html = ref_html[:3000] + "\n<!-- truncated -->"
|
||||
parts.append(f"""
|
||||
## 디자인 레퍼런스 — 이 HTML의 구조와 색상 패턴을 따르되, 콘텐츠를 교체하라.
|
||||
구조(레이아웃, 색상 배치, 카드/불릿 패턴)를 따르고, 텍스트만 원본으로 교체.
|
||||
발명하지 마라. 이 구조를 따라라.
|
||||
|
||||
{ref_html}""")
|
||||
|
||||
# ── 디자인 예산 (있으면) ──
|
||||
if avail_h > 0:
|
||||
parts.append(f"""
|
||||
## 디자인 예산
|
||||
- 텍스트 영역 확보 후 남은 공간: 높이 {avail_h}px, 너비 {avail_w}px
|
||||
- 도형/이미지/배경색 영역은 이 예산 안에서 배치.""")
|
||||
|
||||
# ── 공통: HTML 규칙 ──
|
||||
parts.append(_COMMON_HTML_RULES)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# Phase S 레거시 프롬프트 — build_area_prompt()로 교체됨. 참고용으로만 보존.
|
||||
_LEGACY_CORE_PROMPT = """다음 콘텐츠를 본심 영역 HTML로 만들어라.
|
||||
|
||||
## 크기: width:100%, max-height: {height}px, overflow: hidden (반드시 적용)
|
||||
|
||||
@@ -218,7 +481,7 @@ CORE_PROMPT = """다음 콘텐츠를 본심 영역 HTML로 만들어라.
|
||||
HTML + inline <style>만 반환. 위 CSS와 HTML 구조를 정확히 따르라. 설명 없이 코드만."""
|
||||
|
||||
|
||||
SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {width}px × {height}px.
|
||||
_LEGACY_SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {width}px × {height}px.
|
||||
|
||||
## 용어 (축약/요약/삭제 금지. 원본 텍스트를 한 글자도 바꾸지 말고 그대로 사용.)
|
||||
{definitions_block}
|
||||
@@ -243,7 +506,7 @@ SIDEBAR_PROMPT = """다음 용어 정의를 sidebar 카드로 만들어라. {wid
|
||||
HTML만 반환. <style> 블록 금지. 모든 스타일은 인라인 style 속성으로. 설명 없이 코드만."""
|
||||
|
||||
|
||||
FOOTER_PROMPT = """결론 배너 HTML.
|
||||
_LEGACY_FOOTER_PROMPT = """결론 배너 HTML.
|
||||
|
||||
## 콘텐츠 (축약/요약/삭제 금지. 원본 텍스트를 그대로 사용.)
|
||||
{content_block}
|
||||
@@ -262,6 +525,68 @@ FOOTER_PROMPT = """결론 배너 HTML.
|
||||
HTML + inline <style>만 반환. 설명 없이 코드만."""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Phase T-7: 프롬프트에 레퍼런스 + 수치 주입
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _build_phase_t_supplement(role: str, analysis: dict) -> str:
|
||||
"""Phase T context가 있으면 프롬프트 보충 섹션을 생성.
|
||||
|
||||
폰트 위계, 디자인 예산, 레퍼런스 HTML을 구체적 수치로 전달.
|
||||
Phase S 교훈: "구체적 프롬프트는 합격, 추상적 프롬프트는 실패"
|
||||
→ px, 폰트 크기, 줄 수를 숫자로 넣되 context에서 동적으로 가져옴.
|
||||
"""
|
||||
phase_t = analysis.get("phase_t")
|
||||
if not phase_t:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
# 1. 폰트 위계 (역할별 확정 폰트)
|
||||
fh = phase_t.get("font_hierarchy", {})
|
||||
role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "core"}
|
||||
font_key = role_font_map.get(role, "core")
|
||||
font_size = fh.get(font_key, 12)
|
||||
parts.append(
|
||||
f"\n[폰트 위계 — 반드시 준수]\n"
|
||||
f"이 영역({role})의 확정 폰트: {font_size}px\n"
|
||||
f"전체 위계: 핵심={fh.get('key_msg', 14)}px > 본심={fh.get('core', 12)}px "
|
||||
f">= 배경={fh.get('bg', 11)}px > 첨부={fh.get('sidebar', 10)}px\n"
|
||||
f"이 영역에서 {font_size}px보다 큰 폰트를 사용하지 마라. 위계가 깨진다."
|
||||
)
|
||||
|
||||
# 2. 디자인 예산 (남은 공간)
|
||||
budgets = phase_t.get("design_budgets", {})
|
||||
budget = budgets.get(role, {})
|
||||
if budget:
|
||||
parts.append(
|
||||
f"\n[디자인 예산]\n"
|
||||
f"텍스트 영역 확보 후 남은 공간:\n"
|
||||
f"- 가용 높이: {budget.get('available_height_px', 0)}px\n"
|
||||
f"- 가용 너비: {budget.get('available_width_px', 0)}px\n"
|
||||
f"- 원형 요소 최대: {budget.get('max_circle_diameter', 0)}px\n"
|
||||
f"- 이미지 최대: {budget.get('max_img_width', 0)}×{budget.get('max_img_height', 0)}px\n"
|
||||
f"디자인 요소(도형, 이미지, 배경색 영역)는 이 예산 안에서 배치하라."
|
||||
)
|
||||
|
||||
# 3. V-1: 꼭지별 디자인 레퍼런스 HTML (리스트)
|
||||
refs = phase_t.get("references", {})
|
||||
ref_list = refs.get(role, [])
|
||||
if isinstance(ref_list, dict):
|
||||
ref_list = [ref_list]
|
||||
ref_html = "\n\n".join(r.get("design_reference_html", "") for r in ref_list if r)
|
||||
if ref_html:
|
||||
# 너무 길면 잘라서 토큰 절약
|
||||
if len(ref_html) > 3000:
|
||||
ref_html = ref_html[:3000] + "\n<!-- ... (truncated) -->"
|
||||
parts.append(
|
||||
f"\n[디자인 레퍼런스 — 구조와 색상 패턴을 참고하되 그대로 복사하지 마라]\n"
|
||||
f"{ref_html}"
|
||||
)
|
||||
|
||||
return "\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 메인 함수
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
@@ -301,94 +626,120 @@ async def generate_slide_html(
|
||||
|
||||
result = {"body_html": "", "sidebar_html": "", "footer_html": "", "reasoning": ""}
|
||||
|
||||
# ── 실제 zone 높이 계산 ──
|
||||
# slide=720, padding=40*2=80, grid-gap=20*2=40, header≈66px(2rem*1.7+padding+border)
|
||||
# body_zone = 720 - 80 - 66 - footer - 40
|
||||
footer_h = concl_spec.height_px if concl_spec else 60
|
||||
body_zone_h = 720 - 80 - 66 - footer_h - 40 # ≈ 474
|
||||
sidebar_zone_h = body_zone_h # body와 sidebar는 같은 grid row
|
||||
# ── 실제 zone 높이: containers에서 온 값 사용 (하드코딩 아님) ──
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
bg_h = bg_spec.height_px if bg_spec else 0
|
||||
core_h = core_spec.height_px if core_spec else 0
|
||||
footer_h = concl_spec.height_px if concl_spec else 0
|
||||
sidebar_h = ref_spec.height_px if ref_spec else 0
|
||||
# body zone = 배경 + 본심 + gap
|
||||
bg_core_gap = tokens["spacing_small"]
|
||||
body_zone_h = bg_h + core_h + (bg_core_gap if bg_topics and core_topics else 0)
|
||||
sidebar_zone_h = sidebar_h if sidebar_h > 0 else body_zone_h
|
||||
# core_max_h: 본심 컨테이너 높이에서 key-msg 높이를 빼야 Sonnet이 넘치지 않음
|
||||
phase_t = analysis.get("phase_t", {})
|
||||
core_sub = phase_t.get("sub_layouts", {}).get("본심", {})
|
||||
keymsg_sub_h = 0
|
||||
for sc in core_sub.get("sub_containers", []):
|
||||
if sc.get("name") == "keymsg":
|
||||
keymsg_sub_h = sc.get("height_px", 0)
|
||||
core_max_h = core_h - keymsg_sub_h if core_h > 0 else (body_zone_h - bg_h - bg_core_gap if bg_topics else body_zone_h)
|
||||
logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px (keymsg={keymsg_sub_h}px 제외)")
|
||||
|
||||
BG_CORE_GAP = 12 # 배경↔본심 간격
|
||||
bg_h = bg_spec.height_px if bg_spec else 176
|
||||
# 본심은 body zone에서 배경+gap을 뺀 나머지
|
||||
core_max_h = body_zone_h - bg_h - BG_CORE_GAP if bg_topics else body_zone_h
|
||||
logger.info(f"[Phase S] zone 계산: body={body_zone_h}px, sidebar={sidebar_zone_h}px, bg={bg_h}px, core_max={core_max_h}px")
|
||||
# Phase T context
|
||||
phase_t = analysis.get("phase_t", {})
|
||||
|
||||
# 원본 텍스트 매핑
|
||||
sections = _slice_mdx_sections(content)
|
||||
|
||||
# ── 콘텐츠 텍스트 가져오기: structured_text 우선, 없으면 sections 매칭 fallback ──
|
||||
def _get_role_content(role_topics):
|
||||
"""structured_text를 우선 사용. 없으면 기존 sections 매칭."""
|
||||
texts = []
|
||||
for t in role_topics:
|
||||
st = t.get("structured_text", "")
|
||||
if st:
|
||||
texts.append(st)
|
||||
else:
|
||||
# fallback: source_hint 키워드로 sections에서 매칭
|
||||
keywords = _extract_keywords_from_hints([t])
|
||||
matched = _map_sections_for_role(sections, [t], keywords)
|
||||
if matched:
|
||||
texts.append(matched)
|
||||
return "\n\n".join(texts) if texts else ""
|
||||
|
||||
# ── 배경 ──
|
||||
if bg_topics:
|
||||
logger.info("[Phase S] 배경 생성...")
|
||||
sections = _slice_mdx_sections(content)
|
||||
bg_content = _map_sections_for_role(
|
||||
sections, bg_topics, _extract_keywords_from_hints(bg_topics),
|
||||
)
|
||||
prompt = BG_PROMPT.format(
|
||||
height=bg_h,
|
||||
logger.info("[Phase T] 배경 생성...")
|
||||
bg_content = _get_role_content(bg_topics)
|
||||
body_width = bg_spec.width_px if bg_spec else (core_spec.width_px if core_spec else 0)
|
||||
prompt = build_area_prompt(
|
||||
role="배경",
|
||||
content_block=bg_content,
|
||||
phase_t=phase_t,
|
||||
height_px=bg_h,
|
||||
width_px=body_width,
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
result["body_html"] += html + f'\n<div style="height:{BG_CORE_GAP}px;"></div>\n'
|
||||
logger.info(f"[Phase S] 배경 완료: {len(html)}자")
|
||||
result["body_html"] += html + f'\n<div style="height:{bg_core_gap}px;"></div>\n'
|
||||
logger.info(f"[Phase T] 배경 완료: {len(html)}자")
|
||||
|
||||
# ── 본심 ──
|
||||
if core_topics:
|
||||
logger.info("[Phase S] 본심 생성...")
|
||||
core_content = _map_sections_for_role(
|
||||
sections, core_topics, _extract_keywords_from_hints(core_topics),
|
||||
)
|
||||
|
||||
img_instruction = ""
|
||||
img_margin = 60
|
||||
img_w = 250
|
||||
for img in images:
|
||||
if img.get("topic_id") in [t["id"] for t in core_topics]:
|
||||
img_id = f"slide-img-{img['topic_id']}"
|
||||
img_instruction = f"이미지 태그: <img id=\"{img_id}\" src=\"placeholder\">\nid=\"{img_id}\"를 반드시 포함 (후처리에서 실제 이미지로 교체)"
|
||||
if img.get("ratio", 1) > 1.5:
|
||||
img_w = 250
|
||||
img_margin = 60
|
||||
|
||||
prompt = CORE_PROMPT.format(
|
||||
width=core_spec.width_px if core_spec else 767,
|
||||
height=core_max_h,
|
||||
img_margin_top=img_margin,
|
||||
img_width=img_w,
|
||||
core_message=analysis.get("core_message", ""),
|
||||
logger.info("[Phase T] 본심 생성...")
|
||||
core_content = _get_role_content(core_topics)
|
||||
core_images = [img for img in images if img.get("topic_id") in [t["id"] for t in core_topics]]
|
||||
body_width = core_spec.width_px if core_spec else (bg_spec.width_px if bg_spec else 0)
|
||||
prompt = build_area_prompt(
|
||||
role="본심",
|
||||
content_block=core_content,
|
||||
img_instruction=img_instruction,
|
||||
phase_t=phase_t,
|
||||
height_px=core_max_h,
|
||||
width_px=body_width,
|
||||
images=core_images,
|
||||
core_message=analysis.get("core_message", ""),
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
html = _replace_img_placeholder(html, images)
|
||||
result["body_html"] += html + "\n"
|
||||
logger.info(f"[Phase S] 본심 완료: {len(html)}자")
|
||||
logger.info(f"[Phase T] 본심 완료: {len(html)}자")
|
||||
|
||||
# ── sidebar ──
|
||||
if ref_topics:
|
||||
logger.info("[Phase S] sidebar 생성...")
|
||||
defs = _get_definitions(content)
|
||||
prompt = SIDEBAR_PROMPT.format(
|
||||
width=ref_spec.width_px if ref_spec else 380,
|
||||
height=sidebar_zone_h,
|
||||
definitions_block=defs,
|
||||
logger.info("[Phase T] sidebar 생성...")
|
||||
sidebar_content = _get_role_content(ref_topics)
|
||||
sidebar_width = ref_spec.width_px if ref_spec else 0
|
||||
prompt = build_area_prompt(
|
||||
role="첨부",
|
||||
content_block=sidebar_content,
|
||||
phase_t=phase_t,
|
||||
height_px=sidebar_zone_h,
|
||||
width_px=sidebar_width,
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
result["sidebar_html"] = html
|
||||
logger.info(f"[Phase S] sidebar 완료: {len(html)}자")
|
||||
logger.info(f"[Phase T] sidebar 완료: {len(html)}자")
|
||||
|
||||
# ── footer ──
|
||||
if conclusion_topics:
|
||||
logger.info("[Phase S] footer 생성...")
|
||||
footer_content = _get_conclusion(content)
|
||||
prompt = FOOTER_PROMPT.format(
|
||||
height=concl_spec.height_px if concl_spec else 60,
|
||||
logger.info("[Phase T] footer 생성...")
|
||||
footer_content = _get_role_content(conclusion_topics) or _get_conclusion(content)
|
||||
footer_width = concl_spec.width_px if concl_spec else 0
|
||||
prompt = build_area_prompt(
|
||||
role="결론",
|
||||
content_block=footer_content.strip(),
|
||||
phase_t=phase_t,
|
||||
height_px=concl_spec.height_px if concl_spec else 0,
|
||||
width_px=footer_width,
|
||||
)
|
||||
html = await _call_claude(client, prompt)
|
||||
if html:
|
||||
result["footer_html"] = html
|
||||
logger.info(f"[Phase S] footer 완료: {len(html)}자")
|
||||
logger.info(f"[Phase T] footer 완료: {len(html)}자")
|
||||
|
||||
result["reasoning"] = "영역별 개별 호출, 검증 합격 프롬프트 템플릿 사용."
|
||||
return result
|
||||
@@ -398,17 +749,98 @@ async def generate_slide_html(
|
||||
# 콘텐츠 추출 함수
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _slice_mdx_sections(content: str) -> dict[str, str]:
|
||||
"""원본 MDX를 ## 기준으로 섹션별 슬라이싱.
|
||||
def normalize_mdx(raw_mdx: str) -> str:
|
||||
"""MDX를 ## 섹션 기반 표준 구조로 정규화 (0단계).
|
||||
|
||||
source_data(Kei 메모 포함)를 사용하지 않고,
|
||||
원본 MDX 텍스트를 그대로 추출하여 프롬프트에 넣는다.
|
||||
다양한 MDX 포맷을 ## 섹션 + 순수 텍스트로 통일.
|
||||
패턴은 계속 추가될 수 있음 — MDX 문법 기반 범용 처리.
|
||||
|
||||
처리 패턴:
|
||||
- frontmatter (---...---) 제거
|
||||
- import 문 제거
|
||||
- <br/>, 장식용 --- 제거
|
||||
- JSX div style 태그 → 내부 텍스트만
|
||||
- 커스텀 컴포넌트 (<Component />) 제거
|
||||
- <details><summary> → 태그 제거, 내용 유지
|
||||
- :::directive[제목] → ## 승격
|
||||
- ## N. 제목 → ## 제목 (번호 제거)
|
||||
- ### N.N 제목 → ### 제목 (번호 제거)
|
||||
- * **제목** (## 전 도입부) → ## 승격
|
||||
-  → [이미지] 참조 보존
|
||||
- *이탤릭 출처* → 출처: 텍스트
|
||||
"""
|
||||
text = raw_mdx
|
||||
|
||||
# frontmatter 제거
|
||||
text = re.sub(r"^---\n.*?\n---\n*", "", text, flags=re.DOTALL)
|
||||
|
||||
# import 문 제거
|
||||
text = re.sub(r"^import\s+.+$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# <br/> 제거
|
||||
text = re.sub(r"<br\s*/?>", "", text)
|
||||
|
||||
# JSX div style → 태그만 제거
|
||||
text = re.sub(r"<div\s+style=\{\{[^}]*\}\}>", "", text)
|
||||
text = text.replace("</div>", "")
|
||||
|
||||
# 커스텀 컴포넌트 태그 제거 (<Component />, <Component>...</Component>)
|
||||
text = re.sub(r"<[A-Z]\w+\s*/>", "", text)
|
||||
text = re.sub(r"<[A-Z]\w+[^>]*>.*?</[A-Z]\w+>", "", text, flags=re.DOTALL)
|
||||
|
||||
# <details>/<summary> → 태그 제거, 내용 유지
|
||||
text = re.sub(r"<details>\s*", "", text)
|
||||
text = re.sub(r"<summary[^>]*>(.+?)</summary>", r"[\1]", text)
|
||||
text = re.sub(r"</details>", "", text)
|
||||
|
||||
# :::directive[제목] → ## 승격
|
||||
text = re.sub(r":::(\w+)\[(.+?)\]", r"## \2", text)
|
||||
text = re.sub(r"^:::\s*$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# ## N. 제목 → ## 제목 (번호 제거)
|
||||
text = re.sub(r"^## \d+\.\s*", "## ", text, flags=re.MULTILINE)
|
||||
|
||||
# ### N.N 제목 → ### 제목 (번호 제거)
|
||||
text = re.sub(r"^### \d+\.\d+\s*", "### ", text, flags=re.MULTILINE)
|
||||
|
||||
# * **제목** → ## 승격 (## 전 도입부에서만)
|
||||
first_hash = text.find("\n## ")
|
||||
if first_hash == -1:
|
||||
first_hash = len(text)
|
||||
intro = text[:first_hash]
|
||||
rest = text[first_hash:]
|
||||
intro = re.sub(r"^\* \*\*(.+?)\*\*\s*$", r"## \1", intro, flags=re.MULTILINE)
|
||||
text = intro + rest
|
||||
|
||||
# 이미지 참조 보존
|
||||
text = re.sub(r"!\[(.+?)\]\((.+?)\)", r"[이미지: \1, 경로: \2]", text)
|
||||
|
||||
# 이탤릭 출처 (단독 줄)
|
||||
text = re.sub(r"^\s*\*([^*\n]+)\*\s*$", r"출처: \1", text, flags=re.MULTILINE)
|
||||
|
||||
# 장식용 --- 제거
|
||||
text = re.sub(r"^---\s*$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# 연속 빈 줄 정리
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _slice_mdx_sections(content: str) -> dict[str, str]:
|
||||
"""원본 MDX를 정규화 후 ## 기준으로 섹션별 슬라이싱.
|
||||
|
||||
0단계: normalize_mdx()로 MDX 표준화
|
||||
1단계: ## 기준으로 분할
|
||||
"""
|
||||
# 0단계: MDX 정규화
|
||||
normalized = normalize_mdx(content)
|
||||
|
||||
sections = {}
|
||||
current_section = None
|
||||
current_lines = []
|
||||
|
||||
for line in content.split("\n"):
|
||||
for line in normalized.split("\n"):
|
||||
if line.startswith("## "):
|
||||
if current_section:
|
||||
sections[current_section] = "\n".join(current_lines).strip()
|
||||
@@ -591,12 +1023,12 @@ async def regenerate_area(
|
||||
ref_spec = container_specs.get("첨부")
|
||||
# 실제 zone 높이 계산
|
||||
footer_h = container_specs.get("결론")
|
||||
footer_h = footer_h.height_px if footer_h else 60
|
||||
sidebar_zone_h = 720 - 80 - 66 - footer_h - 40
|
||||
footer_h = footer_h.height_px if footer_h else 0
|
||||
sidebar_zone_h = ref_spec.height_px if ref_spec else 0
|
||||
|
||||
defs = _get_definitions(content)
|
||||
prompt = SIDEBAR_PROMPT.format(
|
||||
width=ref_spec.width_px if ref_spec else 380,
|
||||
prompt = _LEGACY_SIDEBAR_PROMPT.format(
|
||||
width=ref_spec.width_px if ref_spec else 0,
|
||||
height=sidebar_zone_h,
|
||||
definitions_block=defs,
|
||||
) + error_feedback
|
||||
@@ -607,8 +1039,8 @@ async def regenerate_area(
|
||||
elif area_name == "footer":
|
||||
concl_spec = container_specs.get("결론")
|
||||
footer_content = _get_conclusion(content)
|
||||
prompt = FOOTER_PROMPT.format(
|
||||
height=concl_spec.height_px if concl_spec else 60,
|
||||
prompt = _LEGACY_FOOTER_PROMPT.format(
|
||||
height=concl_spec.height_px if concl_spec else 0,
|
||||
content_block=footer_content.strip(),
|
||||
) + error_feedback
|
||||
|
||||
@@ -634,3 +1066,4 @@ def _replace_img_placeholder(html: str, images: list[dict]) -> str:
|
||||
html = html.replace("src='placeholder'", f"src='{data_uri}'")
|
||||
logger.info(f"[Phase S] 이미지 교체: {img_id}")
|
||||
return html
|
||||
|
||||
|
||||
@@ -116,18 +116,22 @@ KEI_PROMPT_B = (
|
||||
" - 예: '기술 융합/포함 관계. 순서 아님. 구성요소 간 관계 표현 필요.'\n"
|
||||
" - 예: '수단-목적 관계. 대등 비교가 아님. 역할 차이를 보여줘야 함.'\n"
|
||||
" - 예: '용어 3개의 독립적 정의. 나열.'\n\n"
|
||||
"3. **원본 데이터 확인 (source_data)**:\n"
|
||||
" - 원본에 비교표가 있는가? → 행/열 수, 활용 여부\n"
|
||||
" - 원본에 사례/증거가 있는가? → 출처 명시\n"
|
||||
" - 원본에 이미지가 있는가? → 역할\n"
|
||||
" - 놓치면 안 되는 핵심 데이터가 있는가?\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'
|
||||
'"source_data": "핵심 항목 나열 + [팝업:] [이미지:] 마커 + 출처"}]}\n'
|
||||
"```\n\n"
|
||||
)
|
||||
|
||||
@@ -173,11 +177,9 @@ async def refine_concepts(
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-refine",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
@@ -222,6 +224,104 @@ async def refine_concepts(
|
||||
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")
|
||||
@@ -230,11 +330,9 @@ async def _call_kei_api(content: str) -> dict[str, Any] | None:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": KEI_PROMPT + content,
|
||||
"session_id": "design-agent",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
@@ -324,7 +422,7 @@ async def select_block_for_topics(
|
||||
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
|
||||
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", "")
|
||||
@@ -347,11 +445,9 @@ async def select_block_for_topics(
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": full_prompt,
|
||||
"session_id": "design-agent-q4",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
@@ -450,7 +546,7 @@ async def vision_quality_gate(
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
response = await client.messages.create(
|
||||
model="claude-opus-4-0-20250514",
|
||||
model="claude-opus-4-6-20250415",
|
||||
max_tokens=2048,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
@@ -568,7 +664,7 @@ async def call_kei_final_review(
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-opus-4-0-20250514",
|
||||
model="claude-opus-4-6-20250415",
|
||||
max_tokens=4096,
|
||||
system=KEI_REVIEW_PROMPT,
|
||||
messages=[{
|
||||
@@ -615,11 +711,9 @@ async def call_kei_final_review(
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-final-review",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
@@ -717,11 +811,9 @@ async def call_kei_overflow_judgment(
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{kei_url}/api/message",
|
||||
f"{kei_url}/api/direct",
|
||||
json={
|
||||
"message": prompt,
|
||||
"session_id": "design-agent-overflow",
|
||||
"mode_hint": "chat",
|
||||
},
|
||||
timeout=None,
|
||||
) as response:
|
||||
@@ -870,7 +962,7 @@ async def select_best_candidate(
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model="claude-opus-4-0-20250514",
|
||||
model="claude-opus-4-6-20250415",
|
||||
max_tokens=2048,
|
||||
messages=[{"role": "user", "content": content_blocks}],
|
||||
)
|
||||
@@ -890,3 +982,435 @@ async def select_best_candidate(
|
||||
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
|
||||
|
||||
434
src/mdx_normalizer.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""Phase T-1: MDX 4-Layer 파서.
|
||||
|
||||
Stage 0에서 호출. 원본 MDX를 정규화하여 이후 모든 Stage에 깨끗한 입력 제공.
|
||||
|
||||
Layer 1: python-frontmatter — YAML frontmatter 분리, title 추출
|
||||
Layer 2: regex — 코드블록 보호 + MDX 전용 패턴 (details, :::, JSX, import)
|
||||
Layer 3: markdown-it-py — AST 파싱 → 이미지/표/헤딩 구조 추출
|
||||
Layer 4: regex — 텍스트 정리, 빈 줄 정리, clean_text
|
||||
|
||||
조사 결과 (T-1):
|
||||
- python-frontmatter: parse() → (dict, str). frontmatter 없으면 안전하게 {}
|
||||
- markdown-it-py: js-default 프리셋에 table 기본 포함. 한국어 정상
|
||||
- 코드블록 보호: backtick 10→3 순서 매칭. 중첩/inline 검증됨
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import frontmatter
|
||||
from markdown_it import MarkdownIt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 코드블록 보호 (Layer 2 선행)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
class _CodeBlockProtector:
|
||||
"""코드블록을 placeholder로 보호하고 복원.
|
||||
|
||||
backtick 개수가 많은 순서(10→3)로 매칭하여 중첩 코드블록 안전 처리.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._store: dict[str, str] = {}
|
||||
self._counter = 0
|
||||
|
||||
def _make_key(self) -> str:
|
||||
self._counter += 1
|
||||
return f"__CODEBLOCK_{self._counter}__"
|
||||
|
||||
def protect(self, text: str) -> str:
|
||||
# fenced code blocks (큰 backtick부터)
|
||||
for n in range(10, 2, -1):
|
||||
pattern = rf"^(`{{{n}}})([^\n]*)\n(.*?)\n\1\s*$"
|
||||
|
||||
def _replacer(m, _n=n):
|
||||
key = self._make_key()
|
||||
self._store[key] = m.group(0)
|
||||
return key
|
||||
|
||||
text = re.sub(pattern, _replacer, text, flags=re.MULTILINE | re.DOTALL)
|
||||
|
||||
# inline code
|
||||
def _inline_replacer(m):
|
||||
key = self._make_key()
|
||||
self._store[key] = m.group(0)
|
||||
return key
|
||||
|
||||
text = re.sub(r"`[^`\n]+`", _inline_replacer, text)
|
||||
return text
|
||||
|
||||
def restore(self, text: str) -> str:
|
||||
for key, original in self._store.items():
|
||||
text = text.replace(key, original)
|
||||
return text
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Layer 2: MDX 전용 패턴 처리
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _convert_md_table_to_html(text: str) -> str:
|
||||
"""마크다운 테이블(| col | col |)을 HTML <table>로 변환.
|
||||
|
||||
어떤 마크다운 테이블이든 동작. 하드코딩 없음.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
result = []
|
||||
table_lines = []
|
||||
in_table = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("|") and stripped.endswith("|"):
|
||||
table_lines.append(stripped)
|
||||
in_table = True
|
||||
else:
|
||||
if in_table and table_lines:
|
||||
result.append(_render_md_table(table_lines))
|
||||
table_lines = []
|
||||
in_table = False
|
||||
result.append(line)
|
||||
|
||||
if table_lines:
|
||||
result.append(_render_md_table(table_lines))
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def _render_md_table(table_lines: list[str]) -> str:
|
||||
"""마크다운 테이블 라인들을 HTML 테이블로."""
|
||||
if len(table_lines) < 2:
|
||||
return "\n".join(table_lines)
|
||||
|
||||
def _parse_row(line):
|
||||
cells = [c.strip() for c in line.split("|")]
|
||||
# 앞뒤 빈 셀 제거 (| col1 | col2 | → ['', 'col1', 'col2', ''])
|
||||
return [c for c in cells if c or c == ""].__getitem__(slice(1, -1)) if cells[0] == "" else cells
|
||||
|
||||
headers = _parse_row(table_lines[0])
|
||||
|
||||
# 구분선(|---|---|) 스킵
|
||||
data_start = 1
|
||||
if len(table_lines) > 1 and all(c.strip().replace("-", "").replace(":", "") == "" for c in table_lines[1].split("|") if c.strip()):
|
||||
data_start = 2
|
||||
|
||||
rows = [_parse_row(line) for line in table_lines[data_start:]]
|
||||
|
||||
# HTML 생성
|
||||
header_html = "".join(f"<th>{h}</th>" for h in headers)
|
||||
rows_html = ""
|
||||
for row in rows:
|
||||
cells = "".join(f"<td>{c}</td>" for c in row)
|
||||
rows_html += f"<tr>{cells}</tr>\n"
|
||||
|
||||
return f"<table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table>"
|
||||
|
||||
|
||||
def _process_mdx_patterns(text: str) -> tuple[str, list[dict]]:
|
||||
"""MDX 전용 패턴 처리. popups를 추출하고 텍스트에서 마커로 교체.
|
||||
|
||||
Returns:
|
||||
(처리된 텍스트, popups 리스트)
|
||||
"""
|
||||
popups = []
|
||||
|
||||
# <details><summary>제목</summary>내용</details> → 팝업 추출
|
||||
def _extract_popup(m):
|
||||
title = m.group(1).strip()
|
||||
content = m.group(2).strip()
|
||||
# 팝업 content 정화: JSX style 제거 + 마크다운 → HTML
|
||||
content = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", content)
|
||||
content = content.replace("</div>", "")
|
||||
content = re.sub(r"<br\s*/?>", "\n", content)
|
||||
content = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", content)
|
||||
# 마크다운 테이블 → HTML 테이블
|
||||
content = _convert_md_table_to_html(content)
|
||||
popups.append({"title": title, "content": content})
|
||||
return f"[팝업: {title}]"
|
||||
|
||||
text = re.sub(
|
||||
r"<details>\s*<summary[^>]*>(.+?)</summary>(.*?)</details>",
|
||||
_extract_popup,
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# import 문 제거
|
||||
text = re.sub(r"^import\s+.+$", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^export\s+.+$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# <br/> 제거
|
||||
text = re.sub(r"<br\s*/?>", "", text)
|
||||
|
||||
# JSX div style → 태그만 제거 (내용 유지)
|
||||
text = re.sub(r"<div\s+style=\{\{[^}]*\}\}\s*>", "", text)
|
||||
text = text.replace("</div>", "")
|
||||
|
||||
# 커스텀 컴포넌트 (<Component />, <Component>...</Component>)
|
||||
text = re.sub(r"<[A-Z]\w+\s*/>", "", text)
|
||||
text = re.sub(r"<[A-Z]\w+[^>]*>.*?</[A-Z]\w+>", "", text, flags=re.DOTALL)
|
||||
|
||||
# :::directive[제목] → ## 승격 + 핵심요약 마킹
|
||||
def _process_directive(m):
|
||||
directive = m.group(1)
|
||||
title = m.group(2)
|
||||
if directive in ("note", "tip", "caution", "danger"):
|
||||
return f"[핵심요약: {title}]"
|
||||
return f"## {title}"
|
||||
|
||||
text = re.sub(r":::(\w+)\[(.+?)\]", _process_directive, text)
|
||||
text = re.sub(r"^:::\s*$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# ## N. 제목 → ## 제목 (번호 제거, 공백 1개 이상 필수 — T-1 조사 버그 수정)
|
||||
text = re.sub(r"^## \d+\.\s+", "## ", text, flags=re.MULTILINE)
|
||||
|
||||
# ### N.N 제목 → ### 제목
|
||||
text = re.sub(r"^### \d+\.\d+\s+", "### ", text, flags=re.MULTILINE)
|
||||
|
||||
# * **제목** → ## 승격 (## 전 도입부에서만)
|
||||
first_hash = text.find("\n## ")
|
||||
if first_hash == -1:
|
||||
first_hash = len(text)
|
||||
intro = text[:first_hash]
|
||||
rest = text[first_hash:]
|
||||
intro = re.sub(r"^\* \*\*(.+?)\*\*\s*$", r"## \1", intro, flags=re.MULTILINE)
|
||||
text = intro + rest
|
||||
|
||||
# 이탤릭 출처 (단독 줄)
|
||||
text = re.sub(r"^\s*\*([^*\n]+)\*\s*$", r"출처: \1", text, flags=re.MULTILINE)
|
||||
|
||||
# 장식용 --- 제거
|
||||
text = re.sub(r"^---\s*$", "", text, flags=re.MULTILINE)
|
||||
|
||||
return text, popups
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Layer 3: AST 파싱
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _extract_structure(text: str) -> dict[str, Any]:
|
||||
"""markdown-it-py AST 파싱으로 구조 추출.
|
||||
|
||||
Returns:
|
||||
{"images": [...], "tables": [...], "sections": [...]}
|
||||
"""
|
||||
md = MarkdownIt("js-default")
|
||||
tokens = md.parse(text)
|
||||
|
||||
images = []
|
||||
tables = []
|
||||
sections = []
|
||||
|
||||
current_section_title = ""
|
||||
current_section_lines = []
|
||||
|
||||
def _flush_section():
|
||||
nonlocal current_section_title, current_section_lines
|
||||
if current_section_title:
|
||||
sections.append({
|
||||
"level": 2,
|
||||
"title": current_section_title,
|
||||
"content": "\n".join(current_section_lines).strip(),
|
||||
})
|
||||
current_section_lines = []
|
||||
|
||||
for i, token in enumerate(tokens):
|
||||
# 이미지 추출 (inline children)
|
||||
if token.type == "inline" and token.children:
|
||||
for child in token.children:
|
||||
if child.type == "image":
|
||||
attrs = child.attrs or {}
|
||||
images.append({
|
||||
"alt": child.content or attrs.get("alt", ""),
|
||||
"path": attrs.get("src", ""),
|
||||
})
|
||||
|
||||
# 표 추출
|
||||
if token.type == "table_open":
|
||||
table = {"headers": [], "rows": []}
|
||||
# 이후 토큰에서 thead/tbody 파싱
|
||||
j = i + 1
|
||||
in_thead = False
|
||||
in_tbody = False
|
||||
current_row = []
|
||||
while j < len(tokens) and tokens[j].type != "table_close":
|
||||
t = tokens[j]
|
||||
if t.type == "thead_open":
|
||||
in_thead = True
|
||||
elif t.type == "thead_close":
|
||||
in_thead = False
|
||||
if current_row:
|
||||
table["headers"] = current_row
|
||||
current_row = []
|
||||
elif t.type == "tbody_open":
|
||||
in_tbody = True
|
||||
elif t.type == "tbody_close":
|
||||
in_tbody = False
|
||||
elif t.type == "tr_close":
|
||||
if in_tbody and current_row:
|
||||
table["rows"].append(current_row)
|
||||
elif in_thead and current_row:
|
||||
table["headers"] = current_row
|
||||
current_row = []
|
||||
elif t.type == "inline" and (in_thead or in_tbody):
|
||||
current_row.append(t.content)
|
||||
j += 1
|
||||
if table["headers"] or table["rows"]:
|
||||
tables.append(table)
|
||||
|
||||
# 섹션 추출 (## 기준)
|
||||
if token.type == "heading_open" and token.tag == "h2":
|
||||
_flush_section()
|
||||
# 다음 토큰이 inline (제목 텍스트)
|
||||
if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
|
||||
current_section_title = tokens[i + 1].content
|
||||
elif current_section_title and token.type in ("paragraph_open", "bullet_list_open",
|
||||
"ordered_list_open", "fence"):
|
||||
# 섹션 내용 수집 — inline 토큰의 content만
|
||||
pass
|
||||
if current_section_title and token.type == "inline" and token.tag == "":
|
||||
# heading의 inline은 제목이므로 건너뜀 (이미 current_section_title에 저장)
|
||||
parent_type = tokens[i - 1].type if i > 0 else ""
|
||||
if parent_type != "heading_open":
|
||||
current_section_lines.append(token.content)
|
||||
|
||||
_flush_section()
|
||||
|
||||
return {"images": images, "tables": tables, "sections": sections}
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Layer 4: 텍스트 정리
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _clean_text(text: str) -> str:
|
||||
"""최종 텍스트 정리: 남은 HTML 태그 제거, 빈 줄 정리."""
|
||||
# 이미지 참조 보존 (markdown 형식 → 마커)
|
||||
text = re.sub(r"!\[(.+?)\]\((.+?)\)", r"[이미지: \1]", text)
|
||||
|
||||
# 남은 HTML 태그 제거 (self-closing)
|
||||
text = re.sub(r"<[^>]+/?>", "", text)
|
||||
|
||||
# 연속 빈 줄 정리
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 메인 함수
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def normalize_mdx_content(raw_mdx: str) -> dict[str, Any]:
|
||||
"""MDX 원본을 4-Layer 파서로 정규화.
|
||||
|
||||
Stage 0에서 호출. 결과는 PipelineContext.normalized에 저장.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"clean_text": str,
|
||||
"title": str,
|
||||
"images": [{"alt": str, "path": str}],
|
||||
"popups": [{"title": str, "content": str}],
|
||||
"tables": [{"headers": list, "rows": list}],
|
||||
"sections": [{"level": int, "title": str, "content": str}],
|
||||
}
|
||||
"""
|
||||
# ── Layer 1: frontmatter 분리 ──
|
||||
metadata, body = frontmatter.parse(raw_mdx)
|
||||
title = metadata.get("title", "")
|
||||
logger.info(f"[Layer 1] title='{title}', metadata keys={list(metadata.keys())}")
|
||||
|
||||
# ── Layer 2: 코드블록 보호 → MDX 패턴 처리 ──
|
||||
protector = _CodeBlockProtector()
|
||||
protected = protector.protect(body)
|
||||
processed, popups = _process_mdx_patterns(protected)
|
||||
restored = protector.restore(processed)
|
||||
logger.info(f"[Layer 2] popups={len(popups)}개, 코드블록={protector._counter}개 보호/복원")
|
||||
|
||||
# ── Layer 3: AST 파싱 → 구조 추출 ──
|
||||
structure = _extract_structure(restored)
|
||||
images = structure["images"]
|
||||
tables = structure["tables"]
|
||||
sections = structure["sections"]
|
||||
logger.info(f"[Layer 3] images={len(images)}, tables={len(tables)}, sections={len(sections)}")
|
||||
|
||||
# ── Layer 4: 텍스트 정리 ──
|
||||
clean_text = _clean_text(restored)
|
||||
logger.info(f"[Layer 4] clean_text={len(clean_text)}자")
|
||||
|
||||
return {
|
||||
"clean_text": clean_text,
|
||||
"title": title,
|
||||
"images": images,
|
||||
"popups": popups,
|
||||
"tables": tables,
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 0 검증
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def validate_stage0(result: dict, raw_mdx: str) -> list[dict]:
|
||||
"""Stage 0 출력 검증.
|
||||
|
||||
Returns:
|
||||
에러 리스트 (빈 리스트 = 통과)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
clean_text = result.get("clean_text", "")
|
||||
if not clean_text.strip():
|
||||
errors.append({
|
||||
"severity": "FATAL",
|
||||
"field": "clean_text",
|
||||
"localization": "clean_text가 비어있음",
|
||||
"instruction": "원본 MDX를 확인하세요",
|
||||
})
|
||||
return errors
|
||||
|
||||
# 원본 대비 텍스트 보존율 (30% 이상)
|
||||
raw_text_len = len(re.sub(r"<[^>]+>|\{[^}]+\}|---\n.*?\n---", "", raw_mdx, flags=re.DOTALL).strip())
|
||||
if raw_text_len > 0:
|
||||
preservation = len(clean_text) / raw_text_len
|
||||
if preservation < 0.3:
|
||||
errors.append({
|
||||
"severity": "FATAL",
|
||||
"field": "clean_text",
|
||||
"localization": f"텍스트 보존율 {preservation:.0%} < 30%",
|
||||
"evidence": f"원본 {raw_text_len}자 → clean {len(clean_text)}자",
|
||||
"instruction": "파서가 너무 많은 텍스트를 제거함",
|
||||
})
|
||||
|
||||
# 이미지 수 대조
|
||||
raw_img_count = len(re.findall(r"!\[", raw_mdx))
|
||||
result_img_count = len(result.get("images", []))
|
||||
if raw_img_count > 0 and result_img_count == 0:
|
||||
errors.append({
|
||||
"severity": "ADJUSTABLE",
|
||||
"field": "images",
|
||||
"localization": f"원본 이미지 {raw_img_count}개, 추출 0개",
|
||||
"instruction": "이미지 추출 패턴 확인",
|
||||
})
|
||||
|
||||
# 팝업 수 대조
|
||||
raw_details_count = raw_mdx.count("<details>")
|
||||
result_popup_count = len(result.get("popups", []))
|
||||
if raw_details_count > 0 and result_popup_count == 0:
|
||||
errors.append({
|
||||
"severity": "ADJUSTABLE",
|
||||
"field": "popups",
|
||||
"localization": f"원본 details {raw_details_count}개, 추출 0개",
|
||||
"instruction": "details 추출 패턴 확인",
|
||||
})
|
||||
|
||||
return errors
|
||||
1100
src/pipeline.py
316
src/pipeline_context.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Phase T-0: 파이프라인 누적 컨텍스트 객체.
|
||||
|
||||
모든 Stage가 하나의 PipelineContext를 공유하며,
|
||||
각 Stage가 transform → validate → update 패턴을 따른다.
|
||||
|
||||
Pydantic BaseModel 채택 이유 (T-0 조사 결과):
|
||||
- model_dump_json()으로 스냅샷 직렬화 한 줄
|
||||
- validate_assignment=True로 타입 오류 즉시 감지
|
||||
- Path, Optional, list[dict] 자동 처리
|
||||
- 프로젝트가 이미 Pydantic 사용 중 (config.py, FastAPI)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 하위 모델
|
||||
# ──────────────────────────────────────
|
||||
|
||||
class NormalizedContent(BaseModel):
|
||||
"""Stage 0 출력: MDX 정규화 결과."""
|
||||
clean_text: str = ""
|
||||
title: str = ""
|
||||
images: list[dict[str, str]] = Field(default_factory=list)
|
||||
popups: list[dict[str, str]] = Field(default_factory=list)
|
||||
tables: list[dict[str, Any]] = Field(default_factory=list)
|
||||
sections: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Topic(BaseModel):
|
||||
"""Stage 1A + 1B 출력: 개별 꼭지 정보.
|
||||
|
||||
weight는 여기에 없음 — page_structure의 역할별 속성임.
|
||||
"""
|
||||
id: int = 0
|
||||
title: str = ""
|
||||
purpose: str = ""
|
||||
role: str = ""
|
||||
layer: str = ""
|
||||
source_hint: str = ""
|
||||
# Stage 1B에서 병합
|
||||
relation_type: str = "" # 7개 enum: hierarchy/cause_effect/comparison/sequence/definition/inclusion/none
|
||||
expression_hint: str = ""
|
||||
source_data: str = ""
|
||||
structured_text: str = "" # Stage 1B: 원본 85% 보존 구조화 텍스트 (조립용)
|
||||
summary: str = ""
|
||||
|
||||
|
||||
class PageStructure(BaseModel):
|
||||
"""Stage 1A 출력: 역할별 비중 구조."""
|
||||
roles: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
# 예: {"본심": {"topic_ids": [1,2], "weight": 0.6}, "배경": {...}, ...}
|
||||
|
||||
|
||||
class Analysis(BaseModel):
|
||||
"""Stage 1A 출력: Kei 분석 결과 전체."""
|
||||
core_message: str = ""
|
||||
title: str = ""
|
||||
total_pages: int = 1
|
||||
image_sizes: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
||||
# topics와 page_structure는 PipelineContext 최상위에 위치
|
||||
|
||||
|
||||
class TextBudget(BaseModel):
|
||||
"""Stage 1.5a 출력: 텍스트 예산."""
|
||||
font_size: float = 12.0
|
||||
chars_per_line: int = 0
|
||||
max_lines: int = 0
|
||||
max_chars: int = 0
|
||||
source_chars: int = 0
|
||||
needs_compression: bool = False
|
||||
|
||||
|
||||
class DesignBudget(BaseModel):
|
||||
"""Stage 1.5b 출력: 디자인 요소 예산."""
|
||||
available_height_px: int = 0
|
||||
available_width_px: int = 0
|
||||
max_circle_diameter: int = 0
|
||||
max_img_width: int = 0
|
||||
max_img_height: int = 0
|
||||
fits: bool = True
|
||||
|
||||
|
||||
class ContainerInfo(BaseModel):
|
||||
"""Stage 1.5a/1.5b 통합: 역할별 컨테이너 정보."""
|
||||
role: str = ""
|
||||
zone: str = ""
|
||||
topic_ids: list[int] = Field(default_factory=list)
|
||||
weight: float = 0.0
|
||||
height_px: int = 0
|
||||
width_px: int = 0
|
||||
max_height_cost: str = "medium"
|
||||
text_budget: Optional[TextBudget] = None
|
||||
design_budget: Optional[DesignBudget] = None
|
||||
block_constraints: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class FontHierarchy(BaseModel):
|
||||
"""Stage 1.5a 출력: 확정된 폰트 위계."""
|
||||
key_msg: float = 14.0 # 핵심 메시지 (가장 큼)
|
||||
core: float = 12.0 # 본문
|
||||
bg: float = 11.0 # 배경 (10-12 범위)
|
||||
sidebar: float = 10.0 # 첨부 (9-11 범위)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_hierarchy(self):
|
||||
"""폰트 위계 유지 검증: key_msg > core >= bg > sidebar."""
|
||||
if not (self.key_msg > self.core >= self.bg > self.sidebar):
|
||||
raise ValueError(
|
||||
f"폰트 위계 위반: key_msg({self.key_msg}) > core({self.core}) "
|
||||
f">= bg({self.bg}) > sidebar({self.sidebar}) 이어야 함"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class BlockReference(BaseModel):
|
||||
"""Stage 1.7 출력: 참고 블록 정보."""
|
||||
block_id: str = ""
|
||||
variant: str = "default"
|
||||
visual_type: str = ""
|
||||
schema_info: dict[str, Any] = Field(default_factory=dict)
|
||||
design_reference_html: str = ""
|
||||
topic_id: int | None = None
|
||||
supporting_topic_ids: list[int] = Field(default_factory=list)
|
||||
is_hierarchical: bool = False
|
||||
|
||||
|
||||
class StageError(BaseModel):
|
||||
"""Stage 실행 중 발생한 에러."""
|
||||
stage: str = ""
|
||||
attempt: int = 0
|
||||
severity: str = "RETRYABLE" # FATAL / RETRYABLE / ADJUSTABLE
|
||||
errors: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# 메인 컨텍스트
|
||||
# ──────────────────────────────────────
|
||||
|
||||
class PipelineContext(BaseModel):
|
||||
"""파이프라인 전체를 관통하는 누적 컨텍스트.
|
||||
|
||||
각 Stage가 이 객체를 받아서 필요한 필드를 읽고,
|
||||
결과를 model_copy(update=...)로 병합한다.
|
||||
"""
|
||||
model_config = {"validate_assignment": True, "arbitrary_types_allowed": True}
|
||||
|
||||
# ── 메타 ──
|
||||
run_id: str = ""
|
||||
run_dir: Optional[str] = None # Path를 str로 저장 (JSON 직렬화)
|
||||
raw_content: str = "" # 원본 MDX (변경 불가 참조용)
|
||||
base_path: str = "" # 이미지 기준 경로
|
||||
|
||||
# ── Stage 0 ──
|
||||
normalized: NormalizedContent = Field(default_factory=NormalizedContent)
|
||||
|
||||
# ── Stage 1A ──
|
||||
analysis: Analysis = Field(default_factory=Analysis)
|
||||
topics: list[Topic] = Field(default_factory=list)
|
||||
page_structure: PageStructure = Field(default_factory=PageStructure)
|
||||
|
||||
# ── Stage 1.5a ──
|
||||
font_hierarchy: FontHierarchy = Field(default_factory=FontHierarchy)
|
||||
container_ratio: tuple[int, int] = (0, 0) # Stage 1.5a에서 설정 (body_pct, sidebar_pct)
|
||||
containers: dict[str, ContainerInfo] = Field(default_factory=dict)
|
||||
|
||||
# ── Stage 1.7 ──
|
||||
references: dict[str, list[BlockReference]] = Field(default_factory=dict)
|
||||
preset_name: str = ""
|
||||
preset: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
# ── Stage 1.8 ──
|
||||
fit_result: dict[str, Any] = Field(default_factory=dict)
|
||||
enhancement_result: dict[str, Any] = Field(default_factory=dict)
|
||||
sub_layouts: dict[str, Any] = Field(default_factory=dict) # role → ContainerLayout 직렬화
|
||||
|
||||
# ── Stage 2 ──
|
||||
generated_html: dict[str, str] = Field(default_factory=dict) # body_html, sidebar_html, footer_html
|
||||
|
||||
# ── Stage 3 ──
|
||||
rendered_html: str = ""
|
||||
|
||||
# ── Stage 4 ──
|
||||
measurement: dict[str, Any] = Field(default_factory=dict)
|
||||
quality_score: int = 0
|
||||
screenshot_b64: str = ""
|
||||
|
||||
# ── 에러/경고 추적 ──
|
||||
errors: list[StageError] = Field(default_factory=list)
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
retry_feedback: str = "" # 재시도 시 Self-Refine 피드백
|
||||
|
||||
# ── 이미지 ──
|
||||
slide_images: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
def get_run_dir(self) -> Path:
|
||||
"""run_dir를 Path 객체로 반환."""
|
||||
if self.run_dir:
|
||||
return Path(self.run_dir)
|
||||
p = Path("data/runs") / self.run_id
|
||||
return p
|
||||
|
||||
def save_snapshot(self, stage_name: str) -> None:
|
||||
"""디버깅용 스냅샷 저장. JSON + HTML 시각화."""
|
||||
run_dir = self.get_run_dir()
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
# JSON
|
||||
path = run_dir / f"{stage_name}_context.json"
|
||||
path.write_text(
|
||||
self.model_dump_json(indent=2, exclude={"screenshot_b64", "rendered_html"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# HTML 시각화
|
||||
try:
|
||||
from src.step_visualizer import generate_step_html
|
||||
steps_dir = run_dir / "steps"
|
||||
steps_dir.mkdir(exist_ok=True)
|
||||
generate_step_html(stage_name, self, steps_dir)
|
||||
except Exception as e:
|
||||
pass # 시각화 실패해도 파이프라인은 계속
|
||||
|
||||
def log_error(self, stage: str, errors: list[dict], attempt: int = 0,
|
||||
severity: str = "RETRYABLE") -> None:
|
||||
"""에러를 컨텍스트에 기록."""
|
||||
self.errors.append(StageError(
|
||||
stage=stage,
|
||||
attempt=attempt,
|
||||
severity=severity,
|
||||
errors=errors,
|
||||
))
|
||||
|
||||
def get_role_content(self, role: str) -> str:
|
||||
"""역할(본심/배경/첨부/결론)에 해당하는 원본 텍스트를 반환.
|
||||
|
||||
page_structure에서 topic_ids를 찾고,
|
||||
해당 topics의 source_data를 합쳐서 반환.
|
||||
source_data가 없으면 normalized.clean_text에서 source_hint로 매칭.
|
||||
"""
|
||||
role_info = self.page_structure.roles.get(role, {})
|
||||
topic_ids = role_info.get("topic_ids", [])
|
||||
|
||||
texts = []
|
||||
for t in self.topics:
|
||||
if t.id in topic_ids:
|
||||
if t.source_data:
|
||||
texts.append(t.source_data)
|
||||
elif t.source_hint and self.normalized.sections:
|
||||
# source_hint로 섹션 매칭
|
||||
for sec in self.normalized.sections:
|
||||
if t.source_hint.lower() in sec.get("title", "").lower():
|
||||
texts.append(sec.get("content", ""))
|
||||
break
|
||||
|
||||
return "\n\n".join(texts) if texts else ""
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Stage 실행 유틸리티
|
||||
# ──────────────────────────────────────
|
||||
|
||||
class StageFailure(Exception):
|
||||
"""Stage 실행 실패 (재시도 소진)."""
|
||||
def __init__(self, stage_name: str, errors: list[dict]):
|
||||
self.stage_name = stage_name
|
||||
self.errors = errors
|
||||
super().__init__(f"Stage {stage_name} 실패: {errors}")
|
||||
|
||||
|
||||
def build_retry_feedback(stage_name: str, errors: list[dict],
|
||||
original_text: str = "") -> str:
|
||||
"""Self-Refine 패턴: localization + evidence + instruction.
|
||||
|
||||
NeurIPS 2023 Self-Refine + VASCAR Scorer/Suggester 분리 패턴.
|
||||
"""
|
||||
lines = [
|
||||
f"## 이전 {stage_name} 결과의 검증 실패. 다음 문제를 수정하라.\n"
|
||||
]
|
||||
|
||||
for i, err in enumerate(errors, 1):
|
||||
lines.append(f"### 문제 {i}: {err.get('field', err.get('layer', ''))}")
|
||||
if err.get("localization"):
|
||||
lines.append(f"- 위치: {err['localization']}")
|
||||
if err.get("current_value"):
|
||||
lines.append(f"- 현재 값: {err['current_value']}")
|
||||
if err.get("evidence"):
|
||||
lines.append(f"- 원본 근거: \"{err['evidence']}\"")
|
||||
if err.get("instruction"):
|
||||
lines.append(f"- 수정 지시: {err['instruction']}")
|
||||
lines.append("")
|
||||
|
||||
if original_text:
|
||||
excerpt = original_text[:500]
|
||||
lines.append(f"## 원본 텍스트 (참고)\n{excerpt}\n")
|
||||
|
||||
lines.append("위 문제들을 해결한 결과를 다시 생성하라. 원본에 없는 해석을 추가하지 마라.")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def create_context(content: str, base_path: str = "") -> PipelineContext:
|
||||
"""파이프라인 시작 시 초기 컨텍스트 생성."""
|
||||
run_id = time.strftime("%Y%m%d_%H%M%S")
|
||||
run_dir = str(Path("data/runs") / run_id)
|
||||
|
||||
return PipelineContext(
|
||||
run_id=run_id,
|
||||
run_dir=run_dir,
|
||||
raw_content=content,
|
||||
base_path=base_path,
|
||||
)
|
||||
@@ -531,13 +531,79 @@ def render_slide_from_html(
|
||||
|
||||
title = analysis.get("title", "슬라이드")
|
||||
grid_areas = preset.get("grid_areas", "'header header' 'body sidebar' 'footer footer'")
|
||||
|
||||
# Phase T: 동적 비율 반영 — container_ratio가 있으면 프리셋 고정값 대신 사용
|
||||
container_ratio = analysis.get("_container_ratio")
|
||||
if container_ratio and len(container_ratio) == 2:
|
||||
grid_columns = f"{container_ratio[0]}fr {container_ratio[1]}fr"
|
||||
else:
|
||||
grid_columns = preset.get("grid_columns", "65fr 35fr")
|
||||
grid_rows = preset.get("grid_rows", "auto 1fr auto")
|
||||
|
||||
# Phase W: AFTER 컨테이너 크기를 grid-template-rows에 반영
|
||||
# header=auto, body/sidebar=AFTER 높이에 맞춤, footer=AFTER 높이
|
||||
containers = analysis.get("_containers", {})
|
||||
redist = analysis.get("_fit_redistribution", {})
|
||||
from src.fit_verifier import _load_design_tokens as _ldt
|
||||
_tokens = _ldt()
|
||||
_header_h = _tokens.get("header_height", 66)
|
||||
_gap_small = _tokens["spacing_small"]
|
||||
_bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
|
||||
_core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
|
||||
_footer_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))
|
||||
_body_row_h = _bg_h + _core_h + _gap_small if _bg_h and _core_h else 0
|
||||
if _body_row_h > 0 and _footer_h > 0:
|
||||
grid_rows = f"auto {_body_row_h}px {_footer_h}px"
|
||||
else:
|
||||
grid_rows = preset.get("grid_rows", "auto auto auto").replace("1fr", "auto")
|
||||
|
||||
body_html = generated.get("body_html", "")
|
||||
sidebar_html = generated.get("sidebar_html", "")
|
||||
footer_html = generated.get("footer_html", "")
|
||||
|
||||
# ── 후처리 ──
|
||||
import re as _re
|
||||
# 1) sidebar 최외곽 wrapper div만 width:100% (grid cell에 맞추기)
|
||||
# 첫 번째 태그의 style에서만 변경. 내부 요소(카드 번호 등)는 건드리지 않음.
|
||||
sidebar_html = _re.sub(
|
||||
r'^(\s*<div\s+style="[^"]*?)width:\s*\d+px',
|
||||
r'\1width:100%',
|
||||
sidebar_html,
|
||||
count=1,
|
||||
)
|
||||
# 2) overflow-y:auto/scroll → overflow:hidden (스크롤 절대 금지)
|
||||
body_html = _re.sub(r'overflow-y:\s*(auto|scroll)', 'overflow:hidden', body_html)
|
||||
body_html = _re.sub(r'overflow:\s*(?:auto|scroll)(?!bar)', 'overflow:hidden', body_html)
|
||||
sidebar_html = _re.sub(r'overflow-y:\s*(auto|scroll)', 'overflow:hidden', sidebar_html)
|
||||
sidebar_html = _re.sub(r'overflow:\s*(?:auto|scroll)(?!bar)', 'overflow:hidden', sidebar_html)
|
||||
# 3) markdown **bold** → <strong>
|
||||
body_html = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', body_html)
|
||||
sidebar_html = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sidebar_html)
|
||||
# 4) 폰트 위계 강제: 배경 영역 font-size가 본심(core)보다 크면 안 됨
|
||||
font_h = analysis.get("_font_hierarchy", {})
|
||||
bg_max = font_h.get("bg", 11.0)
|
||||
core_max = font_h.get("core", 12.0)
|
||||
sidebar_max = font_h.get("sidebar", 10.0)
|
||||
|
||||
def _cap_font(html_str: str, max_size: float) -> str:
|
||||
"""font-size: NNpx 중 max_size 초과하는 것을 max_size로 캡."""
|
||||
def _repl(m):
|
||||
val = float(m.group(1))
|
||||
if val > max_size:
|
||||
return f"font-size:{max_size}px"
|
||||
return m.group(0)
|
||||
return _re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _repl, html_str)
|
||||
|
||||
# body_html의 첫 번째 주요 div(배경)만 캡: height:117px or 배경 색상이 있는 div
|
||||
# 배경 div 끝(</div> + spacer) 이후가 본심
|
||||
bg_end = body_html.find('<div style="height:12px;') # spacer between bg and core
|
||||
if bg_end > 0:
|
||||
bg_part = body_html[:bg_end]
|
||||
core_part = body_html[bg_end:]
|
||||
bg_part = _cap_font(bg_part, bg_max)
|
||||
body_html = bg_part + core_part
|
||||
# sidebar 전체 캡
|
||||
sidebar_html = _cap_font(sidebar_html, sidebar_max)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
|
||||
@@ -21,14 +21,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# height_cost → px 범위 매핑
|
||||
# height_cost → px 범위 매핑: catalog.yaml의 블록들에서 동적 구축
|
||||
# ──────────────────────────────────────
|
||||
HEIGHT_COST_PX_RANGE = {
|
||||
"compact": (30, 80),
|
||||
"medium": (80, 200),
|
||||
"large": (200, 350),
|
||||
"xlarge": (350, 500),
|
||||
}
|
||||
_height_cost_cache: dict[str, tuple[int, int]] | None = None
|
||||
|
||||
def _get_height_cost_px_range() -> dict[str, tuple[int, int]]:
|
||||
"""catalog.yaml의 블록 min_height_px에서 height_cost별 범위를 동적 계산."""
|
||||
global _height_cost_cache
|
||||
if _height_cost_cache is not None:
|
||||
return _height_cost_cache
|
||||
|
||||
from src.block_reference import _load_catalog
|
||||
# height_cost별로 min_height_px 수집
|
||||
cost_heights: dict[str, list[int]] = {}
|
||||
for b in _load_catalog():
|
||||
cost = b.get("height_cost", "medium")
|
||||
h = b.get("min_height_px", 0)
|
||||
if cost not in cost_heights:
|
||||
cost_heights[cost] = []
|
||||
cost_heights[cost].append(h)
|
||||
|
||||
# 각 cost의 (min, max) 범위 계산
|
||||
result = {}
|
||||
for cost, heights in cost_heights.items():
|
||||
if heights:
|
||||
result[cost] = (min(heights), max(heights))
|
||||
else:
|
||||
result[cost] = (0, 0)
|
||||
|
||||
_height_cost_cache = result
|
||||
return result
|
||||
|
||||
HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}
|
||||
|
||||
@@ -44,6 +66,229 @@ ROLE_ZONE_MAP = {
|
||||
DEFAULT_FONT_SIZE_PX = 15.2
|
||||
DEFAULT_LINE_HEIGHT = 1.7
|
||||
DEFAULT_AVG_CHAR_WIDTH_PX = 14.4 # fonttools 실측 기반 (Pretendard 한글)
|
||||
CHAR_WIDTH_RATIO = 0.947 # Pretendard 한글 실측: char_width = font_size × 0.947
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Phase T-5: 폰트 위계 + 동적 비율 역산
|
||||
# ──────────────────────────────────────
|
||||
|
||||
# 역할별 폰트 위계 범위 (min, max)
|
||||
# 핵심 원칙: font_size(핵심) > font_size(본심) >= font_size(배경) > font_size(첨부)
|
||||
FONT_HIERARCHY_RANGE: dict[str, tuple[float, float]] = {
|
||||
"핵심": (14.0, 14.0), # 고정 14px bold
|
||||
"본심": (12.0, 12.0), # 고정 12px
|
||||
"배경": (10.0, 12.0), # 텍스트 양에 따라 조정
|
||||
"첨부": (9.0, 11.0), # 텍스트 양에 따라 조정
|
||||
"결론": (12.0, 14.0), # footer 배너용
|
||||
}
|
||||
|
||||
# 역할별 줄 높이 비율
|
||||
ROLE_LINE_HEIGHT: dict[str, float] = {
|
||||
"핵심": 1.4,
|
||||
"본심": 1.5,
|
||||
"배경": 1.4,
|
||||
"첨부": 1.4,
|
||||
"결론": 1.3,
|
||||
}
|
||||
|
||||
|
||||
def _estimate_required_height(
|
||||
text_chars: int,
|
||||
font_size: float,
|
||||
available_width: int,
|
||||
line_height_ratio: float = 1.5,
|
||||
padding: int | None = None,
|
||||
) -> int:
|
||||
"""주어진 폰트 크기로 텍스트를 넣으려면 몇 px 필요한가."""
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
if padding is None:
|
||||
padding = tokens["spacing_block"]
|
||||
if text_chars <= 0:
|
||||
return padding * 2
|
||||
char_width = font_size * CHAR_WIDTH_RATIO
|
||||
inner_width = max(tokens["spacing_page"], available_width - padding * 2)
|
||||
chars_per_line = max(1, int(inner_width / char_width))
|
||||
total_lines = max(1, -(-text_chars // chars_per_line)) # ceil division
|
||||
line_height_px = font_size * line_height_ratio
|
||||
return int(total_lines * line_height_px) + padding * 2
|
||||
|
||||
|
||||
def calculate_font_hierarchy(
|
||||
role_text_lengths: dict[str, int],
|
||||
available_width: int | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""역할별 폰트 크기를 위계 범위 내에서 텍스트 양 기반으로 확정.
|
||||
|
||||
Phase T 핵심: 위계가 먼저, 컨테이너가 따라간다.
|
||||
|
||||
Args:
|
||||
role_text_lengths: {"본심": 500, "배경": 200, "첨부": 300, "결론": 50}
|
||||
available_width: 예상 가용 너비 (px)
|
||||
|
||||
Returns:
|
||||
{"핵심": 14.0, "본심": 12.0, "배경": 11.0, "첨부": 10.0, "결론": 13.0}
|
||||
"""
|
||||
if available_width is None:
|
||||
from src.config import settings
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
available_width = settings.slide_width - tokens["spacing_page"] * 2
|
||||
|
||||
result = {}
|
||||
|
||||
for role, (font_min, font_max) in FONT_HIERARCHY_RANGE.items():
|
||||
text_len = role_text_lengths.get(role, 0)
|
||||
|
||||
if font_min == font_max:
|
||||
# 고정 폰트 (핵심, 본심)
|
||||
result[role] = font_max
|
||||
continue
|
||||
|
||||
# 텍스트 양이 많으면 폰트 축소 (범위 내)
|
||||
# max 폰트로 시도 → 안 되면 1px씩 축소
|
||||
chosen = font_max
|
||||
for fs in [font_max, font_max - 1, font_min]:
|
||||
fs = max(font_min, fs)
|
||||
required_h = _estimate_required_height(
|
||||
text_len, fs, available_width, ROLE_LINE_HEIGHT.get(role, 1.5)
|
||||
)
|
||||
# 합리적 범위(xlarge 최대 높이 이내)면 이 폰트 사용
|
||||
ranges = _get_height_cost_px_range()
|
||||
max_reasonable_h = ranges.get("xlarge", (0, 0))[1] if ranges.get("xlarge") else required_h
|
||||
if required_h <= max_reasonable_h:
|
||||
chosen = fs
|
||||
break
|
||||
chosen = fs # 최소 폰트라도 사용
|
||||
|
||||
result[role] = chosen
|
||||
|
||||
# 위계 강제: 핵심 > 본심 >= 배경 > 첨부
|
||||
if result.get("배경", 11) > result.get("본심", 12):
|
||||
result["배경"] = result["본심"]
|
||||
if result.get("첨부", 10) >= result.get("배경", 11):
|
||||
result["첨부"] = max(FONT_HIERARCHY_RANGE["첨부"][0], result["배경"] - 1)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def calculate_dynamic_ratio(
|
||||
role_text_lengths: dict[str, int],
|
||||
font_hierarchy: dict[str, float],
|
||||
slide_width: int = 1280,
|
||||
slide_height: int = 720,
|
||||
preset: dict[str, Any] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""sidebar 텍스트 양에서 body:sidebar 비율 역산.
|
||||
|
||||
고정 65:35가 아니라 텍스트 양 기반.
|
||||
|
||||
Returns:
|
||||
(body_pct, sidebar_pct) 예: (70, 30) or (65, 35)
|
||||
"""
|
||||
# 프리셋에서 기본 비율 가져오기
|
||||
preset_body_pct = 0
|
||||
preset_sidebar_pct = 0
|
||||
if preset:
|
||||
zones = preset.get("zones", {})
|
||||
for zone_name, zone_info in zones.items():
|
||||
if zone_name == "body":
|
||||
preset_body_pct = zone_info.get("width_pct", 0)
|
||||
elif zone_name == "sidebar":
|
||||
preset_sidebar_pct = zone_info.get("width_pct", 0)
|
||||
|
||||
sidebar_text = role_text_lengths.get("첨부", 0)
|
||||
body_text = sum(v for k, v in role_text_lengths.items() if k != "첨부" and k != "결론")
|
||||
|
||||
total_text = body_text + sidebar_text
|
||||
if total_text <= 0 or sidebar_text <= 0:
|
||||
# sidebar 텍스트 없으면 프리셋의 기본 비율 사용
|
||||
if preset_body_pct > 0 and preset_sidebar_pct > 0:
|
||||
return (preset_body_pct, preset_sidebar_pct)
|
||||
return (100, 0)
|
||||
|
||||
# 텍스트 비율에서 순수 계산
|
||||
sidebar_ratio = sidebar_text / total_text
|
||||
sidebar_pct = max(1, int(sidebar_ratio * 100))
|
||||
body_pct = 100 - sidebar_pct
|
||||
|
||||
return (body_pct, sidebar_pct)
|
||||
|
||||
|
||||
def calculate_design_budget(
|
||||
container_height_px: int,
|
||||
container_width_px: int,
|
||||
block_schema: dict,
|
||||
font_size: float,
|
||||
padding: int | None = None,
|
||||
) -> dict:
|
||||
"""블록 schema 기반 디자인 요소 크기 역산.
|
||||
|
||||
텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산.
|
||||
텍스트를 줄이는 것이 아니라 도형/이미지/CSS 요소의 크기를 맞추는 방향.
|
||||
|
||||
Args:
|
||||
container_height_px: 컨테이너 높이
|
||||
container_width_px: 컨테이너 너비
|
||||
block_schema: catalog.yaml의 해당 블록 schema
|
||||
font_size: 이 역할의 확정된 폰트 크기 (T-5에서 결정)
|
||||
padding: 컨테이너 내부 패딩
|
||||
|
||||
Returns:
|
||||
{
|
||||
"text_height_px": int, # 텍스트가 차지하는 높이
|
||||
"available_height_px": int, # 디자인 요소 가용 높이
|
||||
"available_width_px": int, # 디자인 요소 가용 너비
|
||||
"max_circle_diameter": int, # 원형 요소 최대 지름
|
||||
"max_img_width": int, # 이미지 최대 너비
|
||||
"max_img_height": int, # 이미지 최대 높이
|
||||
"fits": bool, # 디자인 요소가 들어가는지
|
||||
}
|
||||
"""
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
if padding is None:
|
||||
padding = tokens["spacing_block"]
|
||||
|
||||
# 블록 schema에서 텍스트 슬롯별 높이 합산
|
||||
text_height = 0
|
||||
for slot_name, spec in block_schema.items():
|
||||
if slot_name.startswith("max_"):
|
||||
continue
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
slot_lines = spec.get("max_lines", 1)
|
||||
slot_font = spec.get("font_size", font_size)
|
||||
# line-height는 typography constant
|
||||
text_height += int(slot_lines * (slot_font * 1.6))
|
||||
|
||||
remaining_height = container_height_px - text_height - padding * 2
|
||||
remaining_width = container_width_px - padding * 2
|
||||
border_w = tokens.get("border_width", tokens.get("accent_border", 1))
|
||||
|
||||
return {
|
||||
"text_height_px": text_height,
|
||||
"available_height_px": max(0, remaining_height),
|
||||
"available_width_px": max(0, remaining_width),
|
||||
"max_circle_diameter": max(0, min(remaining_height, remaining_width) - border_w * 2),
|
||||
"max_img_width": max(0, remaining_width),
|
||||
"max_img_height": max(0, remaining_height),
|
||||
"fits": remaining_height >= 0,
|
||||
}
|
||||
|
||||
|
||||
def _estimate_capacity(width_px: int, font_size: float, height_px: int) -> int:
|
||||
"""주어진 공간에서 수용 가능한 총 글자 수."""
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
padding = tokens["spacing_block"]
|
||||
char_width = font_size * CHAR_WIDTH_RATIO
|
||||
inner_width = max(1, width_px - padding * 2)
|
||||
chars_per_line = max(1, int(inner_width / char_width))
|
||||
inner_height = max(1, height_px - padding * 2)
|
||||
line_height_px = font_size * 1.4 # line-height (typography)
|
||||
max_lines = max(1, int(inner_height / line_height_px))
|
||||
return chars_per_line * max_lines
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
@@ -101,28 +346,60 @@ def calculate_container_specs(
|
||||
zone_roles[zone] = []
|
||||
zone_roles[zone].append((role_name, role_info))
|
||||
|
||||
# tokens.css에서 spacing 읽기 (하드코딩 방지)
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
slide_padding = tokens["spacing_page"] # --spacing-page
|
||||
|
||||
for zone_name, role_list in zone_roles.items():
|
||||
zone_info = zones.get(zone_name, {})
|
||||
zone_budget = zone_info.get("budget_px", 490)
|
||||
zone_width_pct = zone_info.get("width_pct", 100)
|
||||
zone_width_px = int(slide_width * zone_width_pct / 100 * 0.85) # 패딩 제외
|
||||
# zone budget: weight 비율로 전체 가용 공간 배정
|
||||
# weight는 초기 배정 비율. before→filled→after에서 조정됨.
|
||||
header_height = tokens.get("header_height", 66)
|
||||
total_available = slide_height - slide_padding * 2 - header_height - gap_px * 2
|
||||
zone_weight_sum = sum(info.get("weight", 0) for _, info in role_list)
|
||||
all_weight_sum = sum(
|
||||
info.get("weight", 0)
|
||||
for roles in zone_roles.values()
|
||||
for _, info in roles
|
||||
)
|
||||
if all_weight_sum > 0 and zone_weight_sum > 0:
|
||||
zone_budget = int(total_available * zone_weight_sum / all_weight_sum)
|
||||
else:
|
||||
# fallback: 프리셋 또는 동적 계산
|
||||
zone_budget = zone_info.get("budget_px") or total_available
|
||||
zone_width_pct = zone_info.get("width_pct", 0)
|
||||
# 패딩 제외 폭: 슬라이드 폭 - 좌우 패딩
|
||||
slide_inner_width = slide_width - slide_padding * 2
|
||||
zone_width_px = int(slide_inner_width * zone_width_pct / 100) if zone_width_pct > 0 else slide_inner_width
|
||||
|
||||
# 이 zone 안의 역할별 비중 비율 계산
|
||||
total_weight = sum(info.get("weight", 0.25) for _, info in role_list)
|
||||
# Kei가 weight를 반드시 제공해야 함 (없으면 균등 배분)
|
||||
total_weight = sum(info.get("weight", 0) for _, info in role_list)
|
||||
if total_weight <= 0:
|
||||
total_weight = 1.0
|
||||
# weight가 없으면 균등 배분
|
||||
total_weight = len(role_list)
|
||||
for _, info in role_list:
|
||||
info.setdefault("weight", 1)
|
||||
|
||||
# 간격 제외
|
||||
total_gap = gap_px * max(0, len(role_list) - 1)
|
||||
available = zone_budget - total_gap
|
||||
|
||||
# 최소 높이: catalog에서 가장 작은 블록의 min_height_px
|
||||
from src.block_reference import _load_catalog
|
||||
min_block_h = min(
|
||||
(b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
|
||||
default=1,
|
||||
)
|
||||
|
||||
for role_name, role_info in role_list:
|
||||
weight = role_info.get("weight", 0.25)
|
||||
weight = role_info.get("weight", 1)
|
||||
topic_ids = role_info.get("topic_ids", [])
|
||||
|
||||
# 비중 비율로 높이 할당
|
||||
ratio = weight / total_weight
|
||||
height_px = max(50, int(available * ratio))
|
||||
height_px = max(min_block_h, int(available * ratio))
|
||||
|
||||
# 블록 내부 제약 계산 — topic당 높이로 판단
|
||||
topic_count = max(1, len(topic_ids))
|
||||
@@ -157,27 +434,43 @@ def calculate_container_specs(
|
||||
|
||||
|
||||
def _max_allowed_height_cost(container_height_px: int) -> str:
|
||||
"""컨테이너 높이에서 허용되는 최대 height_cost."""
|
||||
if container_height_px >= 350:
|
||||
return "xlarge"
|
||||
elif container_height_px >= 200:
|
||||
return "large"
|
||||
elif container_height_px >= 80:
|
||||
return "medium"
|
||||
else:
|
||||
"""컨테이너 높이에서 허용되는 최대 height_cost.
|
||||
|
||||
catalog.yaml 블록들의 min_height_px 기반 동적 계산.
|
||||
"""
|
||||
ranges = _get_height_cost_px_range()
|
||||
# 높은 cost부터 확인: 컨테이너가 해당 cost의 최소 높이 이상이면 허용
|
||||
for cost in ["xlarge", "large", "medium", "compact"]:
|
||||
if cost in ranges:
|
||||
min_h, _ = ranges[cost]
|
||||
if container_height_px >= min_h:
|
||||
return cost
|
||||
return "compact"
|
||||
|
||||
|
||||
def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]:
|
||||
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
|
||||
if per_block_height_px >= 300:
|
||||
return (15.2, 20, 1.7)
|
||||
elif per_block_height_px >= 150:
|
||||
return (14.0, 14, 1.6)
|
||||
elif per_block_height_px >= 80:
|
||||
return (13.0, 10, 1.5)
|
||||
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정.
|
||||
|
||||
font-size와 line-height는 typography constant (허용).
|
||||
padding은 tokens.css의 spacing 값에서 가져옴.
|
||||
"""
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
tokens = _load_design_tokens()
|
||||
|
||||
# height_cost 범위에서 어떤 급인지 판단
|
||||
ranges = _get_height_cost_px_range()
|
||||
xlarge_min = ranges.get("xlarge", (0, 0))[0]
|
||||
large_min = ranges.get("large", (0, 0))[0]
|
||||
medium_min = ranges.get("medium", (0, 0))[0]
|
||||
|
||||
if per_block_height_px >= xlarge_min and xlarge_min > 0:
|
||||
return (15.2, tokens["spacing_block"], 1.7) # font 15.2, padding=--spacing-block, lh 1.7
|
||||
elif per_block_height_px >= large_min and large_min > 0:
|
||||
return (14.0, tokens["spacing_inner"], 1.6) # font 14, padding=--spacing-inner, lh 1.6
|
||||
elif per_block_height_px >= medium_min and medium_min > 0:
|
||||
return (13.0, tokens["spacing_small"], 1.5) # font 13, padding=--spacing-small, lh 1.5
|
||||
else:
|
||||
return (12.0, 8, 1.4)
|
||||
return (12.0, tokens["spacing_small"], 1.4) # font 12, padding=--spacing-small, lh 1.4
|
||||
|
||||
|
||||
def _calculate_block_constraints(
|
||||
@@ -188,11 +481,17 @@ def _calculate_block_constraints(
|
||||
line_height: float,
|
||||
padding_px: int,
|
||||
) -> dict:
|
||||
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
|
||||
per_topic_height = max(30, (height_px - padding_px * 2) // topic_count)
|
||||
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산.
|
||||
|
||||
모든 수치는 입력 파라미터(이전 Stage 결과) + font metric에서 도출.
|
||||
"""
|
||||
per_topic_height = max(1, (height_px - padding_px * 2) // max(1, topic_count))
|
||||
line_height_px = font_size_px * line_height
|
||||
max_lines = max(1, int(per_topic_height / line_height_px))
|
||||
chars_per_line = max(5, int((width_px - padding_px * 2) / (font_size_px * 0.95)))
|
||||
max_lines = max(1, int(per_topic_height / max(1, line_height_px)))
|
||||
# chars_per_line: CHAR_WIDTH_RATIO(font metric)로 계산
|
||||
char_width = font_size_px * CHAR_WIDTH_RATIO
|
||||
usable_width = max(1, width_px - padding_px * 2)
|
||||
chars_per_line = max(1, int(usable_width / max(1, char_width)))
|
||||
max_items = max(1, max_lines // 2)
|
||||
max_chars_total = max_lines * chars_per_line
|
||||
|
||||
@@ -200,8 +499,8 @@ def _calculate_block_constraints(
|
||||
"max_lines": max_lines,
|
||||
"max_items": max_items,
|
||||
"chars_per_line": chars_per_line,
|
||||
"max_chars_total": max(20, max_chars_total),
|
||||
"max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
|
||||
"max_chars_total": max(1, max_chars_total),
|
||||
"max_chars_per_item": max(1, max_chars_total // max(1, max_items)),
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +548,9 @@ def finalize_block_specs(
|
||||
if find_container_for_topic(b.get("topic_id"), container_specs) == spec
|
||||
and b.get("topic_id") is not None]
|
||||
sibling_count = max(1, len(siblings))
|
||||
per_block_height = max(40, spec.height_px // sibling_count)
|
||||
# 최소 높이: catalog에서 가장 작은 블록의 min_height_px
|
||||
_min_h = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1)
|
||||
per_block_height = max(_min_h, spec.height_px // sibling_count)
|
||||
|
||||
# 폰트/패딩 결정
|
||||
font_size, padding, line_h = _determine_typography(per_block_height)
|
||||
@@ -318,31 +619,19 @@ def calculate_trim_chars(
|
||||
# Phase Q-3: 글자수 예산 계산
|
||||
# ──────────────────────────────────────
|
||||
|
||||
# 블록 유형별 구조적 오버헤드 (제목, 패딩, 간격 등 — px 단위)
|
||||
# Phase Q 2차 테스트 기반 실측 보정: 실제 CSS padding/margin 기반
|
||||
_BLOCK_STRUCTURAL_OVERHEAD: dict[str, int] = {
|
||||
"card-numbered": 40, # 패딩 12*2=24 + gap 10 + border 2 + 여유
|
||||
"card-icon-desc": 50, # 아이콘 40 + 패딩 + gap
|
||||
"card-step-vertical": 50, # 마커 30 + 패딩 + gap
|
||||
"dark-bullet-list": 52, # 패딩 20*2=40 + 제목 줄 12
|
||||
"comparison-2col": 60, # 헤더*2 + 구분선 + 패딩
|
||||
"compare-3col-badge": 60, # 헤더 행 40 + 배지 + 패딩
|
||||
"compare-2col-split": 60, # 헤더 행 40 + 패딩
|
||||
"table-simple-striped": 50, # 헤더 행 35 + 패딩
|
||||
"banner-gradient": 36, # 패딩 16*2=32 + 여유
|
||||
"callout-solution": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"callout-warning": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"quote-big-mark": 50, # 따옴표 장식 + 패딩 20*2
|
||||
"quote-question": 76, # 패딩 28*2=56 + desc margin 10 + 여유 10 (실측 기반)
|
||||
"compare-pill-pair": 52, # 외곽 패딩 6*2 + 내부 패딩 18*2 + 여유
|
||||
"venn-diagram": 60, # SVG 구조 + 패딩
|
||||
"process-horizontal": 50, # 화살표 + 번호 36 + 패딩
|
||||
"flow-arrow-horizontal": 30, # 캡슐 + 화살표 + 패딩
|
||||
"keyword-circle-row": 60, # 원형 + 라벨 + 패딩
|
||||
}
|
||||
# 블록 유형별 구조적 오버헤드 — catalog.yaml의 padding_overhead_px에서 읽음
|
||||
def _get_block_overhead(block_type: str) -> int:
|
||||
"""catalog.yaml에서 블록의 padding_overhead_px를 읽어옴."""
|
||||
from src.block_reference import _load_catalog
|
||||
for b in _load_catalog():
|
||||
if b["id"] == block_type:
|
||||
return b.get("padding_overhead_px", 0)
|
||||
return 0
|
||||
|
||||
# 같은 컨테이너 내 블록 간 gap (px)
|
||||
_CONTAINER_BLOCK_GAP = 8
|
||||
# 같은 컨테이너 내 블록 간 gap — tokens.css에서 읽음
|
||||
def _get_block_gap() -> int:
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
return _load_design_tokens()["spacing_small"]
|
||||
|
||||
|
||||
def calculate_char_budget(
|
||||
@@ -371,15 +660,16 @@ def calculate_char_budget(
|
||||
topic_count = max(1, len(container_spec.topic_ids))
|
||||
|
||||
# 같은 컨테이너 내 블록 간 gap 차감
|
||||
total_gap = _CONTAINER_BLOCK_GAP * max(0, topic_count - 1)
|
||||
available_container_height = max(40, container_spec.height_px - total_gap)
|
||||
total_gap = _get_block_gap() * max(0, topic_count - 1)
|
||||
_min_h2 = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1)
|
||||
available_container_height = max(_min_h2, container_spec.height_px - total_gap)
|
||||
per_topic_px = available_container_height // topic_count
|
||||
|
||||
# 폰트 크기 결정
|
||||
font_size, padding, line_h = _determine_typography(per_topic_px)
|
||||
|
||||
# 구조적 오버헤드
|
||||
structural = _BLOCK_STRUCTURAL_OVERHEAD.get(block_type, 20)
|
||||
structural = _get_block_overhead(block_type)
|
||||
content_height = max(10, per_topic_px - structural)
|
||||
|
||||
# 줄 수 계산
|
||||
@@ -387,7 +677,10 @@ def calculate_char_budget(
|
||||
available_lines = max(1, int(content_height / line_height_px))
|
||||
|
||||
# 한국어 줄당 글자수 (폰트 크기 기반)
|
||||
usable_width = container_spec.width_px * 0.85 # 패딩 제외
|
||||
# 패딩 제외: tokens.css의 spacing_page × 2가 아니라 블록 내부 padding
|
||||
# 블록 padding은 container_spec.block_constraints에 있을 수 있음
|
||||
block_padding = container_spec.block_constraints.get("padding_px", 0)
|
||||
usable_width = container_spec.width_px - block_padding * 2 if block_padding else container_spec.width_px
|
||||
chars_per_line = max(5, int(usable_width / font_size))
|
||||
|
||||
# 항목 수 제한 (블록 정의 참조)
|
||||
|
||||
780
src/step_visualizer.py
Normal file
@@ -0,0 +1,780 @@
|
||||
"""파이프라인 각 Stage 실행 후 자동으로 HTML 시각화를 생성.
|
||||
|
||||
save_snapshot()에서 호출됨. 각 stage의 실제 context 데이터로 HTML 생성.
|
||||
JSON context 파일과 동일한 stage 이름을 사용.
|
||||
|
||||
생성되는 파일 (JSON context 파일과 1:1 매칭):
|
||||
stage_0.html — MDX 정규화 결과 (섹션, 팝업, 이미지)
|
||||
stage_1a.html — Kei 꼭지 + 영역 배정 (테이블)
|
||||
stage_1b.html — 컨셉 구체화 (source_data, summary 추가)
|
||||
stage_1_5a.html — 빈 컨테이너 (1280x720)
|
||||
stage_1_5a_content.html — 컨테이너에 실제 콘텐츠 배치
|
||||
stage_1_5b.html — 디자인 예산 (영역별 available_height/width)
|
||||
stage_1_7.html — 블록 선택 표시
|
||||
stage_1_8_fit_before.html — 적합성 검증 (재배분 전)
|
||||
stage_1_8_fit_after.html — 재배분 후 + 보강 결과
|
||||
stage_1_8_blocks.html — 재배분 후 컨테이너에 블록 배치
|
||||
stage_2.html — HTML 생성 결과 (영역별 생성된 HTML)
|
||||
stage_3.html — 렌더링 조립 → final.html 링크
|
||||
stage_4.html — 품질 게이트 (측정값, 점수)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.pipeline_context import PipelineContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
|
||||
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
|
||||
|
||||
|
||||
def generate_step_html(stage_name: str, ctx: "PipelineContext", steps_dir: Path) -> None:
|
||||
"""stage_name에 따라 적절한 시각화 HTML 생성. 파일명은 JSON context와 1:1 매칭."""
|
||||
try:
|
||||
if stage_name == "stage_0":
|
||||
_gen_stage_0(ctx, steps_dir)
|
||||
elif stage_name == "stage_1a":
|
||||
_gen_stage_1a(ctx, steps_dir)
|
||||
elif stage_name == "stage_1b":
|
||||
_gen_stage_1b(ctx, steps_dir)
|
||||
elif stage_name == "stage_1_5a":
|
||||
_gen_stage_1_5a(ctx, steps_dir)
|
||||
_gen_stage_1_5a_content(ctx, steps_dir)
|
||||
elif stage_name == "stage_1_5b":
|
||||
_gen_stage_1_5b(ctx, steps_dir)
|
||||
elif stage_name == "stage_1_7":
|
||||
_gen_stage_1_7(ctx, steps_dir)
|
||||
elif stage_name == "stage_1_8":
|
||||
# before와 filled는 pipeline.py Stage 1.8 내부에서 before context로 이미 생성됨
|
||||
# step_visualizer는 after context로 호출되므로 덮어쓰면 안 됨
|
||||
# blocks와 fit_after만 생성 (after 상태 반영)
|
||||
_gen_stage_1_8_blocks(ctx, steps_dir)
|
||||
_gen_stage_1_8_fit_after(ctx, steps_dir)
|
||||
elif stage_name == "stage_2":
|
||||
_gen_stage_2(ctx, steps_dir)
|
||||
elif stage_name == "stage_3":
|
||||
_gen_stage_3(ctx, steps_dir)
|
||||
elif stage_name == "stage_4":
|
||||
_gen_stage_4(ctx, steps_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"[step_viz] {stage_name} 시각화 실패: {e}")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 공통
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _tokens():
|
||||
from src.fit_verifier import _load_design_tokens
|
||||
return _load_design_tokens()
|
||||
|
||||
|
||||
def _calc_coords(containers: dict, ratio: tuple) -> dict:
|
||||
t = _tokens()
|
||||
pad = t.get("spacing_page", 40)
|
||||
gap = t.get("spacing_block", 20)
|
||||
small = t.get("spacing_small", 8)
|
||||
header_h = 66
|
||||
|
||||
inner_w = 1280 - pad * 2
|
||||
body_w = int(inner_w * ratio[0] / 100) if ratio[0] > 0 else inner_w
|
||||
sidebar_w = inner_w - body_w - gap if ratio[1] > 0 else 0
|
||||
|
||||
def gh(c):
|
||||
if hasattr(c, "height_px"): return c.height_px
|
||||
return c.get("height_px", 0) if isinstance(c, dict) else 0
|
||||
|
||||
bg_h = gh(containers.get("배경", {}))
|
||||
core_h = gh(containers.get("본심", {}))
|
||||
sb_h = gh(containers.get("첨부", {}))
|
||||
ft_h = gh(containers.get("결론", {}))
|
||||
|
||||
bg_top = pad + header_h + gap
|
||||
core_top = bg_top + bg_h + small
|
||||
ft_top = max(core_top + core_h, bg_top + sb_h) + gap
|
||||
|
||||
return {
|
||||
"header": {"l": pad, "t": pad, "w": inner_w, "h": header_h},
|
||||
"배경": {"l": pad, "t": bg_top, "w": body_w, "h": bg_h},
|
||||
"본심": {"l": pad, "t": core_top, "w": body_w, "h": core_h},
|
||||
"첨부": {"l": pad + body_w + gap, "t": bg_top, "w": sidebar_w, "h": sb_h},
|
||||
"결론": {"l": pad, "t": ft_top, "w": inner_w, "h": ft_h},
|
||||
}
|
||||
|
||||
|
||||
def _wrap(title, subtitle, slide_body):
|
||||
return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
|
||||
</style></head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">{title}</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:8px;">{subtitle}</div>
|
||||
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
|
||||
{slide_body}
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def _hdr(c, title):
|
||||
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
|
||||
f'background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;'
|
||||
f'padding:0 20px;font-size:22px;font-weight:900;color:#1e293b;">{title}</div>\n')
|
||||
|
||||
|
||||
def _box(c, role, inner, extra=""):
|
||||
cl = COLORS.get(role, "#333")
|
||||
return (f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;height:{c["h"]}px;'
|
||||
f'border:2px solid {cl};border-radius:6px;background:{cl}08;overflow:hidden;{extra}">{inner}</div>\n')
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 0: MDX 정규화
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_0(ctx, steps_dir):
|
||||
"""MDX 정규화 결과: 섹션, 팝업, 이미지, 테이블 목록."""
|
||||
norm = ctx.normalized if hasattr(ctx, 'normalized') else {}
|
||||
if hasattr(norm, 'model_dump'):
|
||||
norm = norm.model_dump()
|
||||
elif not isinstance(norm, dict):
|
||||
norm = {}
|
||||
|
||||
sections = norm.get("sections", [])
|
||||
popups = norm.get("popups", [])
|
||||
images = norm.get("images", [])
|
||||
tables = norm.get("tables", [])
|
||||
title = norm.get("title", ctx.analysis.title if ctx.analysis else "")
|
||||
|
||||
sec_rows = ""
|
||||
for i, s in enumerate(sections):
|
||||
heading = s.get("heading", "") if isinstance(s, dict) else ""
|
||||
content = s.get("content", "") if isinstance(s, dict) else str(s)
|
||||
preview = content[:120].replace("<", "<") + ("..." if len(content) > 120 else "")
|
||||
bg = "#f8fafc" if i % 2 == 0 else "#fff"
|
||||
sec_rows += f'<tr style="background:{bg};"><td style="padding:6px 8px;">{i+1}</td><td style="padding:6px 8px;font-weight:700;">{heading}</td><td style="padding:6px 8px;font-size:11px;">{preview}</td></tr>\n'
|
||||
|
||||
popup_rows = ""
|
||||
for p in popups:
|
||||
pt = p.get("title", "") if isinstance(p, dict) else str(p)
|
||||
pc = p.get("content", "") if isinstance(p, dict) else ""
|
||||
popup_rows += f'<tr><td style="padding:6px 8px;font-weight:700;">{pt}</td><td style="padding:6px 8px;font-size:11px;">{len(pc)}자</td></tr>\n'
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||||
</head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 0: MDX 정규화</div>
|
||||
<div style="font-size:12px;color:#555;margin-bottom:12px;">제목: <b>{title}</b> | 섹션: {len(sections)}개 | 팝업: {len(popups)}개 | 이미지: {len(images)}개 | 테이블: {len(tables)}개</div>
|
||||
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">섹션</div>
|
||||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;margin-bottom:16px;">
|
||||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">#</th><th style="padding:8px;">heading</th><th style="padding:8px;">content (미리보기)</th></tr>{sec_rows}</table>
|
||||
<div style="font-size:13px;font-weight:700;margin-bottom:4px;">팝업</div>
|
||||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;">
|
||||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">title</th><th style="padding:8px;">분량</th></tr>{popup_rows}</table>
|
||||
</body></html>"""
|
||||
(steps_dir / "stage_0.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1A: Kei 꼭지
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1a(ctx, steps_dir):
|
||||
ps = ctx.page_structure.roles
|
||||
rm = {}
|
||||
for role, info in ps.items():
|
||||
if isinstance(info, dict):
|
||||
for tid in info.get("topic_ids", []):
|
||||
rm[tid] = role
|
||||
|
||||
rows = ""
|
||||
for t in ctx.topics:
|
||||
role = rm.get(t.id, "?")
|
||||
c = COLORS.get(role, "#333")
|
||||
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
|
||||
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
|
||||
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
|
||||
f'<td style="padding:6px 8px;">{t.purpose}</td><td style="padding:6px 8px;">{t.layer}</td>'
|
||||
f'<td style="padding:6px 8px;">{t.relation_type}</td>'
|
||||
f'<td style="padding:6px 8px;color:{c};font-weight:700;">{role}</td></tr>\n')
|
||||
|
||||
ps_info = "<br>".join(f"{r}: topic_ids={info.get('topic_ids')}, weight={info.get('weight')}"
|
||||
for r, info in ps.items() if isinstance(info, dict))
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||||
</head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1A/1B: Kei 꼭지 + 영역 배정</div>
|
||||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:900px;">
|
||||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
|
||||
<th style="padding:8px;">purpose</th><th style="padding:8px;">layer</th><th style="padding:8px;">relation_type</th>
|
||||
<th style="padding:8px;">영역</th></tr>{rows}</table>
|
||||
<div style="margin-top:12px;font-size:12px;color:#555;"><b>페이지 구조:</b><br>{ps_info}</div></body></html>"""
|
||||
(steps_dir / "stage_1a.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1B: 컨셉 구체화
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1b(ctx, steps_dir):
|
||||
"""Stage 1B 후 꼭지에 source_data, summary가 추가된 상태."""
|
||||
ps = ctx.page_structure.roles
|
||||
rm = {}
|
||||
for role, info in ps.items():
|
||||
if isinstance(info, dict):
|
||||
for tid in info.get("topic_ids", []):
|
||||
rm[tid] = role
|
||||
|
||||
rows = ""
|
||||
for t in ctx.topics:
|
||||
role = rm.get(t.id, "?")
|
||||
c = COLORS.get(role, "#333")
|
||||
bg = "#f8fafc" if t.id % 2 == 0 else "#fff"
|
||||
sd = (t.source_data or "")[:150]
|
||||
sd_display = sd.replace("<", "<") + ("..." if len(t.source_data or "") > 150 else "")
|
||||
summary = (t.summary or "")[:100] if hasattr(t, 'summary') else ""
|
||||
rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;text-align:center;">{t.id}</td>'
|
||||
f'<td style="padding:6px 8px;font-weight:700;">{t.title}</td>'
|
||||
f'<td style="padding:6px 8px;color:{c};">{role}</td>'
|
||||
f'<td style="padding:6px 8px;">{t.layer}</td>'
|
||||
f'<td style="padding:6px 8px;font-size:10px;">{sd_display}</td>'
|
||||
f'<td style="padding:6px 8px;font-size:10px;color:#555;">{summary}</td></tr>\n')
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||||
</head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 1B: 컨셉 구체화</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 1A의 꼭지에 source_data(원본 텍스트)와 summary가 추가됨</div>
|
||||
<table style="border-collapse:collapse;font-size:12px;width:100%;">
|
||||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">ID</th><th style="padding:8px;">제목</th>
|
||||
<th style="padding:8px;">영역</th><th style="padding:8px;">layer</th>
|
||||
<th style="padding:8px;">source_data (미리보기)</th><th style="padding:8px;">summary</th></tr>{rows}</table></body></html>"""
|
||||
(steps_dir / "stage_1b.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.5a: 빈 컨테이너
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1_5a(ctx, steps_dir):
|
||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
||||
fh = ctx.font_hierarchy
|
||||
title = ctx.analysis.title or "슬라이드"
|
||||
body = _hdr(coords["header"], title)
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
c = coords[role]
|
||||
cl = COLORS[role]
|
||||
fk = FONT_MAP[role]
|
||||
font = getattr(fh, fk, "?")
|
||||
inner = (f'<div style="text-align:center;margin-top:{max(0,c["h"]//2-15)}px;">'
|
||||
f'<b style="color:{cl};font-size:13px;">{role}</b><br>'
|
||||
f'<span style="color:#888;font-size:10px;">{c["w"]}x{c["h"]}px / font:{font}px</span></div>')
|
||||
body += _box(c, role, inner)
|
||||
|
||||
r = ctx.container_ratio
|
||||
html = _wrap(f"Step 1: 빈 컨테이너 (Stage 1.5a)", f"비율 {r[0]}:{r[1]}", body)
|
||||
(steps_dir / "stage_1_5a.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.5a: 컨테이너에 콘텐츠 배치
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1_5a_content(ctx, steps_dir):
|
||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
||||
title = ctx.analysis.title or "슬라이드"
|
||||
body = _hdr(coords["header"], title)
|
||||
ps = ctx.page_structure.roles
|
||||
topic_map = {t.id: t for t in ctx.topics}
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
c = coords[role]
|
||||
cl = COLORS[role]
|
||||
info = ps.get(role, {})
|
||||
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
||||
|
||||
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role}</div>']
|
||||
for tid in tids:
|
||||
t = topic_map.get(tid)
|
||||
if not t:
|
||||
continue
|
||||
lines.append(f'<div style="font-size:11px;font-weight:700;margin-bottom:2px;">[꼭지{tid}] {t.title} — {t.purpose} · {t.layer}</div>')
|
||||
sd = t.source_data
|
||||
if sd:
|
||||
# 불릿으로 표시
|
||||
for sent in sd.split(", "):
|
||||
sent = sent.strip()
|
||||
if sent:
|
||||
lines.append(f'<div class="bl" style="font-size:10px;color:#444;"><span class="bl-m">•</span><span class="bl-t">{sent}</span></div>')
|
||||
|
||||
inner = f'<div style="padding:6px 10px;overflow:hidden;">{"".join(lines)}</div>'
|
||||
body += _box(c, role, inner)
|
||||
|
||||
html = _wrap("Step 1b: 콘텐츠 배치 (꼭지 → 컨테이너)", "각 컨테이너에 배정된 꼭지의 source_data", body)
|
||||
(steps_dir / "stage_1_5a_content.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.5b: 디자인 예산
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1_5b(ctx, steps_dir):
|
||||
"""영역별 디자인 예산 (available height/width, fits 여부)."""
|
||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
||||
title = ctx.analysis.title or "슬라이드"
|
||||
body = _hdr(coords["header"], title)
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
c = coords[role]
|
||||
cl = COLORS[role]
|
||||
ci = ctx.containers.get(role)
|
||||
if not ci:
|
||||
continue
|
||||
db = ci.design_budget
|
||||
if db and hasattr(db, 'model_dump'):
|
||||
db = db.model_dump()
|
||||
elif not isinstance(db, dict):
|
||||
db = {}
|
||||
|
||||
avail_h = db.get("available_height_px", 0)
|
||||
avail_w = db.get("available_width_px", 0)
|
||||
fits = db.get("fits", False)
|
||||
icon = "✅" if fits else "⚠️"
|
||||
|
||||
inner = (f'<div style="padding:6px 10px;">'
|
||||
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}×{c["h"]}px)</div>'
|
||||
f'<div style="font-size:10px;color:#555;">available: {avail_h}×{avail_w}px</div>'
|
||||
f'<div style="font-size:10px;color:#555;">fits: {fits}</div>'
|
||||
f'</div>')
|
||||
body += _box(c, role, inner)
|
||||
|
||||
html = _wrap("Stage 1.5b: 디자인 예산", "영역별 available_height/width + fits 여부", body)
|
||||
(steps_dir / "stage_1_5b.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.7: 블록 선택
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1_7(ctx, steps_dir):
|
||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
||||
title = ctx.analysis.title or "슬라이드"
|
||||
body = _hdr(coords["header"], title)
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
c = coords[role]
|
||||
cl = COLORS[role]
|
||||
ref_list = ctx.references.get(role, [])
|
||||
|
||||
lines = [f'<div style="font-size:10px;color:{cl};font-weight:700;margin-bottom:4px;">{role} ({c["w"]}x{c["h"]}px)</div>']
|
||||
for r in ref_list:
|
||||
bid = r.block_id
|
||||
var = r.variant
|
||||
vtype = r.visual_type
|
||||
line = f'<b>{bid}</b> ({var}) <span style="color:#888;font-size:9px;">{vtype}</span>'
|
||||
# 주종 정보 — model_dump에서 확인
|
||||
rd = r.model_dump() if hasattr(r, "model_dump") else {}
|
||||
# BlockReference에는 supporting 정보가 없음 — stage_1_7_context.json에서 확인
|
||||
lines.append(f'<div style="font-size:11px;margin-bottom:2px;">{line}</div>')
|
||||
|
||||
inner = f'<div style="padding:6px 10px;">{"".join(lines)}</div>'
|
||||
body += _box(c, role, inner)
|
||||
|
||||
html = _wrap("Step 2: 블록 선택 (Stage 1.7)", "layer 기반 주종 판단. 컨테이너 위에 블록 표시.", body)
|
||||
(steps_dir / "stage_1_7.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.8: 텍스트/그림 채운 상태 (filled)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1_8_filled(ctx, steps_dir):
|
||||
"""블록 디자인에 텍스트를 채운 상태. 공통 조립 함수(block_assembler) 사용."""
|
||||
from src.block_assembler import assemble_slide_html
|
||||
slide_html = assemble_slide_html(ctx)
|
||||
# 시각화 제목 삽입
|
||||
header = (
|
||||
'<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
|
||||
'Stage 1.8: 블록 디자인에 텍스트 채운 상태 (fit 검증 전)</div>\n'
|
||||
'<div style="font-size:11px;color:#666;margin-bottom:8px;">'
|
||||
'블록 CSS + structured_text + font_hierarchy. 공통 조립 함수 사용.</div>\n'
|
||||
)
|
||||
html = slide_html.replace('</head><body>', '</head><body>\n' + header, 1)
|
||||
(steps_dir / "stage_1_8_filled.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
def _gen_stage_1_8_fit_before(ctx, steps_dir):
|
||||
"""before: weight 비중대로 배정된 빈 컨테이너. fit 판단 없이 크기만 표시."""
|
||||
coords = _calc_coords(ctx.containers, ctx.container_ratio)
|
||||
title = ctx.analysis.title or "슬라이드"
|
||||
body = _hdr(coords["header"], title)
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
c = coords[role]
|
||||
cl = COLORS[role]
|
||||
|
||||
ref_list = ctx.references.get(role, [])
|
||||
blocks = ", ".join(r.block_id for r in ref_list) if ref_list else "미선택"
|
||||
|
||||
ps = ctx.page_structure.roles
|
||||
info = ps.get(role, {})
|
||||
weight = info.get("weight", 0) if isinstance(info, dict) else 0
|
||||
|
||||
inner = (f'<div style="padding:6px 10px;">'
|
||||
f'<div style="font-size:10px;color:{cl};font-weight:700;">{role} ({c["w"]}x{c["h"]}px)</div>'
|
||||
f'<div style="font-size:10px;color:#555;">weight: {weight}</div>'
|
||||
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>'
|
||||
f'</div>')
|
||||
body += _box(c, role, inner)
|
||||
|
||||
html = _wrap("Stage 1.8: before (weight 비중 초기 배정)", "빈 컨테이너. filled에서 텍스트를 채운 후 넘침 확인.", body)
|
||||
(steps_dir / "stage_1_8_fit_before.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.8: 재배분 후 + 보강
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1_8_fit_after(ctx, steps_dir):
|
||||
fit = ctx.fit_result
|
||||
enh = ctx.enhancement_result
|
||||
redist = fit.get("redistribution", {})
|
||||
roles_fit = fit.get("roles", {})
|
||||
|
||||
# 재배분된 컨테이너
|
||||
new_c = {}
|
||||
for role, ci in ctx.containers.items():
|
||||
new_h = int(redist.get(role, ci.height_px))
|
||||
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
|
||||
|
||||
coords = _calc_coords(new_c, ctx.container_ratio)
|
||||
title = ctx.analysis.title or "슬라이드"
|
||||
body = _hdr(coords["header"], title)
|
||||
|
||||
emps = enh.get("emphasis_blocks", [])
|
||||
bolds = enh.get("bold_keywords", {})
|
||||
sups = enh.get("supplement_blocks", [])
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
c = coords[role]
|
||||
cl = COLORS[role]
|
||||
rf = roles_fit.get(role, {})
|
||||
status = rf.get("fit_status", "?")
|
||||
icon = {"OK": "✅", "TIGHT": "⚠️", "OVERFLOW": "❌"}.get(status, "?")
|
||||
old_h = rf.get("allocated_px", 0)
|
||||
new_h = int(redist.get(role, old_h))
|
||||
needed = rf.get("total_required_px", 0)
|
||||
delta = new_h - old_h
|
||||
|
||||
ref_list = ctx.references.get(role, [])
|
||||
blocks = ", ".join(r.block_id for r in ref_list)
|
||||
|
||||
delta_str = f" <span style='color:#16a34a;'>({delta:+d}px)</span>" if delta != 0 else ""
|
||||
|
||||
inner = (f'<div style="padding:6px 10px;">'
|
||||
f'<div style="font-size:10px;color:{cl};font-weight:700;">{icon} {role} ({c["w"]}x{new_h}px){delta_str}</div>'
|
||||
f'<div style="font-size:10px;color:#555;">필요: {needed:.0f}px / 재배분 후: {new_h}px</div>'
|
||||
f'<div style="font-size:10px;color:#888;margin-top:2px;">블록: {blocks}</div>')
|
||||
|
||||
# 보강 정보
|
||||
role_emps = [e for e in emps if e.get("role") == role]
|
||||
role_bolds = bolds.get(role, [])
|
||||
role_sups = [s for s in sups if s.get("role") == role]
|
||||
|
||||
if role_emps:
|
||||
for e in role_emps:
|
||||
inner += f'<div style="margin-top:3px;background:#991b1b;color:#fff;border-radius:2px;padding:2px 6px;font-size:9px;font-weight:700;">강조: {e.get("sentence","")[:40]}...</div>'
|
||||
if role_sups:
|
||||
for s in role_sups:
|
||||
inner += f'<div style="margin-top:3px;font-size:9px;color:#2563eb;">보충: {s.get("block_id")} ({s.get("content_source")})</div>'
|
||||
if role_bolds:
|
||||
inner += f'<div style="margin-top:3px;font-size:9px;color:#475569;">bold: {role_bolds[:4]}</div>'
|
||||
|
||||
inner += '</div>'
|
||||
body += _box(c, role, inner)
|
||||
|
||||
redist_str = ", ".join(f"{r}:{int(v)}px" for r, v in redist.items())
|
||||
html = _wrap("Step 3b: 재배분 후 + 보강 (Stage 1.8)", f"재배분: {redist_str}", body)
|
||||
(steps_dir / "stage_1_8_fit_after.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1.8: 블록 디자인을 컨테이너에 배치
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_1_8_blocks(ctx, steps_dir):
|
||||
"""재배분된 컨테이너에 블록 SLOT 구조 + 블록 디자인 + 주종관계 표시.
|
||||
debug_steps/step2_phase_v.html 수준의 시각화."""
|
||||
import re as _re
|
||||
|
||||
fit = ctx.fit_result or {}
|
||||
redist = fit.get("redistribution", {})
|
||||
topic_map = {t.id: t for t in ctx.topics}
|
||||
ps = ctx.page_structure.roles
|
||||
|
||||
new_c = {}
|
||||
for role, ci in ctx.containers.items():
|
||||
new_h = int(redist.get(role, ci.height_px))
|
||||
new_c[role] = {"height_px": new_h, "width_px": ci.width_px}
|
||||
|
||||
coords = _calc_coords(new_c, ctx.container_ratio)
|
||||
title = ctx.analysis.title or "슬라이드"
|
||||
|
||||
all_block_css = set()
|
||||
slide_body = _hdr(coords["header"], title)
|
||||
legend_lines = []
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
c = coords[role]
|
||||
cl = COLORS[role]
|
||||
ref_list = ctx.references.get(role, [])
|
||||
info = ps.get(role, {})
|
||||
tids = info.get("topic_ids", []) if isinstance(info, dict) else []
|
||||
|
||||
if not ref_list:
|
||||
slide_body += _box(c, role, f'<div style="padding:6px;color:#888;">블록 없음</div>')
|
||||
continue
|
||||
|
||||
r0 = ref_list[0]
|
||||
bid = r0.block_id
|
||||
var = r0.variant
|
||||
is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
|
||||
sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
|
||||
primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
|
||||
|
||||
# 블록 디자인 HTML — SLOT 주석은 유지, 내용은 SLOT 마커로
|
||||
raw = r0.design_reference_html or ""
|
||||
# CSS 추출
|
||||
styles = _re.findall(r'<style>(.*?)</style>', raw, _re.DOTALL)
|
||||
for s in styles:
|
||||
all_block_css.add(s)
|
||||
clean = _re.sub(r'<style>.*?</style>', '', raw, flags=_re.DOTALL)
|
||||
|
||||
# SLOT 주석을 보이는 텍스트로 변환
|
||||
def _slot_comment_to_visible(match):
|
||||
text = match.group(1).strip()
|
||||
if 'SLOT:' in text:
|
||||
return f'<span style="color:#888;font-size:9px;background:#f0f0f0;padding:1px 4px;border-radius:2px;">{text}</span>'
|
||||
return ''
|
||||
clean = _re.sub(r'<!--\s*(SLOT:[^>]+?)-->', _slot_comment_to_visible, clean)
|
||||
# 나머지 주석 제거
|
||||
clean = _re.sub(r'<!--.*?-->', '', clean, flags=_re.DOTALL)
|
||||
|
||||
# 태그 라벨 (동적)
|
||||
tag_parts = [f"{role} ({c['w']}×{c['h']})", bid]
|
||||
if is_hier:
|
||||
sup_str = "+".join(f"꼭지{st}" for st in sup_tids)
|
||||
tag_parts.append(f"꼭지{primary_tid}(주)+{sup_str}(종) → 블록 1개")
|
||||
tag_label = " · ".join(tag_parts)
|
||||
|
||||
# 종속 꼭지 SLOT 표시
|
||||
sub_slot = ""
|
||||
if is_hier and sup_tids:
|
||||
for st in sup_tids:
|
||||
st_topic = topic_map.get(st)
|
||||
st_purpose = st_topic.purpose if st_topic and hasattr(st_topic, 'purpose') else ""
|
||||
sub_slot += (
|
||||
f'<div style="padding-left:8px;border-left:2px solid {cl};margin-top:4px;'
|
||||
f'font-size:10px;color:{cl};">'
|
||||
f'SLOT: 하위 (꼭지{st} — {st_purpose})</div>'
|
||||
)
|
||||
|
||||
# key-msg SLOT (본심만)
|
||||
keymsg_slot = ""
|
||||
if role == "본심" and ctx.analysis.core_message:
|
||||
keymsg_slot = (
|
||||
f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:3px;'
|
||||
f'padding:2px 6px;font-size:9px;color:#1e40af;font-weight:700;margin-top:4px;">'
|
||||
f'SLOT: key-msg</div>'
|
||||
)
|
||||
|
||||
inner = (
|
||||
f'<div style="position:relative;padding-top:18px;width:100%;height:100%;">'
|
||||
f'<span style="position:absolute;top:-16px;left:4px;font-size:9px;font-weight:700;'
|
||||
f'background:white;padding:1px 6px;border-radius:3px;border:1px solid {cl};'
|
||||
f'color:{cl};white-space:nowrap;">{tag_label}</span>'
|
||||
f'{clean}{sub_slot}{keymsg_slot}</div>'
|
||||
)
|
||||
|
||||
slide_body += (
|
||||
f'<div style="position:absolute;left:{c["l"]}px;top:{c["t"]}px;width:{c["w"]}px;'
|
||||
f'height:{c["h"]}px;border:2px dashed {cl};border-radius:6px;overflow:hidden;">'
|
||||
f'{inner}</div>\n'
|
||||
)
|
||||
|
||||
# 범례
|
||||
if is_hier:
|
||||
primary_topic = topic_map.get(primary_tid)
|
||||
p_layer = primary_topic.layer if primary_topic and hasattr(primary_topic, 'layer') else ""
|
||||
legend_lines.append(
|
||||
f'• {role}: 꼭지{primary_tid}({p_layer}) + '
|
||||
f'{"+".join(f"꼭지{st}" for st in sup_tids)} → '
|
||||
f'<b>주종 관계 → {bid} 1개</b>'
|
||||
)
|
||||
else:
|
||||
for r in ref_list:
|
||||
t = topic_map.get(r.topic_id if hasattr(r, 'topic_id') else None)
|
||||
t_layer = t.layer if t and hasattr(t, 'layer') else ""
|
||||
legend_lines.append(f'• {role}: 꼭지{r.topic_id}({t_layer}) → <b>{r.block_id}</b>')
|
||||
|
||||
css_block = "\n".join(all_block_css)
|
||||
legend_html = "<br>".join(legend_lines)
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>
|
||||
*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
|
||||
{css_block}
|
||||
</style></head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 1.8: SLOT 구조 + 블록 디자인 (재배분 후)</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:8px;">블록 디자인에 SLOT 마커 + 주종관계 표시. 다음 Stage 2에서 실제 콘텐츠로 채워짐.</div>
|
||||
<div style="width:1280px;height:720px;background:white;position:relative;border:1px solid #ccc;">
|
||||
{slide_body}
|
||||
</div>
|
||||
<div style="margin-top:16px;font-size:12px;color:#555;line-height:1.8;">
|
||||
<b>블록 선택 근거 (layer 기반):</b><br>{legend_html}
|
||||
</div></body></html>"""
|
||||
(steps_dir / "stage_1_8_blocks.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
def _gen_stage_2(ctx, steps_dir):
|
||||
"""Stage 2 결과: 영역별 Sonnet 출력을 실제 렌더링하여 보여줌.
|
||||
각 역할(배경/본심/첨부/결론)의 HTML을 개별 컨테이너에 실제 렌더링."""
|
||||
gen = ctx.generated_html or {}
|
||||
sub_layouts = ctx.sub_layouts or {}
|
||||
ps = ctx.page_structure.roles
|
||||
|
||||
# body_html에서 배경/본심 분리 (spacer로 구분)
|
||||
body_html = gen.get("body_html", "")
|
||||
sidebar_html = gen.get("sidebar_html", "")
|
||||
footer_html = gen.get("footer_html", "")
|
||||
|
||||
# body_html = 배경 + spacer + 본심. spacer로 분리
|
||||
import re as _re
|
||||
spacer_pattern = r'<div style="height:\d+px;"></div>'
|
||||
body_parts = _re.split(spacer_pattern, body_html, maxsplit=1)
|
||||
bg_html = body_parts[0].strip() if len(body_parts) > 1 else ""
|
||||
core_html = body_parts[1].strip() if len(body_parts) > 1 else body_html.strip()
|
||||
|
||||
# 역할별 HTML 매핑
|
||||
role_htmls = {}
|
||||
if bg_html and "배경" in ps:
|
||||
role_htmls["배경"] = bg_html
|
||||
if core_html and "본심" in ps:
|
||||
role_htmls["본심"] = core_html
|
||||
if sidebar_html and "첨부" in ps:
|
||||
role_htmls["첨부"] = sidebar_html
|
||||
if footer_html and "결론" in ps:
|
||||
role_htmls["결론"] = footer_html
|
||||
|
||||
# 각 역할을 컨테이너 크기에 맞게 실제 렌더링
|
||||
fit = ctx.fit_result or {}
|
||||
redist = fit.get("redistribution", {})
|
||||
sections = []
|
||||
|
||||
for role in ["배경", "본심", "첨부", "결론"]:
|
||||
rhtml = role_htmls.get(role, "")
|
||||
if not rhtml:
|
||||
continue
|
||||
cl = COLORS.get(role, "#333")
|
||||
ci = ctx.containers.get(role)
|
||||
if not ci:
|
||||
continue
|
||||
h = int(redist.get(role, ci.height_px))
|
||||
w = ci.width_px
|
||||
|
||||
# sub_layout 정보
|
||||
layout = sub_layouts.get(role, {})
|
||||
scs = layout.get("sub_containers", [])
|
||||
sc_desc = " + ".join(f'{sc["name"]}({int(sc["width_px"])}×{int(sc["height_px"])}px)' for sc in scs) if scs else ""
|
||||
|
||||
sections.append(
|
||||
f'<div style="margin-bottom:20px;">'
|
||||
f'<div style="font-size:13px;font-weight:700;color:{cl};margin-bottom:4px;">'
|
||||
f'{role} ({w}×{h}px)'
|
||||
f'{" — " + sc_desc if sc_desc else ""}</div>'
|
||||
f'<div style="width:{w}px;height:{h}px;border:2px solid {cl};border-radius:6px;'
|
||||
f'overflow:hidden;background:white;font-family:Pretendard Variable,sans-serif;'
|
||||
f'word-break:keep-all;">{rhtml}</div></div>'
|
||||
)
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
|
||||
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}</style>
|
||||
</head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 2: 영역별 HTML 생성 결과 (Sonnet)</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:12px;">각 역할의 Sonnet 출력을 컨테이너 크기에 맞게 실제 렌더링</div>
|
||||
{"".join(sections)}
|
||||
</body></html>"""
|
||||
(steps_dir / "stage_2.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
def _gen_stage_3(ctx, steps_dir):
|
||||
"""Stage 3 결과: rendered_html을 별도 파일로 저장 + 링크.
|
||||
rendered_html 자체가 완성 HTML이므로 직접 렌더링 가능."""
|
||||
rendered = ctx.rendered_html or ""
|
||||
if rendered:
|
||||
# rendered_html을 별도 파일로 저장하여 브라우저에서 직접 확인 가능
|
||||
(steps_dir / "stage_3_rendered.html").write_text(rendered, encoding="utf-8")
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}
|
||||
body{{background:#e5e5e5;padding:10px;font-family:sans-serif;}}</style>
|
||||
</head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">Stage 3: 렌더링 조립 결과</div>
|
||||
<div style="font-size:11px;color:#666;margin-bottom:8px;">Stage 2의 영역별 HTML을 슬라이드 프레임(CSS Grid)에 배치 + 후처리 적용</div>
|
||||
<p style="margin-bottom:8px;"><a href="stage_3_rendered.html" style="font-size:16px;font-weight:700;">렌더링 결과 보기 (1280×720) →</a></p>
|
||||
<p><a href="../final.html" style="font-size:14px;">final.html 보기 →</a></p>
|
||||
<div style="margin-top:16px;font-size:12px;color:#555;">
|
||||
Stage 3 후처리: sidebar width:100% 조정, 폰트 캡핑 (배경≤{ctx.font_hierarchy.bg}px, 첨부≤{ctx.font_hierarchy.sidebar}px), overflow 제거, bold 변환
|
||||
</div>
|
||||
</body></html>"""
|
||||
(steps_dir / "stage_3.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 4: 품질 게이트
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _gen_stage_4(ctx, steps_dir):
|
||||
"""Stage 4 결과: 측정값 + 품질 점수."""
|
||||
measurement = ctx.measurement or {}
|
||||
quality_score = ctx.quality_score if hasattr(ctx, 'quality_score') else "N/A"
|
||||
|
||||
slide_m = measurement.get("slide", {})
|
||||
zones = measurement.get("zones", {})
|
||||
|
||||
zone_rows = ""
|
||||
for zone_name, zone_data in zones.items():
|
||||
overflowed = zone_data.get("overflowed", False)
|
||||
excess = zone_data.get("excess_px", 0)
|
||||
client_h = zone_data.get("clientHeight", 0)
|
||||
scroll_h = zone_data.get("scrollHeight", 0)
|
||||
icon = "❌" if overflowed else "✅"
|
||||
bg = "#fee2e2" if overflowed else "#f0fdf4"
|
||||
zone_rows += (f'<tr style="background:{bg};"><td style="padding:6px 8px;">{icon} {zone_name}</td>'
|
||||
f'<td style="padding:6px 8px;">{client_h}px</td>'
|
||||
f'<td style="padding:6px 8px;">{scroll_h}px</td>'
|
||||
f'<td style="padding:6px 8px;">{excess:+d}px</td></tr>\n')
|
||||
|
||||
score_color = "#16a34a" if (isinstance(quality_score, (int, float)) and quality_score >= 80) else "#dc2626"
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
|
||||
<style>*{{margin:0;padding:0;box-sizing:border-box;}}body{{background:#e5e5e5;padding:10px;font-family:sans-serif;word-break:keep-all;}}</style>
|
||||
</head><body>
|
||||
<div style="font-size:16px;font-weight:bold;margin-bottom:8px;">Stage 4: 품질 게이트</div>
|
||||
<div style="font-size:24px;font-weight:900;color:{score_color};margin-bottom:12px;">품질 점수: {quality_score}</div>
|
||||
<div style="font-size:12px;color:#555;margin-bottom:4px;">슬라이드: clientHeight={slide_m.get("clientHeight", "?")}px, scrollHeight={slide_m.get("scrollHeight", "?")}px, overflow={slide_m.get("overflowed", "?")}</div>
|
||||
<table style="border-collapse:collapse;font-size:12px;width:100%;max-width:600px;margin-top:8px;">
|
||||
<tr style="background:#1e293b;color:white;"><th style="padding:8px;">영역</th><th style="padding:8px;">clientH</th><th style="padding:8px;">scrollH</th><th style="padding:8px;">excess</th></tr>{zone_rows}</table>
|
||||
</body></html>"""
|
||||
(steps_dir / "stage_4.html").write_text(html, encoding="utf-8")
|
||||
380
src/validators.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""Phase T-2: Stage 1A/1B 검증 시스템.
|
||||
|
||||
Stage 2 이후 검증(content_verifier.py)과 분리된 독립 모듈.
|
||||
AI(Kei)의 콘텐츠 분석 결과를 원본과 대조하여 검증.
|
||||
|
||||
검증 4계층:
|
||||
1. 형식 검증 (Pydantic) — 값 범위, 유효 enum, null 체크
|
||||
2. 내용 검증 (코드+대조) — 결과가 원본에 대해 적절한가
|
||||
3. 모순 탐지 (결정 테이블) — purpose × relation_type 논리 모순
|
||||
4. 피드백 생성 — Self-Refine 패턴: localization + evidence + instruction
|
||||
|
||||
도구:
|
||||
- kiwipiepy: 한국어 명사/키워드 추출 (T-2 조사: Windows 즉시 동작, Java 불필요)
|
||||
- regex: 관계 표현 패턴 (T-2 조사: 7개 relation_type별 15개+ 패턴)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# kiwipiepy lazy loading (첫 import 시 ~50MB 모델 다운로드)
|
||||
_kiwi = None
|
||||
|
||||
def _get_kiwi():
|
||||
global _kiwi
|
||||
if _kiwi is None:
|
||||
from kiwipiepy import Kiwi
|
||||
_kiwi = Kiwi()
|
||||
return _kiwi
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 한국어 키워드 추출 (kiwipiepy)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def extract_keywords_kiwi(text: str) -> set[str]:
|
||||
"""kiwipiepy로 명사 + 영문 약어 추출.
|
||||
|
||||
기존 content_verifier.py의 regex extract_keywords()보다 정확:
|
||||
- "정립되지" → "정립" 추출 가능 (어미 분리)
|
||||
- "혼용되어" → "혼용" 추출 가능
|
||||
- 복합 조사 "에서는" 등 정확 분리
|
||||
"""
|
||||
kiwi = _get_kiwi()
|
||||
tokens = kiwi.tokenize(text)
|
||||
keywords = set()
|
||||
for t in tokens:
|
||||
# NNG: 일반명사, NNP: 고유명사, SL: 외국어(영문약어)
|
||||
if t.tag in ("NNG", "NNP", "SL") and len(t.form) >= 2:
|
||||
keywords.add(t.form)
|
||||
return keywords
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 관계 표현 패턴 (T-2 조사 결과)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
RELATION_PATTERNS: dict[str, list[str]] = {
|
||||
"comparison": [
|
||||
r"[Vv][Ss]\.?", r"에\s*비해", r"반면", r"차이점?", r"비교",
|
||||
r"대비", r"와\s*달리", r"과\s*달리", r"한편", r"에\s*반하여",
|
||||
r"그에\s*비해", r"상이", r"구분", r"차별화",
|
||||
],
|
||||
"sequence": [
|
||||
r"→", r"이후", r"다음", r"먼저", r"그\s*후", r"단계",
|
||||
r"순서", r"[0-9]+차", r"최종적", r"한\s*뒤", r"우선",
|
||||
r"이어서", r"점진적", r"과정", r"를\s*거쳐",
|
||||
],
|
||||
"hierarchy": [
|
||||
r"상위", r"하위", r"속하", r"의\s*일부", r"범주",
|
||||
r"구성요소", r"체계", r"분류", r"계층", r"로\s*나뉜?다",
|
||||
r"종속", r"수직적", r"상하\s*관계", r"아우르", r"광의|협의",
|
||||
],
|
||||
"inclusion": [
|
||||
r"포함", r"융합", r"통합", r"안에", r"속에", r"결합",
|
||||
r"합쳐", r"아우르", r"망라", r"수렴", r"내포", r"포괄",
|
||||
r"연계", r"접목", r"겹치|중복",
|
||||
],
|
||||
"cause_effect": [
|
||||
r"때문에", r"따라서", r"결과", r"원인", r"로\s*인해",
|
||||
r"하여", r"해서", r"초래", r"야기", r"기인",
|
||||
r"영향", r"유발", r"한\s*결과", r"므로",
|
||||
r"그래서", r"그러므로", r"에\s*의해",
|
||||
],
|
||||
"definition": [
|
||||
r"이란", r"정의", r"의미", r"개념",
|
||||
r"을\s*말한다", r"을\s*뜻한다", r"로\s*정의", r"가리킨다",
|
||||
r"라\s*함은", r"라고\s*한다", r"줄임말|약어|약자",
|
||||
r"에\s*해당", r"일컫", r"용어",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def detect_relation_evidence(text: str) -> dict[str, int]:
|
||||
"""원본 텍스트에서 각 relation_type의 증거 수를 카운트."""
|
||||
evidence = {}
|
||||
for rel_type, patterns in RELATION_PATTERNS.items():
|
||||
count = sum(1 for p in patterns if re.search(p, text))
|
||||
evidence[rel_type] = count
|
||||
return evidence
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 모순 결정 테이블 (데이터로 정의)
|
||||
# ══════════════════════════════════════
|
||||
|
||||
# purpose × relation_type 하드 모순 (이 조합은 논리적으로 불가능)
|
||||
CONTRADICTIONS: dict[str, list[str]] = {
|
||||
"결론강조": ["comparison", "sequence"], # 결론은 비교나 순서가 아님
|
||||
"문제제기": ["sequence", "definition"], # 문제제기는 순서 나열이나 정의가 아님
|
||||
"용어정의": ["hierarchy", "cause_effect"], # 정의 나열은 상하위나 인과가 아님
|
||||
"구조시각화": ["none"], # 시각화할 관계가 없으면 구조시각화가 아님
|
||||
}
|
||||
|
||||
# 소프트 경고 (의심 수준)
|
||||
SOFT_WARNINGS: dict[str, list[str]] = {
|
||||
"핵심전달": ["definition"], # 핵심전달에 definition은 약간 의심
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1A 검증
|
||||
# ══════════════════════════════════════
|
||||
|
||||
VALID_PURPOSES = {"문제제기", "근거사례", "핵심전달", "용어정의", "결론강조", "구조시각화"}
|
||||
VALID_ROLES = {"flow", "reference"}
|
||||
VALID_LAYERS = {"intro", "core", "supporting", "conclusion"}
|
||||
|
||||
|
||||
def validate_stage_1a(
|
||||
analysis: dict[str, Any],
|
||||
clean_text: str,
|
||||
) -> list[dict]:
|
||||
"""Stage 1A(Kei 꼭지 추출) 결과 검증.
|
||||
|
||||
Args:
|
||||
analysis: Kei API 반환 dict
|
||||
clean_text: Stage 0에서 정규화된 텍스트
|
||||
|
||||
Returns:
|
||||
에러 리스트 (빈 리스트 = 통과)
|
||||
"""
|
||||
errors = []
|
||||
topics = analysis.get("topics", [])
|
||||
page_struct = analysis.get("page_structure", {})
|
||||
|
||||
# ── 형식 검증 ──
|
||||
|
||||
if not topics:
|
||||
errors.append({
|
||||
"severity": "FATAL",
|
||||
"field": "topics",
|
||||
"localization": "topics가 비어있음",
|
||||
"instruction": "콘텐츠에서 최소 1개 꼭지를 추출하라",
|
||||
})
|
||||
return errors
|
||||
|
||||
# weight 합 검증 (0.9~1.1)
|
||||
total_weight = sum(
|
||||
info.get("weight", 0) for info in page_struct.values()
|
||||
if isinstance(info, dict)
|
||||
)
|
||||
if total_weight < 0.9 or total_weight > 1.1:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": "page_structure.weight",
|
||||
"localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
|
||||
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
|
||||
})
|
||||
|
||||
# 본심 존재 + 본심 weight ≥ 0.3
|
||||
core_info = page_struct.get("본심", {})
|
||||
if not core_info or not isinstance(core_info, dict):
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": "page_structure.본심",
|
||||
"localization": "본심 역할이 page_structure에 없음",
|
||||
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
|
||||
})
|
||||
elif core_info.get("weight", 0) < 0.3:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": "page_structure.본심.weight",
|
||||
"localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
|
||||
"instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
|
||||
})
|
||||
|
||||
# 필수 필드 검증
|
||||
for t in topics:
|
||||
tid = t.get("id", "?")
|
||||
if not t.get("title"):
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{tid}].title",
|
||||
"localization": f"topic {tid}에 title 없음",
|
||||
"instruction": "각 topic에 title을 부여하라",
|
||||
})
|
||||
if t.get("purpose") and t["purpose"] not in VALID_PURPOSES:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{tid}].purpose",
|
||||
"localization": f"topic {tid} purpose '{t['purpose']}' 유효하지 않음",
|
||||
"current_value": t["purpose"],
|
||||
"instruction": f"유효한 purpose: {VALID_PURPOSES}",
|
||||
})
|
||||
|
||||
# page_structure의 topic_ids가 실제 topics에 존재하는지
|
||||
all_topic_ids = {t.get("id") for t in topics}
|
||||
for role, info in page_struct.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
for tid in info.get("topic_ids", []):
|
||||
if tid not in all_topic_ids:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"page_structure.{role}.topic_ids",
|
||||
"localization": f"{role}에 존재하지 않는 topic_id {tid}",
|
||||
"instruction": f"topic_ids는 topics[].id에 존재하는 값만 사용하라. 현재 topics: {sorted(all_topic_ids)}",
|
||||
})
|
||||
|
||||
# ── 내용 검증 (원본 대조) ──
|
||||
|
||||
if clean_text:
|
||||
# 원본 ## 섹션 수 vs topic 수 비교
|
||||
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
|
||||
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > 2:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": "topics",
|
||||
"localization": f"원본 ## 섹션 {len(original_sections)}개, topic {len(topics)}개 (차이 {abs(len(topics) - len(original_sections))})",
|
||||
"evidence": f"원본 섹션: {[s[3:].strip()[:30] for s in original_sections]}",
|
||||
"instruction": "원본의 주요 섹션이 topic에 매핑되었는지 확인하라",
|
||||
})
|
||||
|
||||
# topic summary 키워드가 원본에 존재하는지 (kiwipiepy)
|
||||
try:
|
||||
orig_keywords = extract_keywords_kiwi(clean_text)
|
||||
for t in topics:
|
||||
summary = t.get("summary", "")
|
||||
if not summary:
|
||||
continue
|
||||
summary_kw = extract_keywords_kiwi(summary)
|
||||
if not summary_kw:
|
||||
continue
|
||||
overlap = summary_kw & orig_keywords
|
||||
rate = len(overlap) / len(summary_kw) if summary_kw else 1.0
|
||||
if rate < 0.5:
|
||||
missing = summary_kw - orig_keywords
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{t.get('id', '?')}].summary",
|
||||
"localization": f"summary 키워드 보존율 {rate:.0%}",
|
||||
"evidence": f"원본에 없는 키워드: {missing}",
|
||||
"instruction": f"summary에 원본에 없는 표현을 추가하지 마라. 원본 키워드로 수정하라.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"[T-2] kiwipiepy 키워드 검증 실패: {e}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# Stage 1B 검증
|
||||
# ══════════════════════════════════════
|
||||
|
||||
VALID_RELATION_TYPES = {"hierarchy", "cause_effect", "comparison", "sequence", "definition", "inclusion", "none"}
|
||||
|
||||
|
||||
def validate_stage_1b(
|
||||
topics: list[dict[str, Any]],
|
||||
clean_text: str,
|
||||
raw_content: str = "",
|
||||
) -> list[dict]:
|
||||
"""Stage 1B(컨셉 구체화) 결과 검증.
|
||||
|
||||
Args:
|
||||
topics: Stage 1B 후 업데이트된 topics 리스트
|
||||
clean_text: Stage 0에서 정규화된 텍스트
|
||||
raw_content: 원본 MDX 전체 (popups/details 포함). 대조 범위 확장용.
|
||||
|
||||
Returns:
|
||||
에러 리스트 (빈 리스트 = 통과)
|
||||
"""
|
||||
# 대조 범위: clean_text + raw_content (popups/details 내용 포함)
|
||||
full_text = clean_text
|
||||
if raw_content:
|
||||
full_text = clean_text + "\n" + raw_content
|
||||
errors = []
|
||||
|
||||
for t in topics:
|
||||
tid = t.get("id", "?")
|
||||
purpose = t.get("purpose", "")
|
||||
relation_type = t.get("relation_type", "")
|
||||
expression_hint = t.get("expression_hint", "")
|
||||
source_data = t.get("source_data", "")
|
||||
|
||||
# ── 형식 검증 ──
|
||||
|
||||
if relation_type not in VALID_RELATION_TYPES:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{tid}].relation_type",
|
||||
"localization": f"topic {tid}: 유효하지 않은 relation_type '{relation_type}'",
|
||||
"current_value": relation_type,
|
||||
"instruction": f"유효한 relation_type: {sorted(VALID_RELATION_TYPES)}",
|
||||
})
|
||||
|
||||
if not expression_hint:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{tid}].expression_hint",
|
||||
"localization": f"topic {tid}: expression_hint 비어있음",
|
||||
"instruction": "expression_hint를 작성하라. 형식: 관계 선언 + 콘텐츠 설명 + 시각 지침",
|
||||
})
|
||||
|
||||
# ── 모순 탐지 (결정 테이블) ──
|
||||
|
||||
if purpose in CONTRADICTIONS:
|
||||
if relation_type in CONTRADICTIONS[purpose]:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{tid}].relation_type",
|
||||
"localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
|
||||
"current_value": f"purpose={purpose}, relation_type={relation_type}",
|
||||
"evidence": f"'{purpose}'는 '{relation_type}'와 논리적으로 양립 불가",
|
||||
"instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
|
||||
f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
|
||||
})
|
||||
|
||||
if purpose in SOFT_WARNINGS:
|
||||
if relation_type in SOFT_WARNINGS[purpose]:
|
||||
logger.warning(
|
||||
f"[T-2 경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 의심"
|
||||
)
|
||||
|
||||
# ── 원본 대조: source_data 할루시네이션 감지 ──
|
||||
# full_text 사용 (popups/details 내용 포함)
|
||||
|
||||
if source_data and full_text:
|
||||
try:
|
||||
source_kw = extract_keywords_kiwi(source_data)
|
||||
orig_kw = extract_keywords_kiwi(full_text)
|
||||
if source_kw:
|
||||
overlap = source_kw & orig_kw
|
||||
rate = len(overlap) / len(source_kw)
|
||||
if rate < 0.4:
|
||||
missing = source_kw - orig_kw
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{tid}].source_data",
|
||||
"localization": f"topic {tid}: source_data 키워드 보존율 {rate:.0%}",
|
||||
"evidence": f"원본에 없는 키워드: {missing}",
|
||||
"instruction": "source_data는 원본에 실제 존재하는 텍스트만 사용하라. 없는 출처를 만들어내지 마라.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"[T-2] source_data 검증 실패: {e}")
|
||||
|
||||
# ── 원본 대조: relation_type과 원본 언어 패턴 ──
|
||||
# full_text 사용 (popups/details 내용 포함)
|
||||
|
||||
if relation_type and relation_type != "none" and full_text:
|
||||
evidence = detect_relation_evidence(full_text)
|
||||
claimed_count = evidence.get(relation_type, 0)
|
||||
|
||||
if claimed_count == 0:
|
||||
# 주장한 관계의 증거가 0개
|
||||
alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
|
||||
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": f"topics[{tid}].relation_type",
|
||||
"localization": f"topic {tid}: '{relation_type}' 증거 0개",
|
||||
"evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음",
|
||||
"instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.",
|
||||
})
|
||||
|
||||
return errors
|
||||