IMPROVEMENT Phase A~D + Phase 2 전체 반영
## IMPROVEMENT (Phase A~D) - A-1: 4단계 Sonnet 디자인 조정 (_adjust_design) — CSS 변수 cascade - A-2: 5단계 HTML 전문 프롬프트 전달 - A-3: shrink/expand 하드코딩 제거 → Sonnet target_ratio 기반 - A-4: rewrite action 구현 - A-5: overflow: visible (area 레벨 텍스트 잘림 방지) - A-6: object-fit cover → contain (이미지 crop 방지) - A-7: table-layout: fixed - A-8: container query 폰트 스케일링 - B-1: details-block 템플릿 신규 (CSS 변수만 사용) - B-2: 인쇄 시 details 자동 펼침 JS - B-3: catalog에 details-block 등록 - B-4/B-5: images[]/tables[] 상세 판단 + fallback 3곳 동기화 - B-8: fallback card-grid → topic-header + char_guide 제거 - C-1: CLAUDE.md gradient 원칙 완화 - C-3: border-radius 9개 파일 var(--radius) 통일 - C-4: box-shadow 2레벨 → 1레벨 - D-0: 이미지 경로 입력 UI + API base_path - D-1: Pillow 의존성 + image_utils.py - D-2~D-4: 이미지 비율/축소방지 프롬프트 전달 - D-5: HTML에 이미지 base64 삽입 ## Phase 2 (다른 Claude 작업) - P2-A: FAISS 블록 검색 (bge-m3, 46개 블록) - P2-B: SVG N개 자동 배치 (svg_calculator.py) - P2-C: Opus 블록 추천 (Kei API 경유) - P2-D: 5단계 재검토 루프 강화 (MAX_REVIEW_ROUNDS=2) - P2-E: details-block fallback 연동 ## 버그 수정 (BF-8~10) - BF-8: 컨테이너 예산 기반 블록 배치 - BF-9: grid와 Sonnet 역할 분리 - BF-10: catalog mtime 캐시 자동 갱신 ## 블록 라이브러리 - 46개 블록 (6 카테고리), catalog/BLOCK_SLOTS/INDEX 동기화 - 구 블록 제거 (quote-block, card-grid, comparison) - 13개 _legacy 블록 보존 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
162
scripts/build_block_index.py
Normal file
162
scripts/build_block_index.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""블록 라이브러리 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()
|
||||
Reference in New Issue
Block a user