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:
208
src/block_search.py
Normal file
208
src/block_search.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""P2-A: FAISS 기반 블록 검색 모듈.
|
||||
|
||||
catalog.yaml 46개 블록 중 콘텐츠에 적합한 후보를 검색하여 반환한다.
|
||||
디자인 팀장(Step B)의 프롬프트에 전체 catalog 대신 관련 블록만 전달하기 위함.
|
||||
|
||||
사용법:
|
||||
from src.block_search import search_blocks_for_topics
|
||||
candidates = search_blocks_for_topics(topics, top_k=8)
|
||||
# → catalog.yaml 형식의 문자열 (팀장 프롬프트에 삽입)
|
||||
|
||||
fallback: 인덱스 없거나 검색 실패 시 → catalog.yaml 전문 반환 (기존 방식)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
INDEX_PATH = PROJECT_ROOT / "data" / "block_index.faiss"
|
||||
META_PATH = PROJECT_ROOT / "data" / "block_metadata.json"
|
||||
CATALOG_PATH = PROJECT_ROOT / "templates" / "catalog.yaml"
|
||||
|
||||
# Kei persona와 동일 모델
|
||||
EMBEDDING_MODEL = "BAAI/bge-m3"
|
||||
|
||||
# 카테고리 목록 (최소 1개 보장용)
|
||||
ALL_CATEGORIES = ["headers", "cards", "tables", "visuals", "emphasis", "media"]
|
||||
|
||||
# Lazy load
|
||||
_index = None
|
||||
_metadata: list[dict] | None = None
|
||||
_model = None
|
||||
_loaded = False
|
||||
|
||||
|
||||
def _ensure_loaded() -> bool:
|
||||
"""인덱스 + 모델을 lazy load한다. 성공 시 True."""
|
||||
global _index, _metadata, _model, _loaded
|
||||
|
||||
if _loaded:
|
||||
return _index is not None
|
||||
|
||||
_loaded = True # 재시도 방지
|
||||
|
||||
if not INDEX_PATH.exists() or not META_PATH.exists():
|
||||
logger.warning(
|
||||
f"블록 인덱스 없음: {INDEX_PATH}. "
|
||||
f"scripts/build_block_index.py를 실행하세요. "
|
||||
f"catalog 전문 fallback 사용."
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import faiss
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
logger.info(f"블록 인덱스 로딩: {INDEX_PATH}")
|
||||
_index = faiss.read_index(str(INDEX_PATH))
|
||||
|
||||
with open(META_PATH, encoding="utf-8") as f:
|
||||
_metadata = json.load(f)
|
||||
|
||||
logger.info(f"임베딩 모델 로딩: {EMBEDDING_MODEL}")
|
||||
_model = SentenceTransformer(EMBEDDING_MODEL)
|
||||
|
||||
logger.info(
|
||||
f"블록 검색 준비 완료: {_index.ntotal}개 벡터, "
|
||||
f"{len(_metadata)}개 메타데이터"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"블록 인덱스 로드 실패: {e}. catalog 전문 fallback.")
|
||||
_index = None
|
||||
_metadata = None
|
||||
_model = None
|
||||
return False
|
||||
|
||||
|
||||
def search_blocks(query: str, top_k: int = 8) -> list[dict]:
|
||||
"""단일 쿼리로 관련 블록을 검색한다.
|
||||
|
||||
Args:
|
||||
query: 검색 쿼리 (꼭지 제목+요약+역할)
|
||||
top_k: 반환할 최대 블록 수
|
||||
|
||||
Returns:
|
||||
관련 블록 메타데이터 목록 (score 포함)
|
||||
"""
|
||||
if not _ensure_loaded():
|
||||
return []
|
||||
|
||||
q_embedding = _model.encode(
|
||||
[query],
|
||||
normalize_embeddings=True,
|
||||
)
|
||||
q_embedding = np.array(q_embedding, dtype=np.float32)
|
||||
|
||||
scores, indices = _index.search(q_embedding, min(top_k, _index.ntotal))
|
||||
|
||||
results = []
|
||||
for score, idx in zip(scores[0], indices[0]):
|
||||
if idx < 0 or idx >= len(_metadata):
|
||||
continue
|
||||
block = dict(_metadata[idx])
|
||||
block["search_score"] = float(score)
|
||||
results.append(block)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search_blocks_for_topics(
|
||||
topics: list[dict],
|
||||
top_k_per_topic: int = 3,
|
||||
total_max: int = 10,
|
||||
) -> str:
|
||||
"""여러 꼭지에 대해 검색하고, 중복 제거 + 카테고리 보장 후 문자열로 반환.
|
||||
|
||||
Args:
|
||||
topics: 1단계 실장의 꼭지 분석 결과
|
||||
top_k_per_topic: 꼭지당 검색 수
|
||||
total_max: 최종 반환 최대 수
|
||||
|
||||
Returns:
|
||||
catalog.yaml 형식의 문자열 (팀장 프롬프트에 삽입)
|
||||
검색 실패 시 catalog.yaml 전문 반환 (fallback)
|
||||
"""
|
||||
if not _ensure_loaded():
|
||||
return _fallback_full_catalog()
|
||||
|
||||
# 1. 꼭지별 검색
|
||||
all_results: dict[str, dict] = {} # id → block (중복 제거)
|
||||
for topic in topics:
|
||||
query = _build_query(topic)
|
||||
results = search_blocks(query, top_k=top_k_per_topic)
|
||||
for block in results:
|
||||
bid = block["id"]
|
||||
if bid not in all_results:
|
||||
all_results[bid] = block
|
||||
else:
|
||||
# 이미 있으면 더 높은 점수로 업데이트
|
||||
if block["search_score"] > all_results[bid]["search_score"]:
|
||||
all_results[bid] = block
|
||||
|
||||
# 2. 카테고리별 최소 1개 보장
|
||||
found_categories = {b.get("category", "") for b in all_results.values()}
|
||||
missing_categories = set(ALL_CATEGORIES) - found_categories
|
||||
|
||||
if missing_categories and _metadata:
|
||||
for block in _metadata:
|
||||
cat = block.get("category", "")
|
||||
if cat in missing_categories:
|
||||
if block["id"] not in all_results:
|
||||
block_copy = dict(block)
|
||||
block_copy["search_score"] = 0.0 # 카테고리 보장용
|
||||
all_results[block["id"]] = block_copy
|
||||
missing_categories.discard(cat)
|
||||
if not missing_categories:
|
||||
break
|
||||
|
||||
# 3. 점수순 정렬 + 최대 개수 제한
|
||||
sorted_blocks = sorted(
|
||||
all_results.values(),
|
||||
key=lambda b: b.get("search_score", 0),
|
||||
reverse=True,
|
||||
)[:total_max]
|
||||
|
||||
# 4. 프롬프트용 문자열 생성
|
||||
return _format_for_prompt(sorted_blocks)
|
||||
|
||||
|
||||
def _build_query(topic: dict) -> str:
|
||||
"""꼭지 정보에서 검색 쿼리를 생성한다."""
|
||||
parts = [
|
||||
topic.get("title", ""),
|
||||
topic.get("summary", ""),
|
||||
f"역할: {topic.get('role', 'flow')}",
|
||||
f"레이어: {topic.get('layer', 'core')}",
|
||||
]
|
||||
if topic.get("content_type"):
|
||||
parts.append(f"콘텐츠: {topic['content_type']}")
|
||||
return ". ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _format_for_prompt(blocks: list[dict]) -> str:
|
||||
"""블록 목록을 팀장 프롬프트에 삽입할 문자열로 변환."""
|
||||
lines = [f"# 관련 블록 후보 ({len(blocks)}개)\n"]
|
||||
for block in blocks:
|
||||
lines.append(f"- **{block['id']}** ({block.get('name', '')})")
|
||||
lines.append(f" 시각: {block.get('visual', '')}")
|
||||
lines.append(f" 사용: {block.get('when', '').strip()}")
|
||||
lines.append(f" 금지: {block.get('not_for', '').strip()}")
|
||||
lines.append(f" 높이: {block.get('height_cost', 'medium')}")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fallback_full_catalog() -> str:
|
||||
"""검색 실패 시 catalog.yaml 전문을 반환한다 (기존 방식)."""
|
||||
if CATALOG_PATH.exists():
|
||||
return CATALOG_PATH.read_text(encoding="utf-8")
|
||||
return "사용 가능한 블록 없음."
|
||||
Reference in New Issue
Block a user