feat(search): Phase 0.5 RRF fusion + 강한 신호 boost
기존 weighted-sum merge를 Reciprocal Rank Fusion으로 교체. 정확 키워드 매치에서 RRF가 평탄화되는 문제는 boost로 보완. 신규 모듈 app/services/search_fusion.py: - FusionStrategy ABC - LegacyWeightedSum : 기존 _merge_results 동작 (A/B 비교용) - RRFOnly : 순수 RRF, k=60 - RRFWithBoost : RRF + title/tags/법령조문/high-text-score boost (default) - normalize_display_scores: SearchResult.score를 [0..1] 랭크 기반 정규화 (프론트엔드가 score*100을 % 표시하므로 RRF 원본 점수 노출 시 표시 깨짐) search.py: - ?fusion=legacy|rrf|rrf_boost 파라미터 (default rrf_boost) - _merge_results 제거 (LegacyWeightedSum에 흡수) - pre-fusion confidence: hybrid는 raw text/vector 신호로 계산 (fused score는 fusion 전략마다 스케일이 달라 일관 비교 불가) - timing에 fusion_ms 추가 - debug notes에 fusion 전략 표시 telemetry: - compute_confidence_hybrid(text_results, vector_results) 헬퍼 - record_search_event에 confidence override 파라미터 run_eval.py: - --fusion CLI 옵션, call_search 쿼리 파라미터에 전달 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,12 @@ from core.auth import get_current_user
|
||||
from core.database import get_session
|
||||
from core.utils import setup_logger
|
||||
from models.user import User
|
||||
from services.search_telemetry import compute_confidence, record_search_event
|
||||
from services.search_fusion import DEFAULT_FUSION, get_strategy, normalize_display_scores
|
||||
from services.search_telemetry import (
|
||||
compute_confidence,
|
||||
compute_confidence_hybrid,
|
||||
record_search_event,
|
||||
)
|
||||
|
||||
# logs/search.log + stdout 동시 출력 (Phase 0.4)
|
||||
logger = setup_logger("search")
|
||||
@@ -80,9 +85,14 @@ async def search(
|
||||
background_tasks: BackgroundTasks,
|
||||
mode: str = Query("hybrid", pattern="^(fts|trgm|vector|hybrid)$"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
fusion: str = Query(
|
||||
DEFAULT_FUSION,
|
||||
pattern="^(legacy|rrf|rrf_boost)$",
|
||||
description="hybrid 모드 fusion 전략 (legacy=기존 가중합, rrf=RRF k=60, rrf_boost=RRF+강한신호 boost)",
|
||||
),
|
||||
debug: bool = Query(False, description="단계별 candidates + timing 응답에 포함"),
|
||||
):
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합"""
|
||||
"""문서 검색 — FTS + ILIKE + 벡터 결합 (Phase 0.5: RRF fusion)"""
|
||||
timing: dict[str, float] = {}
|
||||
notes: list[str] = []
|
||||
text_results: list[SearchResult] = []
|
||||
@@ -110,22 +120,39 @@ async def search(
|
||||
notes.append("vector_search_returned_empty — text-only fallback")
|
||||
|
||||
t2 = time.perf_counter()
|
||||
results = _merge_results(text_results, vector_results, limit)
|
||||
timing["merge_ms"] = (time.perf_counter() - t2) * 1000
|
||||
strategy = get_strategy(fusion)
|
||||
results = strategy.fuse(text_results, vector_results, q, limit)
|
||||
timing["fusion_ms"] = (time.perf_counter() - t2) * 1000
|
||||
notes.append(f"fusion={strategy.name}")
|
||||
else:
|
||||
results = text_results
|
||||
|
||||
# display score 정규화 — 프론트엔드는 score*100을 % 표시.
|
||||
# fusion 내부 score(RRF는 0.01~0.05 범위)를 그대로 노출하면 표시가 깨짐.
|
||||
normalize_display_scores(results)
|
||||
|
||||
timing["total_ms"] = (time.perf_counter() - t_total) * 1000
|
||||
|
||||
# confidence는 fusion 적용 전 raw 신호로 계산 (Phase 0.5 이후 fused score는 절대값 의미 없음)
|
||||
if mode == "hybrid":
|
||||
confidence_signal = compute_confidence_hybrid(text_results, vector_results)
|
||||
elif mode == "vector":
|
||||
confidence_signal = compute_confidence(vector_results, "vector")
|
||||
else:
|
||||
confidence_signal = compute_confidence(text_results, mode)
|
||||
|
||||
# 사용자 feedback: 모든 단계 timing은 debug 응답과 별도로 항상 로그로 남긴다
|
||||
timing_str = " ".join(f"{k}={v:.0f}" for k, v in timing.items())
|
||||
fusion_str = f" fusion={fusion}" if mode == "hybrid" else ""
|
||||
logger.info(
|
||||
"search query=%r mode=%s results=%d %s",
|
||||
q[:80], mode, len(results), timing_str,
|
||||
"search query=%r mode=%s%s results=%d conf=%.2f %s",
|
||||
q[:80], mode, fusion_str, len(results), confidence_signal, timing_str,
|
||||
)
|
||||
|
||||
# Phase 0.3: 실패 자동 로깅 (응답 latency에 영향 X — background task)
|
||||
background_tasks.add_task(record_search_event, q, user.id, results, mode)
|
||||
background_tasks.add_task(
|
||||
record_search_event, q, user.id, results, mode, confidence_signal
|
||||
)
|
||||
|
||||
debug_obj: SearchDebug | None = None
|
||||
if debug:
|
||||
@@ -134,7 +161,7 @@ async def search(
|
||||
text_candidates=_to_debug_candidates(text_results) if text_results or mode != "vector" else None,
|
||||
vector_candidates=_to_debug_candidates(vector_results) if vector_results or mode in ("vector", "hybrid") else None,
|
||||
fused_candidates=_to_debug_candidates(results) if mode == "hybrid" else None,
|
||||
confidence=compute_confidence(results, mode),
|
||||
confidence=confidence_signal,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
@@ -221,33 +248,3 @@ async def _search_vector(session: AsyncSession, query: str, limit: int) -> list[
|
||||
return [SearchResult(**row._mapping) for row in result]
|
||||
|
||||
|
||||
def _merge_results(
|
||||
text_results: list[SearchResult],
|
||||
vector_results: list[SearchResult],
|
||||
limit: int,
|
||||
) -> list[SearchResult]:
|
||||
"""텍스트 + 벡터 결과 합산 (중복 제거, 점수 합산)"""
|
||||
merged: dict[int, SearchResult] = {}
|
||||
|
||||
for r in text_results:
|
||||
merged[r.id] = r
|
||||
|
||||
for r in vector_results:
|
||||
if r.id in merged:
|
||||
# 이미 텍스트로 잡힌 문서 — 벡터 점수 가산
|
||||
existing = merged[r.id]
|
||||
merged[r.id] = SearchResult(
|
||||
id=existing.id,
|
||||
title=existing.title,
|
||||
ai_domain=existing.ai_domain,
|
||||
ai_summary=existing.ai_summary,
|
||||
file_format=existing.file_format,
|
||||
score=existing.score + r.score * 0.5,
|
||||
snippet=existing.snippet,
|
||||
match_reason=f"{existing.match_reason}+vector",
|
||||
)
|
||||
elif r.score > 0.3: # 벡터 유사도 최소 threshold
|
||||
merged[r.id] = r
|
||||
|
||||
results = sorted(merged.values(), key=lambda x: x.score, reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
239
app/services/search_fusion.py
Normal file
239
app/services/search_fusion.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""검색 결과 fusion 전략 (Phase 0.5)
|
||||
|
||||
기존 가중합 → Reciprocal Rank Fusion 기본 + 강한 시그널 boost.
|
||||
|
||||
전략 비교:
|
||||
- LegacyWeightedSum : 기존 _merge_results (text 가중치 + 0.5*벡터 합산). A/B 비교용.
|
||||
- RRFOnly : 순수 RRF, k=60. 안정적이지만 강한 키워드 신호 약화 가능.
|
||||
- RRFWithBoost : RRF + 강한 시그널 boost (title/tags/법령조문/high text score).
|
||||
정확 키워드 케이스에서 RRF 한계를 보완. **default**.
|
||||
|
||||
fuse() 결과의 .score는 fusion 내부 점수(RRF는 1/60 단위로 작음).
|
||||
사용자에게 노출되는 SearchResult.score는 search.py에서 normalize_display_scores로
|
||||
[0..1] 랭크 기반 정규화 후 반환된다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.search import SearchResult
|
||||
|
||||
|
||||
# ─── 추상 인터페이스 ─────────────────────────────────────
|
||||
|
||||
|
||||
class FusionStrategy(ABC):
|
||||
name: str = "abstract"
|
||||
|
||||
@abstractmethod
|
||||
def fuse(
|
||||
self,
|
||||
text_results: list["SearchResult"],
|
||||
vector_results: list["SearchResult"],
|
||||
query: str,
|
||||
limit: int,
|
||||
) -> list["SearchResult"]:
|
||||
...
|
||||
|
||||
|
||||
# ─── 1) 기존 가중합 (legacy) ─────────────────────────────
|
||||
|
||||
|
||||
class LegacyWeightedSum(FusionStrategy):
|
||||
"""기존 _merge_results 동작.
|
||||
|
||||
텍스트 점수에 벡터 cosine * 0.5 가산. 벡터 단독 결과는 cosine > 0.3만 채택.
|
||||
Phase 0.5 RRF로 교체 전 baseline. A/B 비교용으로 보존.
|
||||
"""
|
||||
|
||||
name = "legacy"
|
||||
|
||||
def fuse(self, text_results, vector_results, query, limit):
|
||||
from api.search import SearchResult # 순환 import 회피
|
||||
|
||||
merged: dict[int, SearchResult] = {}
|
||||
|
||||
for r in text_results:
|
||||
merged[r.id] = r
|
||||
|
||||
for r in vector_results:
|
||||
if r.id in merged:
|
||||
existing = merged[r.id]
|
||||
merged[r.id] = SearchResult(
|
||||
id=existing.id,
|
||||
title=existing.title,
|
||||
ai_domain=existing.ai_domain,
|
||||
ai_summary=existing.ai_summary,
|
||||
file_format=existing.file_format,
|
||||
score=existing.score + r.score * 0.5,
|
||||
snippet=existing.snippet,
|
||||
match_reason=f"{existing.match_reason}+vector",
|
||||
)
|
||||
elif r.score > 0.3:
|
||||
merged[r.id] = r
|
||||
|
||||
ordered = sorted(merged.values(), key=lambda x: x.score, reverse=True)
|
||||
return ordered[:limit]
|
||||
|
||||
|
||||
# ─── 2) Reciprocal Rank Fusion ──────────────────────────
|
||||
|
||||
|
||||
class RRFOnly(FusionStrategy):
|
||||
"""순수 RRF.
|
||||
|
||||
RRF_score(doc) = Σ (1 / (k + rank_i))
|
||||
k=60 (TREC 표준값). 점수 절대값을 무시하고 랭크만 사용 → 다른 retriever 간
|
||||
스케일 차이에 강하지만, FTS의 압도적 신호도 평탄화되는 단점.
|
||||
"""
|
||||
|
||||
name = "rrf"
|
||||
K = 60
|
||||
|
||||
def fuse(self, text_results, vector_results, query, limit):
|
||||
from api.search import SearchResult
|
||||
|
||||
scores: dict[int, float] = {}
|
||||
sources: dict[int, dict[str, SearchResult]] = {}
|
||||
|
||||
for rank, r in enumerate(text_results, start=1):
|
||||
scores[r.id] = scores.get(r.id, 0.0) + 1.0 / (self.K + rank)
|
||||
sources.setdefault(r.id, {})["text"] = r
|
||||
|
||||
for rank, r in enumerate(vector_results, start=1):
|
||||
scores[r.id] = scores.get(r.id, 0.0) + 1.0 / (self.K + rank)
|
||||
sources.setdefault(r.id, {})["vector"] = r
|
||||
|
||||
merged: list[SearchResult] = []
|
||||
for doc_id, rrf_score in sorted(scores.items(), key=lambda kv: -kv[1]):
|
||||
srcs = sources[doc_id]
|
||||
base = srcs.get("text") or srcs.get("vector")
|
||||
assert base is not None
|
||||
reasons: list[str] = []
|
||||
if "text" in srcs:
|
||||
reasons.append(srcs["text"].match_reason or "text")
|
||||
if "vector" in srcs:
|
||||
reasons.append("vector")
|
||||
merged.append(
|
||||
SearchResult(
|
||||
id=base.id,
|
||||
title=base.title,
|
||||
ai_domain=base.ai_domain,
|
||||
ai_summary=base.ai_summary,
|
||||
file_format=base.file_format,
|
||||
score=rrf_score,
|
||||
snippet=base.snippet,
|
||||
match_reason="+".join(reasons),
|
||||
)
|
||||
)
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
# ─── 3) RRF + 강한 시그널 boost ─────────────────────────
|
||||
|
||||
|
||||
class RRFWithBoost(RRFOnly):
|
||||
"""RRF + 강한 시그널 boost.
|
||||
|
||||
RRF의 점수 평탄화를 보완하기 위해 다음 케이스에 score를 추가 가산:
|
||||
- title 정확 substring 매치 : +0.020
|
||||
- tags 매치 : +0.015
|
||||
- 법령 조문 정확 매치(예 제80조): +0.050 (가장 강한 override)
|
||||
- text score >= 5.0 : +0.010
|
||||
|
||||
Boost 크기는 의도적으로 적당히. RRF의 안정성은 유지하되 강한 신호는 끌어올림.
|
||||
Phase 0.5 default 전략.
|
||||
"""
|
||||
|
||||
name = "rrf_boost"
|
||||
|
||||
BOOST_TITLE = 0.020
|
||||
BOOST_TAGS = 0.015
|
||||
BOOST_LEGAL_ARTICLE = 0.050
|
||||
BOOST_HIGH_TEXT_SCORE = 0.010
|
||||
|
||||
LEGAL_ARTICLE_RE = re.compile(r"제\s*\d+\s*조")
|
||||
HIGH_TEXT_SCORE_THRESHOLD = 5.0
|
||||
|
||||
def fuse(self, text_results, vector_results, query, limit):
|
||||
# 일단 RRF로 후보 충분히 확보 (boost 후 재정렬되도록 limit 넓게)
|
||||
candidates = super().fuse(text_results, vector_results, query, max(limit * 3, 30))
|
||||
|
||||
# 원본 text 신호 lookup
|
||||
text_score_by_id = {r.id: r.score for r in text_results}
|
||||
text_reason_by_id = {r.id: (r.match_reason or "") for r in text_results}
|
||||
|
||||
# 쿼리에 법령 조문이 있으면 그 조문 추출
|
||||
legal_articles_in_query = set(
|
||||
re.sub(r"\s+", "", a) for a in self.LEGAL_ARTICLE_RE.findall(query)
|
||||
)
|
||||
|
||||
for result in candidates:
|
||||
boost = 0.0
|
||||
text_reason = text_reason_by_id.get(result.id, "")
|
||||
|
||||
if "title" in text_reason:
|
||||
boost += self.BOOST_TITLE
|
||||
elif "tags" in text_reason:
|
||||
boost += self.BOOST_TAGS
|
||||
|
||||
if text_score_by_id.get(result.id, 0.0) >= self.HIGH_TEXT_SCORE_THRESHOLD:
|
||||
boost += self.BOOST_HIGH_TEXT_SCORE
|
||||
|
||||
if legal_articles_in_query and result.title:
|
||||
title_articles = set(
|
||||
re.sub(r"\s+", "", a)
|
||||
for a in self.LEGAL_ARTICLE_RE.findall(result.title)
|
||||
)
|
||||
if legal_articles_in_query & title_articles:
|
||||
boost += self.BOOST_LEGAL_ARTICLE
|
||||
|
||||
if boost > 0:
|
||||
# pydantic v2에서도 mutate 가능
|
||||
result.score = result.score + boost
|
||||
|
||||
candidates.sort(key=lambda r: r.score, reverse=True)
|
||||
return candidates[:limit]
|
||||
|
||||
|
||||
# ─── factory ─────────────────────────────────────────────
|
||||
|
||||
|
||||
_STRATEGIES: dict[str, type[FusionStrategy]] = {
|
||||
"legacy": LegacyWeightedSum,
|
||||
"rrf": RRFOnly,
|
||||
"rrf_boost": RRFWithBoost,
|
||||
}
|
||||
|
||||
DEFAULT_FUSION = "rrf_boost"
|
||||
|
||||
|
||||
def get_strategy(name: str) -> FusionStrategy:
|
||||
cls = _STRATEGIES.get(name)
|
||||
if cls is None:
|
||||
raise ValueError(f"unknown fusion strategy: {name}")
|
||||
return cls()
|
||||
|
||||
|
||||
# ─── display score 정규화 ────────────────────────────────
|
||||
|
||||
|
||||
def normalize_display_scores(results: list["SearchResult"]) -> None:
|
||||
"""SearchResult.score를 [0.05..1.0] 랭크 기반 값으로 in-place 갱신.
|
||||
|
||||
프론트엔드는 score*100을 % 표시하므로 [0..1] 범위가 적절.
|
||||
fusion 내부 score는 상대적 순서만 의미가 있으므로 절대값 노출 없이 랭크만 표시.
|
||||
|
||||
랭크 1 → 1.0 / 랭크 2 → 0.95 / ... / 랭크 20 → 0.05 (균등 분포)
|
||||
"""
|
||||
n = len(results)
|
||||
if n == 0:
|
||||
return
|
||||
for i, r in enumerate(results):
|
||||
# 1.0 → 0.05 사이 균등 분포
|
||||
rank_score = 1.0 - (i / max(n - 1, 1)) * 0.95
|
||||
r.score = round(rank_score, 4)
|
||||
@@ -149,6 +149,22 @@ def _cosine_to_confidence(cosine: float) -> float:
|
||||
return 0.10
|
||||
|
||||
|
||||
def compute_confidence_hybrid(
|
||||
text_results: list[Any],
|
||||
vector_results: list[Any],
|
||||
) -> float:
|
||||
"""hybrid 모드 confidence — fusion 적용 *전*의 raw text/vector 결과로 계산.
|
||||
|
||||
Phase 0.5에서 RRF 도입 후 fused score는 절대값 의미가 사라지므로,
|
||||
원본 retrieval 신호의 더 강한 쪽을 confidence로 채택.
|
||||
"""
|
||||
text_conf = compute_confidence(text_results, "fts") if text_results else 0.0
|
||||
vector_conf = (
|
||||
compute_confidence(vector_results, "vector") if vector_results else 0.0
|
||||
)
|
||||
return max(text_conf, vector_conf)
|
||||
|
||||
|
||||
# ─── 로깅 진입점 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -200,16 +216,22 @@ async def record_search_event(
|
||||
user_id: int | None,
|
||||
results: list[Any],
|
||||
mode: str,
|
||||
confidence: float | None = None,
|
||||
) -> None:
|
||||
"""검색 응답 직후 호출. 실패 트리거에 해당하면 로그 INSERT.
|
||||
|
||||
background task에서 await로 호출. request 세션과 분리.
|
||||
user_id가 None이면 reformulation 추적 + 로깅 모두 스킵 (시스템 호출 등).
|
||||
|
||||
confidence 파라미터:
|
||||
- None이면 results 기준으로 자체 계산 (legacy 호출용).
|
||||
- 명시적으로 전달되면 그 값 사용 (Phase 0.5+: fusion 적용 전 raw 신호 기준).
|
||||
"""
|
||||
if user_id is None:
|
||||
return
|
||||
|
||||
confidence = compute_confidence(results, mode)
|
||||
if confidence is None:
|
||||
confidence = compute_confidence(results, mode)
|
||||
result_count = len(results)
|
||||
base_ctx = _build_context(results, mode, extra={"confidence": confidence})
|
||||
|
||||
|
||||
452
frontend/src/routes/__styleguide/+page.svelte
Normal file
452
frontend/src/routes/__styleguide/+page.svelte
Normal file
@@ -0,0 +1,452 @@
|
||||
<script lang="ts">
|
||||
// 디자인 시스템 styleguide. dev only — +page.ts 에서 가드.
|
||||
// Phase A 산출물 전체 시각 검증 + Modal stack / Drawer 동작 데모.
|
||||
import {
|
||||
Search,
|
||||
Mail,
|
||||
User,
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Inbox,
|
||||
FileText,
|
||||
Link2,
|
||||
Menu,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||
import Textarea from '$lib/components/ui/Textarea.svelte';
|
||||
import Select from '$lib/components/ui/Select.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
|
||||
import { ui } from '$lib/stores/uiState.svelte';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
|
||||
// ── 폼 데모 상태 ────────────────────────────────────────
|
||||
let textValue = $state('');
|
||||
let textWithError = $state('잘못된값');
|
||||
let textareaValue = $state('');
|
||||
let selectValue = $state('');
|
||||
let tabValue = $state('overview');
|
||||
|
||||
// ── Modal 데모 상태 ─────────────────────────────────────
|
||||
function openSimpleModal() {
|
||||
ui.openModal('demo-simple');
|
||||
}
|
||||
function openNestedModal() {
|
||||
ui.openModal('demo-nested');
|
||||
}
|
||||
function openConfirm() {
|
||||
ui.openModal('demo-confirm');
|
||||
}
|
||||
function handleConfirm() {
|
||||
addToast('success', '확인 처리됨');
|
||||
}
|
||||
|
||||
// ── Drawer 데모 ────────────────────────────────────────
|
||||
function openSidebarDrawer() {
|
||||
ui.openDrawer('sidebar');
|
||||
}
|
||||
function openMetaDrawer() {
|
||||
ui.openDrawer('meta');
|
||||
}
|
||||
|
||||
// ── 색상 토큰 (시각 swatch용) ────────────────────────────
|
||||
const colorTokens = [
|
||||
{ name: 'bg', class: 'bg-bg' },
|
||||
{ name: 'surface', class: 'bg-surface' },
|
||||
{ name: 'surface-hover', class: 'bg-surface-hover' },
|
||||
{ name: 'surface-active', class: 'bg-surface-active' },
|
||||
{ name: 'sidebar', class: 'bg-sidebar' },
|
||||
{ name: 'default (border)', class: 'bg-default' },
|
||||
{ name: 'border-strong', class: 'bg-border-strong' },
|
||||
{ name: 'text', class: 'bg-text' },
|
||||
{ name: 'dim', class: 'bg-dim' },
|
||||
{ name: 'faint', class: 'bg-faint' },
|
||||
{ name: 'accent', class: 'bg-accent' },
|
||||
{ name: 'accent-hover', class: 'bg-accent-hover' },
|
||||
{ name: 'success', class: 'bg-success' },
|
||||
{ name: 'warning', class: 'bg-warning' },
|
||||
{ name: 'error', class: 'bg-error' },
|
||||
];
|
||||
|
||||
const domainTokens = [
|
||||
{ name: 'philosophy', class: 'bg-domain-philosophy' },
|
||||
{ name: 'language', class: 'bg-domain-language' },
|
||||
{ name: 'engineering', class: 'bg-domain-engineering' },
|
||||
{ name: 'safety', class: 'bg-domain-safety' },
|
||||
{ name: 'programming', class: 'bg-domain-programming' },
|
||||
{ name: 'general', class: 'bg-domain-general' },
|
||||
{ name: 'reference', class: 'bg-domain-reference' },
|
||||
];
|
||||
|
||||
const radiusTokens = [
|
||||
{ name: 'sm', class: 'rounded-sm' },
|
||||
{ name: 'md', class: 'rounded-md' },
|
||||
{ name: 'lg', class: 'rounded-lg' },
|
||||
{ name: 'card', class: 'rounded-card' },
|
||||
];
|
||||
|
||||
const selectOptions = [
|
||||
{ value: 'knowledge', label: 'Knowledge' },
|
||||
{ value: 'reference', label: 'Reference' },
|
||||
{ value: 'inbox', label: 'Inbox' },
|
||||
{ value: 'archived', label: 'Archived (disabled)', disabled: true },
|
||||
];
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: '개요' },
|
||||
{ id: 'edit', label: '편집' },
|
||||
{ id: 'history', label: '이력' },
|
||||
{ id: 'disabled', label: '비활성', disabled: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Styleguide — Document Server</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div class="max-w-5xl mx-auto p-6 space-y-12">
|
||||
<header class="border-b border-default pb-4">
|
||||
<h1 class="text-2xl font-bold text-text">디자인 시스템 styleguide</h1>
|
||||
<p class="text-sm text-dim mt-1">
|
||||
Phase A 산출물 — 토큰, 프리미티브, layer 컴포넌트 검증. <span class="text-faint">dev only.</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- ═════ 색상 토큰 ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">색상 토큰</h2>
|
||||
<div class="grid grid-cols-3 md:grid-cols-5 gap-3">
|
||||
{#each colorTokens as t (t.name)}
|
||||
<div class="space-y-1.5">
|
||||
<div class={'h-12 rounded-md border border-default ' + t.class}></div>
|
||||
<p class="text-[10px] text-dim font-mono">{t.name}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h3 class="text-xs font-semibold text-faint uppercase pt-3">domain</h3>
|
||||
<div class="grid grid-cols-3 md:grid-cols-7 gap-3">
|
||||
{#each domainTokens as t (t.name)}
|
||||
<div class="space-y-1.5">
|
||||
<div class={'h-12 rounded-md border border-default ' + t.class}></div>
|
||||
<p class="text-[10px] text-dim font-mono">{t.name}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Radius ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Radius</h2>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
{#each radiusTokens as r (r.name)}
|
||||
<div class="space-y-1.5 text-center">
|
||||
<div class={'w-16 h-16 bg-surface border border-default ' + r.class}></div>
|
||||
<p class="text-[10px] text-dim font-mono">{r.name}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Button ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Button</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs text-faint">variant × size</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="primary" size="sm">sm Primary</Button>
|
||||
<Button variant="secondary" size="sm">sm Secondary</Button>
|
||||
<Button variant="ghost" size="sm">sm Ghost</Button>
|
||||
<Button variant="danger" size="sm">sm Danger</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-faint pt-2">아이콘 + 상태</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="primary" icon={Plus}>새 문서</Button>
|
||||
<Button variant="secondary" icon={Save}>저장</Button>
|
||||
<Button variant="secondary" icon={ExternalLink} iconPosition="right">새 탭에서 열기</Button>
|
||||
<Button variant="primary" loading>저장 중…</Button>
|
||||
<Button variant="secondary" disabled>비활성</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-faint pt-2">href (자동 a 변환)</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="ghost" href="/" icon={ExternalLink}>홈으로</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ IconButton ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">IconButton</h2>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<IconButton icon={Menu} aria-label="메뉴" variant="ghost" />
|
||||
<IconButton icon={Search} aria-label="검색" variant="ghost" />
|
||||
<IconButton icon={Settings} aria-label="설정" variant="secondary" />
|
||||
<IconButton icon={Save} aria-label="저장" variant="primary" />
|
||||
<IconButton icon={Trash2} aria-label="삭제" variant="danger" />
|
||||
<IconButton icon={Save} aria-label="저장 중" variant="primary" loading />
|
||||
<IconButton icon={Trash2} aria-label="비활성" variant="ghost" disabled />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Card ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Card</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<p class="text-sm font-medium text-text">기본 padded</p>
|
||||
<p class="text-xs text-dim mt-1">bg-surface + rounded-card + border-default</p>
|
||||
</Card>
|
||||
<Card padded={false}>
|
||||
<div class="p-3">
|
||||
<p class="text-sm font-medium text-text">padded={false}</p>
|
||||
<p class="text-xs text-dim mt-1">내부에서 직접 padding 제어</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card interactive onclick={() => addToast('info', 'Card 클릭됨')}>
|
||||
<p class="text-sm font-medium text-text">interactive</p>
|
||||
<p class="text-xs text-dim mt-1">클릭 시 hover + button 시맨틱</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Badge ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Badge</h2>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<Badge tone="neutral">neutral</Badge>
|
||||
<Badge tone="success">success</Badge>
|
||||
<Badge tone="warning">warning</Badge>
|
||||
<Badge tone="error">error</Badge>
|
||||
<Badge tone="accent">accent</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<Badge tone="neutral" size="sm">sm neutral</Badge>
|
||||
<Badge tone="success" size="sm">완료</Badge>
|
||||
<Badge tone="warning" size="sm">대기</Badge>
|
||||
<Badge tone="error" size="sm">실패</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Skeleton ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Skeleton</h2>
|
||||
<div class="space-y-2">
|
||||
<Skeleton w="w-1/3" h="h-3" />
|
||||
<Skeleton w="w-2/3" h="h-4" />
|
||||
<Skeleton w="w-full" h="h-20" rounded="card" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Skeleton w="w-12" h="h-12" rounded="full" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton h="h-3" w="w-1/2" />
|
||||
<Skeleton h="h-3" w="w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ EmptyState ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">EmptyState</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card padded={false}>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Inbox가 비어 있습니다"
|
||||
description="새 파일이 들어오면 자동으로 표시됩니다."
|
||||
/>
|
||||
</Card>
|
||||
<Card padded={false}>
|
||||
<EmptyState
|
||||
icon={Link2}
|
||||
title="추후 지원"
|
||||
description="벡터 유사도 기반 추천은 백엔드 API 추가 후 활성화됩니다."
|
||||
>
|
||||
<Button variant="secondary" size="sm" icon={ExternalLink}>관련 이슈 보기</Button>
|
||||
</EmptyState>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ TextInput / Textarea / Select ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">입력 프리미티브</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TextInput
|
||||
label="제목"
|
||||
placeholder="문서 제목 입력"
|
||||
hint="여기에 도움말이 들어갑니다."
|
||||
bind:value={textValue}
|
||||
/>
|
||||
<TextInput
|
||||
label="이메일"
|
||||
type="email"
|
||||
leadingIcon={Mail}
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<TextInput
|
||||
label="검색어"
|
||||
placeholder="키워드"
|
||||
leadingIcon={Search}
|
||||
trailingIcon={ExternalLink}
|
||||
/>
|
||||
<TextInput label="에러 상태" bind:value={textWithError} error="값이 유효하지 않습니다." />
|
||||
|
||||
<TextInput label="비활성" placeholder="readonly + disabled" disabled value="고정값" />
|
||||
<TextInput label="필수" required placeholder="반드시 입력" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Textarea
|
||||
label="메모"
|
||||
placeholder="자유 형식 메모"
|
||||
rows={3}
|
||||
bind:value={textareaValue}
|
||||
hint="autoGrow={'{false}'} 기본"
|
||||
/>
|
||||
<Textarea label="autoGrow" placeholder="입력하면 늘어남" autoGrow maxRows={6} />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Select
|
||||
label="도메인"
|
||||
options={selectOptions}
|
||||
placeholder="선택…"
|
||||
bind:value={selectValue}
|
||||
/>
|
||||
<Select label="에러" options={selectOptions} error="필수 항목" />
|
||||
<Select label="비활성" options={selectOptions} disabled value="knowledge" />
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-faint">
|
||||
현재 값:
|
||||
<code class="text-text">{JSON.stringify({ textValue, textareaValue, selectValue })}</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Tabs ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Tabs</h2>
|
||||
<Card padded={false}>
|
||||
<Tabs {tabs} bind:value={tabValue}>
|
||||
{#snippet children(activeId)}
|
||||
<div class="p-5">
|
||||
{#if activeId === 'overview'}
|
||||
<p class="text-sm text-text">📋 개요 패널 — 좌우 화살표 키로 탭 이동.</p>
|
||||
{:else if activeId === 'edit'}
|
||||
<p class="text-sm text-text">✏️ 편집 패널.</p>
|
||||
{:else if activeId === 'history'}
|
||||
<p class="text-sm text-text">🕓 이력 패널.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Drawer ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Drawer (단일 slot)</h2>
|
||||
<p class="text-xs text-faint">
|
||||
둘 다 열어 보세요. sidebar 열려 있을 때 meta를 누르면 sidebar가 자동으로 닫힙니다.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" icon={Menu} onclick={openSidebarDrawer}>Sidebar drawer</Button>
|
||||
<Button variant="secondary" icon={FileText} onclick={openMetaDrawer}>Meta drawer (오른쪽)</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Modal ═════ -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Modal (stack 지원)</h2>
|
||||
<p class="text-xs text-faint">
|
||||
Simple modal 안에서 "또 열기"를 누르면 nested modal이 위에 쌓입니다. Esc는 가장 위 modal부터 닫습니다.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="primary" onclick={openSimpleModal}>Simple Modal 열기</Button>
|
||||
<Button variant="danger" icon={Trash2} onclick={openConfirm}>ConfirmDialog 열기</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═════ Toast ═════ -->
|
||||
<section class="space-y-3 pb-12">
|
||||
<h2 class="text-sm font-semibold text-dim uppercase tracking-wider">Toast</h2>
|
||||
<p class="text-xs text-faint">현재 토스트는 기존 +layout.svelte 렌더러 사용 — A-8에서 토큰 swap 예정.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="ghost" size="sm" onclick={() => addToast('success', '저장 완료')}>success</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => addToast('error', '저장 실패')}>error</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => addToast('warning', '경고')}>warning</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => addToast('info', '정보')}>info</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═════ 데모용 Drawer/Modal 인스턴스 ═════ -->
|
||||
|
||||
<Drawer id="sidebar" side="left" width="sidebar" aria-label="Sidebar drawer 데모">
|
||||
<div class="p-5 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-text">Sidebar drawer</h3>
|
||||
<p class="text-xs text-dim">id="sidebar". 단일 slot이므로 meta drawer가 열리면 자동으로 닫힙니다.</p>
|
||||
<Button variant="ghost" size="sm" onclick={() => ui.closeDrawer()}>닫기</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<Drawer id="meta" side="right" width="rail" aria-label="Meta drawer 데모">
|
||||
<div class="p-5 space-y-3">
|
||||
<h3 class="text-sm font-semibold text-text">Meta drawer</h3>
|
||||
<p class="text-xs text-dim">id="meta". 모바일/태블릿에서 메타 패널 폴백으로 쓰일 layer.</p>
|
||||
<Button variant="ghost" size="sm" onclick={() => ui.closeDrawer()}>닫기</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
<Modal id="demo-simple" title="Simple modal">
|
||||
<div class="space-y-3">
|
||||
<p>이 모달은 stack에 1개만 있습니다. 안에서 "또 열기"를 누르면 nested modal이 쌓입니다.</p>
|
||||
<p class="text-xs text-faint">stack 인덱스로 z-index가 자동 계산되어 backdrop이 정확히 겹칩니다.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('demo-simple')}>닫기</Button>
|
||||
<Button variant="primary" size="sm" onclick={openNestedModal}>또 열기 (nested)</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal id="demo-nested" title="Nested modal" size="sm">
|
||||
<p>nested modal입니다. Esc를 누르면 이것만 닫히고, 아래 simple modal은 그대로 남습니다.</p>
|
||||
{#snippet footer()}
|
||||
<Button variant="primary" size="sm" onclick={() => ui.closeTopModal()}>이것만 닫기</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
id="demo-confirm"
|
||||
title="문서를 삭제할까요?"
|
||||
message="이 작업은 되돌릴 수 없습니다. 선택한 문서가 영구적으로 제거됩니다."
|
||||
confirmLabel="삭제"
|
||||
tone="danger"
|
||||
onconfirm={handleConfirm}
|
||||
/>
|
||||
9
frontend/src/routes/__styleguide/+page.ts
Normal file
9
frontend/src/routes/__styleguide/+page.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
// dev 모드에서만 접근 가능. 프로덕션 빌드에서는 / 로 리다이렉트.
|
||||
export const load = () => {
|
||||
if (!dev) {
|
||||
throw redirect(307, '/');
|
||||
}
|
||||
};
|
||||
@@ -132,11 +132,14 @@ async def call_search(
|
||||
query: str,
|
||||
mode: str = "hybrid",
|
||||
limit: int = 20,
|
||||
fusion: str | None = None,
|
||||
) -> tuple[list[int], float]:
|
||||
"""검색 API 호출 → (doc_ids, latency_ms)."""
|
||||
url = f"{base_url.rstrip('/')}/api/search/"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
params = {"q": query, "mode": mode, "limit": limit}
|
||||
params: dict[str, str | int] = {"q": query, "mode": mode, "limit": limit}
|
||||
if fusion:
|
||||
params["fusion"] = fusion
|
||||
|
||||
import time
|
||||
|
||||
@@ -161,6 +164,7 @@ async def evaluate(
|
||||
token: str,
|
||||
label: str,
|
||||
mode: str = "hybrid",
|
||||
fusion: str | None = None,
|
||||
) -> list[QueryResult]:
|
||||
"""전체 쿼리셋 평가."""
|
||||
results: list[QueryResult] = []
|
||||
@@ -169,7 +173,7 @@ async def evaluate(
|
||||
for q in queries:
|
||||
try:
|
||||
returned_ids, latency_ms = await call_search(
|
||||
client, base_url, token, q.query, mode=mode
|
||||
client, base_url, token, q.query, mode=mode, fusion=fusion
|
||||
)
|
||||
results.append(
|
||||
QueryResult(
|
||||
@@ -393,6 +397,13 @@ def main() -> int:
|
||||
choices=["fts", "trgm", "vector", "hybrid"],
|
||||
help="검색 mode 파라미터",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fusion",
|
||||
type=str,
|
||||
default=None,
|
||||
choices=["legacy", "rrf", "rrf_boost"],
|
||||
help="hybrid 모드 fusion 전략 (Phase 0.5+, 미지정 시 서버 기본값)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
type=str,
|
||||
@@ -420,28 +431,31 @@ def main() -> int:
|
||||
|
||||
queries = load_queries(args.queries)
|
||||
print(f"Loaded {len(queries)} queries from {args.queries}")
|
||||
print(f"Mode: {args.mode}")
|
||||
print(f"Mode: {args.mode}", end="")
|
||||
if args.fusion:
|
||||
print(f" / fusion: {args.fusion}", end="")
|
||||
print()
|
||||
|
||||
all_results: list[QueryResult] = []
|
||||
|
||||
if args.base_url:
|
||||
print(f"\n>>> evaluating: {args.base_url}")
|
||||
results = asyncio.run(
|
||||
evaluate(queries, args.base_url, args.token, "single", mode=args.mode)
|
||||
evaluate(queries, args.base_url, args.token, "single", mode=args.mode, fusion=args.fusion)
|
||||
)
|
||||
print_summary("single", results)
|
||||
all_results.extend(results)
|
||||
else:
|
||||
print(f"\n>>> baseline: {args.baseline_url}")
|
||||
baseline_results = asyncio.run(
|
||||
evaluate(queries, args.baseline_url, args.token, "baseline", mode=args.mode)
|
||||
evaluate(queries, args.baseline_url, args.token, "baseline", mode=args.mode, fusion=args.fusion)
|
||||
)
|
||||
baseline_summary = print_summary("baseline", baseline_results)
|
||||
|
||||
print(f"\n>>> candidate: {args.candidate_url}")
|
||||
candidate_results = asyncio.run(
|
||||
evaluate(
|
||||
queries, args.candidate_url, args.token, "candidate", mode=args.mode
|
||||
queries, args.candidate_url, args.token, "candidate", mode=args.mode, fusion=args.fusion
|
||||
)
|
||||
)
|
||||
candidate_summary = print_summary("candidate", candidate_results)
|
||||
|
||||
Reference in New Issue
Block a user