Files
C.E.L_Slide_test2/scripts/build_block_index.py
kyeongmin 9bd9dad9ac IMPROVEMENT Phase A~D + Phase 2 전체 반영
## IMPROVEMENT (Phase A~D)
- A-1: 4단계 Sonnet 디자인 조정 (_adjust_design) — CSS 변수 cascade
- A-2: 5단계 HTML 전문 프롬프트 전달
- A-3: shrink/expand 하드코딩 제거 → Sonnet target_ratio 기반
- A-4: rewrite action 구현
- A-5: overflow: visible (area 레벨 텍스트 잘림 방지)
- A-6: object-fit cover → contain (이미지 crop 방지)
- A-7: table-layout: fixed
- A-8: container query 폰트 스케일링
- B-1: details-block 템플릿 신규 (CSS 변수만 사용)
- B-2: 인쇄 시 details 자동 펼침 JS
- B-3: catalog에 details-block 등록
- B-4/B-5: images[]/tables[] 상세 판단 + fallback 3곳 동기화
- B-8: fallback card-grid → topic-header + char_guide 제거
- C-1: CLAUDE.md gradient 원칙 완화
- C-3: border-radius 9개 파일 var(--radius) 통일
- C-4: box-shadow 2레벨 → 1레벨
- D-0: 이미지 경로 입력 UI + API base_path
- D-1: Pillow 의존성 + image_utils.py
- D-2~D-4: 이미지 비율/축소방지 프롬프트 전달
- D-5: HTML에 이미지 base64 삽입

## Phase 2 (다른 Claude 작업)
- P2-A: FAISS 블록 검색 (bge-m3, 46개 블록)
- P2-B: SVG N개 자동 배치 (svg_calculator.py)
- P2-C: Opus 블록 추천 (Kei API 경유)
- P2-D: 5단계 재검토 루프 강화 (MAX_REVIEW_ROUNDS=2)
- P2-E: details-block fallback 연동

## 버그 수정 (BF-8~10)
- BF-8: 컨테이너 예산 기반 블록 배치
- BF-9: grid와 Sonnet 역할 분리
- BF-10: catalog mtime 캐시 자동 갱신

## 블록 라이브러리
- 46개 블록 (6 카테고리), catalog/BLOCK_SLOTS/INDEX 동기화
- 구 블록 제거 (quote-block, card-grid, comparison)
- 13개 _legacy 블록 보존

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:40:20 +09:00

