# Phase 2 기술 검토 보고서 각 항목별로 **정확한 구현 방법, 기존 코드 충돌 여부, 회귀 위험, 대충 처리 위험**을 검토한다. --- ## Phase 2-A: FAISS 블록 검색 ### 현재 코드 상태 ``` design_director.py line 184~188: _load_catalog() → catalog.yaml 전문을 문자열로 읽어서 프롬프트에 통째로 넣음 → 46개 블록 전체 설명 = 약 8,000~10,000 토큰 design_director.py line 294: catalog_text = _load_catalog() design_director.py line 322: catalog=catalog_text # 프롬프트에 삽입 ``` ### 정확한 구현 방법 **1. 임베딩 모델 선택** ``` Kei persona가 사용하는 모델: BAAI/bge-m3 (1024차원) 위치: D:\ad-hoc\kei\persona_agent\backend\llm\retriever.py line 49 design_agent에서도 동일 모델 사용: → 한국어 지원 ✅ → Kei에서 검증됨 ✅ → 1024차원으로 46개 벡터 = 약 184KB (가벼움) ``` **2. 인덱스 구축 (1회성, 오프라인)** ```python # src/block_search.py (신규 파일) import faiss import yaml from sentence_transformers import SentenceTransformer def build_block_index(): # 1. catalog.yaml 로드 with open("templates/catalog.yaml") as f: catalog = yaml.safe_load(f) # 2. 각 블록의 검색용 텍스트 생성 texts = [] ids = [] for block in catalog["blocks"]: text = f"{block['name']}. {block['visual']}. {block['when']}" texts.append(text) ids.append(block["id"]) # 3. 임베딩 model = SentenceTransformer("BAAI/bge-m3") embeddings = model.encode(texts, normalize_embeddings=True) # 4. FAISS 인덱스 생성 dim = embeddings.shape[1] index = faiss.IndexFlatIP(dim) # Inner Product (코사인 유사도) index.add(embeddings) # 5. 저장 faiss.write_index(index, "data/block_index.faiss") # ids 매핑도 저장 ``` **3. 검색 (런타임, 매 요청)** ```python def search_blocks(query: str, top_k: int = 8) -> list[dict]: """콘텐츠 꼭지 설명으로 적합한 블록 검색""" embedding = model.encode([query], normalize_embeddings=True) scores, indices = index.search(embedding, top_k) return [catalog_blocks[i] for i in indices[0]] ``` **4. design_director.py 수정 지점** ``` 현재 line 294: catalog_text = _load_catalog() # 전문 변경: catalog_text = search_blocks(topics_summary, top_k=8) # 관련 8개만 ``` ### 충돌 검토 | 파일 | 영향 | 충돌? | |------|------|-------| | design_director.py | _load_catalog() 반환값이 문자열 → 문자열(검색결과) | ❌ (인터페이스 동일) | | pipeline.py | 호출하지 않음 | ❌ | | renderer.py | _load_catalog_map()은 별도 함수 (경로 매핑용) | ❌ (다른 함수) | | content_editor.py | BLOCK_SLOTS만 참조 | ❌ | ### 회귀 위험 - _load_catalog()를 교체하므로, 검색이 실패하면 catalog 전문을 fallback으로 넘겨야 함 - FAISS 인덱스 파일이 없으면 기존 방식(전문)으로 동작해야 함 ### 대충 처리 위험 - ⚠️ "검색 결과 8개만 넣으면 되지" → 검색 품질이 낮으면 적합한 블록이 빠질 수 있음 - 대응: 검색 결과 + 카테고리별 최소 1개 보장 (8개 중 카테고리 커버 확인) --- ## Phase 2-B: SVG N개 자동 배치 ### 현재 코드 상태 ``` templates/blocks/visuals/venn-diagram.html: → 3개 원 좌표가 하드코딩 (cx="265" cy="300", cx="370" cy="230", cx="365" cy="355") → items[0], items[1], items[2]로 직접 인덱싱 renderer.py: → render_standalone_block()에서 block_data를 Jinja2에 **kwargs로 전달 → 별도 전처리 없음 ``` ### 정확한 구현 방법 **1. 좌표 계산 함수 (신규)** ```python # src/svg_calculator.py (신규 파일) import math def calc_circle_positions( n: int, center_x: float = 300, center_y: float = 300, radius: float = 120, ) -> list[dict]: """N개 원소를 원형으로 배치. 12시부터 시계방향.""" positions = [] for i in range(n): angle = (2 * math.pi * i / n) - math.pi / 2 positions.append({ "cx": round(center_x + radius * math.cos(angle), 1), "cy": round(center_y + radius * math.sin(angle), 1), }) return positions def calc_circle_radius(n: int, base_radius: int = 120) -> int: """N에 따라 작은 원 크기 자동 조정.""" if n <= 3: return base_radius if n <= 5: return int(base_radius * 0.7) return int(base_radius * 0.5) ``` **2. renderer.py 수정 지점** ```python # render_multi_page() 또는 render_slide() 안에서: if block_type in ("venn-diagram", "relationship"): items = block_data.get("items", []) if items: from src.svg_calculator import calc_circle_positions, calc_circle_radius positions = calc_circle_positions(len(items)) small_r = calc_circle_radius(len(items)) for i, item in enumerate(items): item["cx"] = positions[i]["cx"] item["cy"] = positions[i]["cy"] item["r"] = small_r ``` **3. venn-diagram.html 수정** ``` 현재: cx="265" (하드코딩) 변경: cx="{{ items[0].cx }}" (동적) + items 개수에 따라 for 루프로 생성 + 큰 원 크기도 N에 따라 조정 ``` ### 충돌 검토 | 파일 | 영향 | 충돌? | |------|------|-------| | renderer.py | block_data 전처리 추가 | ⚠️ 주의: 기존 render 흐름에 if 분기 추가 | | venn-diagram.html | 하드코딩 → 동적 좌표 | ⚠️ Phase 1 고정 SVG가 깨짐 → fallback 필요 | | pipeline.py | 변경 없음 | ❌ | | content_editor.py | items[].cx/cy는 편집자가 생성하지 않음 | ❌ (renderer에서 추가) | ### 회귀 위험 - **venn-diagram.html 변경 시 Phase 1 고정 SVG가 깨질 수 있음** - 대응: items에 cx/cy가 없으면 기존 하드코딩 좌표 사용 (fallback) ### 대충 처리 위험 - ⚠️ 원 크기 자동 조정을 대충 하면 7개 원이 겹침 - 대응: N별 최적 반지름/큰원 크기 테이블 사전 정의 --- ## Phase 2-C: Step A Opus+FAISS ### 현재 코드 상태 ``` design_director.py line 145~178: select_preset() → 규칙 4줄: reference→sidebar, 대등비교→two-column, 고강조→hero, 나머지→single → LLM 호출 없음, 코드만 의도: Opus가 FAISS로 적합한 구조/블록 검색 + 배치/크기 결정 ``` ### 정확한 구현 방법 **1단계: select_preset()은 유지 (규칙 기반 프리셋은 안정적)** **2단계: Opus가 블록 후보를 검색+선정하는 함수 추가** ```python # design_director.py에 추가 async def _opus_block_selection( content: str, analysis: dict, block_candidates: list[dict], # FAISS 검색 결과 ) -> list[dict]: """Opus가 FAISS 후보에서 최종 블록을 선정하고 배치를 결정.""" # Kei API를 통해 Opus 호출 kei_url = settings.kei_api_url prompt = f""" 콘텐츠 분석 결과와 블록 후보를 보고, 각 꼭지에 가장 적합한 블록을 선택하고 배치를 결정해줘. 후보 블록: {block_candidates} 꼭지: {analysis['topics']} """ # Kei API 호출 (실장과 동일 패턴) ... ``` ### 충돌 검토 | 파일 | 영향 | 충돌? | |------|------|-------| | design_director.py | select_preset() 유지 + _opus_block_selection() 추가 | ❌ (추가만) | | kei_client.py | Kei API 호출 패턴 재사용 | ❌ (참조만) | | pipeline.py | create_layout_concept() 인터페이스 동일 | ❌ | ### 회귀 위험 - ⚠️ Opus가 Kei API를 통해 호출되어야 하는데, **Sonnet을 직접 호출하면 안 됨** - 대응: _call_kei_api() 패턴 그대로 복제. Anthropic 직접 호출 금지. - ⚠️ Kei API 실패 시 fallback = 현재 규칙 기반 방식 (select_preset + Sonnet Step B) ### 대충 처리 위험 - ⚠️ "Opus 대신 Sonnet 직접 호출" → **절대 금지**. 3단계에서 이미 이 실수 했음. - ⚠️ FAISS 없이 catalog 전문 넣기 → Phase 2-A가 선행 안 되면 의미 없음 - 대응: Phase 2-A 완료 후에만 시작 --- ## Phase 2-D: 5단계 재검토 강화 ### 현재 코드 상태 ``` pipeline.py line 102~161: _review_balance() → Sonnet에게 블록별 데이터 양(글자수)만 전달 → HTML 자체는 전달하지 않음 → shrink/rewrite action이 실질적으로 no-op pipeline.py line 164~193: _apply_adjustments() → expand만 동작 (char_guide * 1.5) → shrink: 조건 매칭 안 됨 (expand만 if 처리) → rewrite: 아예 동작 없음 ``` ### 정확한 구현 방법 **1. _review_balance 프롬프트 개선** ```python # 현재: 블록별 데이터 양만 # 변경: 블록별 텍스트 길이 + 블록 타입 + zone + height_cost block_summary = [] for block in blocks: data_len = len(json.dumps(block.get("data", {}), ensure_ascii=False)) block_summary.append( f" {block['area']}/{block['type']}: " f"데이터 {data_len}자, height_cost={block.get('height_cost', '?')}" ) ``` **2. shrink/rewrite 구현** ```python # _apply_adjustments 수정 for adj in adjustments: action = adj.get("action", "") if action == "expand": # 현재 동작: char_guide * 1.5 ... elif action == "shrink": # 신규: char_guide * 0.7 for key in block.get("char_guide", {}): block["char_guide"][key] = int(block["char_guide"][key] * 0.7) elif action == "rewrite": # 신규: data를 비우고 재편집 유도 block.pop("data", None) ``` **3. 재조정 횟수 제한** ```python MAX_ADJUSTMENTS = 2 for attempt in range(MAX_ADJUSTMENTS): review = await _review_balance(...) if not review or not review.get("needs_adjustment"): break layout_concept = await _apply_adjustments(...) html = render_slide(layout_concept) ``` ### 충돌 검토 | 파일 | 영향 | 충돌? | |------|------|-------| | pipeline.py | _review_balance, _apply_adjustments 수정 | ❌ (내부 함수만) | | content_editor.py | fill_content() 재호출됨 | ⚠️ data가 비워진 블록 → _apply_defaults로 fallback | | renderer.py | 변경 없음 | ❌ | ### 회귀 위험 - ⚠️ 재조정 루프가 무한 반복되면 API 비용 폭증 - 대응: MAX_ADJUSTMENTS = 2로 하드 제한 - ⚠️ fill_content 재호출 시 Kei API가 아닌 Sonnet으로 빠질 수 있음 - 대응: fill_content는 이미 Kei API 1순위로 수정됨 ✅ --- ## Phase 2-E: 누락 기능 ### E-1: Pillow 이미지 크기 **수정 지점:** design_director.py create_layout_concept() 내부 ```python # 콘텐츠에 이미지 경로가 있으면 크기 확인 from PIL import Image for topic in analysis.get("topics", []): if topic.get("content_type") == "image": img_path = topic.get("image_path") if img_path and Path(img_path).exists(): w, h = Image.open(img_path).size topic["image_width"] = w topic["image_height"] = h topic["image_ratio"] = w / h # >1.2 가로, <0.8 세로 ``` **충돌:** 없음 (analysis dict에 필드 추가만) **회귀:** 없음 (이미지가 없으면 기존 흐름 그대로) ### E-2: details-block 연결 **수정 지점:** pipeline.py generate_slide() 내부 ```python # 실장이 detail_target=True로 판단한 꼭지를 details-block으로 변환 # 현재 "생략"으로 처리 → details-block으로 연결 ``` **충돌:** design_director.py에서 detail_target 꼭지를 "생략"으로 처리 중 → 이것을 "details-block으로 배치"로 변경 필요 **회귀:** detail_target 로직이 변경되므로 기존 테스트 영향 --- ## 전체 충돌 매트릭스 ``` director editor renderer pipeline kei_client config 2-A FAISS 수정 - - - - - 2-B SVG - - 수정 - - - 2-C Opus 수정 - - - 참조 - 2-D 재검토 - 호출 - 수정 - - 2-E Pillow 수정 - - 수정 - - ``` **동시 수정 파일이 겹치는 경우:** - design_director.py: 2-A + 2-C + 2-E → **순서대로 진행 (2-A 먼저)** - pipeline.py: 2-D + 2-E → **독립적 함수라 병렬 가능** --- ## 절대 규칙 (모든 Phase 2 작업에 적용) ### 🔴 절대 금지 1. **단발성/하드코딩 금지** — 특정 상황만 해결하는 if문, 매직넘버, 고정값 절대 금지. 모든 구현은 N개, M종류에 범용으로 동작해야 한다. 2. **회귀 금지** — Phase 1에서 확정한 구조(catalog 매핑, 카테고리 경로, BF-9 grid 분리, Kei API 우선)를 절대 되돌리지 않는다. 3. **Opus 대신 Sonnet 직접 호출 금지** — Kei API가 필요한 곳에 anthropic.AsyncAnthropic 직접 호출로 대체하지 않는다. fallback은 fallback이지 기본 경로가 아니다. 4. **"일단 돌아가게" 금지** — 동작하지만 원래 설계와 다른 구현은 기술 부채다. 설계대로 구현하거나 설계를 먼저 변경한다. ### 자가 점검 질문 (구현 전 반드시 확인) - [ ] 이 코드가 블록 100개가 되어도 동작하는가? - [ ] 이 코드가 원소 7개가 되어도 동작하는가? - [ ] 이 코드에 하드코딩된 값이 있는가? 있다면 설정/계산으로 대체 가능한가? - [ ] Phase 1에서 확정한 인터페이스(catalog 매핑, grid 프리셋 분리)를 변경하는가? - [ ] Kei API가 아닌 Sonnet을 직접 호출하는 코드가 있는가? (fallback 제외) - [ ] 이 수정이 다른 모듈의 기존 동작을 깨뜨리는가? --- ## "대충 처리" 방지 체크리스트 | # | 위험 | 방지책 | 점검 방법 | |---|------|-------|----------| | 1 | Opus 대신 Sonnet 직접 호출 | Kei API 패턴만 사용 | `grep "AsyncAnthropic" src/*.py` → fallback 위치만 허용 | | 2 | FAISS 없이 catalog 전문 유지 | _load_catalog() 교체 | FAISS 실패 시에만 fallback, 기본은 검색 | | 3 | SVG 좌표를 하드코딩 | calc_circle_positions() 계산 | `grep "cx=\"[0-9]" templates/blocks/visuals/` → 0건이어야 함 | | 4 | 재검토 루프 무한 반복 | MAX_ADJUSTMENTS = 2 | 코드에 상수 존재 확인 | | 5 | shrink/rewrite 미구현 | 3개 action 모두 if 분기 | _apply_adjustments에서 action별 동작 확인 | | 6 | 이미지 크기 하드코딩 | Pillow로 실측 | 고정 비율(예: 1.5) 사용 금지 | | 7 | details-block "생략" 유지 | detail_target → details-block 배치 | design_director에서 "생략" 문자열 제거 확인 | | 8 | 특정 블록 수에만 동작 | N개 범용 루프 | `for i in range(n)` 패턴 확인, `items[0]` 직접 인덱싱 금지 | | 9 | 특정 프리셋에만 동작 | 모든 프리셋에서 테스트 | 4개 프리셋 × 테스트 콘텐츠 조합 |