Files
hyungi_document_server/frontend/src/routes/__styleguide/+page.svelte
Hyungi Ahn 161ff18a31 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>
2026-04-07 08:58:33 +09:00

453 lines
18 KiB
Svelte
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.
<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}
/>