163 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""블록 라이브러리 FAISS 인덱스 빌드 스크립트.
catalog.yaml의 46개 블록을 임베딩하여 FAISS 인덱스를 생성한다.
블록 추가/수정 시 이 스크립트를 다시 실행하면 인덱스가 갱신된다.
사용법:
python scripts/build_block_index.py
산출물:
data/block_index.faiss — FAISS 벡터 인덱스
data/block_metadata.json — 인덱스 순서 → 블록 매핑
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
import faiss
import numpy as np
import yaml
from sentence_transformers import SentenceTransformer
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
# Kei persona와 동일 모델 (1024차원, 한국어 최적화)
EMBEDDING_MODEL = "BAAI/bge-m3"
PROJECT_ROOT = Path(__file__).parent.parent
CATALOG_PATH = PROJECT_ROOT / "templates" / "catalog.yaml"
INDEX_PATH = PROJECT_ROOT / "data" / "block_index.faiss"
META_PATH = PROJECT_ROOT / "data" / "block_metadata.json"
def load_catalog() -> list[dict]:
"""catalog.yaml에서 블록 목록을 로드한다."""
if not CATALOG_PATH.exists():
logger.error(f"catalog.yaml 없음: {CATALOG_PATH}")
sys.exit(1)
with open(CATALOG_PATH, encoding="utf-8") as f:
data = yaml.safe_load(f)
blocks = data.get("blocks", [])
if not blocks:
logger.error("catalog.yaml에 블록이 없습니다.")
sys.exit(1)
logger.info(f"catalog 로드: {len(blocks)}개 블록")
return blocks
def build_search_texts(blocks: list[dict]) -> list[str]:
"""각 블록의 검색용 텍스트를 생성한다.
name + visual + when을 조합하여 검색 쿼리와 매칭되도록 한다.
not_for는 네거티브이므로 검색 텍스트에 포함하지 않는다.
"""
texts = []
for block in blocks:
parts = [
block.get("name", ""),
block.get("visual", ""),
block.get("when", ""),
]
text = ". ".join(p.strip() for p in parts if p.strip())
texts.append(text)
return texts
def build_index(texts: list[str]) -> tuple[faiss.IndexFlatIP, np.ndarray]:
"""텍스트를 임베딩하고 FAISS 인덱스를 생성한다."""
logger.info(f"임베딩 모델 로딩: {EMBEDDING_MODEL}")
model = SentenceTransformer(EMBEDDING_MODEL)
logger.info(f"{len(texts)}개 텍스트 임베딩 중...")
embeddings = model.encode(
texts,
normalize_embeddings=True, # 코사인 유사도를 위해 정규화
show_progress_bar=True,
)
embeddings = np.array(embeddings, dtype=np.float32)
dim = embeddings.shape[1]
logger.info(f"임베딩 완료: {embeddings.shape[0]}× {dim}차원")
# Inner Product = 정규화된 벡터에서 코사인 유사도
index = faiss.IndexFlatIP(dim)
index.add(embeddings)
logger.info(f"FAISS 인덱스 생성: {index.ntotal}개 벡터, {dim}차원")
return index, embeddings
def save_metadata(blocks: list[dict]) -> None:
"""블록 메타데이터를 인덱스 순서대로 저장한다."""
metadata = []
for block in blocks:
metadata.append({
"id": block["id"],
"name": block.get("name", ""),
"template": block.get("template", ""),
"category": block.get("template", "").split("/")[1] if "/" in block.get("template", "") else "",
"height_cost": block.get("height_cost", "medium"),
"visual": block.get("visual", ""),
"when": block.get("when", ""),
"not_for": block.get("not_for", ""),
})
META_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(META_PATH, "w", encoding="utf-8") as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
logger.info(f"메타데이터 저장: {META_PATH} ({len(metadata)}개)")
def main():
# 1. catalog 로드
blocks = load_catalog()
# 2. 검색용 텍스트 생성
texts = build_search_texts(blocks)
# 3. 임베딩 + FAISS 인덱스
index, embeddings = build_index(texts)
# 4. 저장
INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
faiss.write_index(index, str(INDEX_PATH))
logger.info(f"FAISS 인덱스 저장: {INDEX_PATH}")
save_metadata(blocks)
# 5. 검증
logger.info("--- 검증 ---")
test_index = faiss.read_index(str(INDEX_PATH))
with open(META_PATH, encoding="utf-8") as f:
test_meta = json.load(f)
assert test_index.ntotal == len(blocks), f"벡터 수 불일치: {test_index.ntotal} vs {len(blocks)}"
assert len(test_meta) == len(blocks), f"메타데이터 수 불일치: {len(test_meta)} vs {len(blocks)}"
logger.info(f"✅ 검증 통과: {test_index.ntotal}개 벡터, {len(test_meta)}개 메타데이터")
# 6. 테스트 검색
model = SentenceTransformer(EMBEDDING_MODEL)
test_queries = [
"A vs B 두 개념 비교",
"연도별 정책 로드맵",
"핵심 수치 KPI 통계",
]
for query in test_queries:
q_emb = model.encode([query], normalize_embeddings=True)
scores, indices = test_index.search(np.array(q_emb, dtype=np.float32), 3)
results = [test_meta[i]["id"] for i in indices[0]]
logger.info(f" 검색 '{query}'{results}")
if __name__ == "__main__":
main()