f4791cfada
D.1: documents route 디자인 토큰 정리 (var(--*) → 시맨틱 토큰, 잔여 0) D.2: isQuestion 질문형 감지 유틸 (? 단일단어 허용, 한/영 6규칙) D.3: AskAnswerCard 컴팩트 답변 카드 + analyze.ts 타입 정의 D.4: 질문형 검색 시 /search/ask 병렬 호출 + 상단 카드 배치 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
161 lines
5.3 KiB
Svelte
161 lines
5.3 KiB
Svelte
<!--
|
|
AskAnswerCard.svelte — 검색 결과 페이지 상단 AI 답변 카드 (컴팩트).
|
|
|
|
/ask 페이지의 AskAnswer.svelte와 달리, 검색 결과를 가리지 않는
|
|
보조 영역으로 설계. 출처 목록 클릭이 must-have, 본문 [n] 클릭은 nice-to-have.
|
|
-->
|
|
<script lang="ts">
|
|
import Badge from '$lib/components/ui/Badge.svelte';
|
|
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
|
import { Sparkles, X, FileText } from 'lucide-svelte';
|
|
import type { AskResponse, Confidence } from '$lib/types/ask';
|
|
|
|
interface Props {
|
|
data: AskResponse | null;
|
|
loading: boolean;
|
|
error: boolean;
|
|
onCitationClick: (docId: number) => void;
|
|
onDismiss: () => void;
|
|
}
|
|
|
|
let { data, loading, error, onCitationClick, onDismiss }: Props = $props();
|
|
|
|
// [n] 파싱 (AskAnswer.svelte에서 가져옴)
|
|
type Token =
|
|
| { type: 'text'; value: string }
|
|
| { type: 'cite'; n: number; raw: string };
|
|
|
|
function splitAnswer(text: string): Token[] {
|
|
return text
|
|
.split(/(\[\d+\])/g)
|
|
.filter(Boolean)
|
|
.map((tok): Token => {
|
|
const m = tok.match(/^\[(\d+)\]$/);
|
|
return m
|
|
? { type: 'cite', n: Number(m[1]), raw: tok }
|
|
: { type: 'text', value: tok };
|
|
});
|
|
}
|
|
|
|
function confidenceTone(c: Confidence | null): 'success' | 'warning' | 'error' | 'neutral' {
|
|
if (c === 'high') return 'success';
|
|
if (c === 'medium') return 'warning';
|
|
if (c === 'low') return 'error';
|
|
return 'neutral';
|
|
}
|
|
|
|
function confidenceLabel(c: Confidence | null): string {
|
|
if (c === 'high') return '높음';
|
|
if (c === 'medium') return '중간';
|
|
if (c === 'low') return '낮음';
|
|
return '';
|
|
}
|
|
|
|
let tokens = $derived(data?.ai_answer ? splitAnswer(data.ai_answer) : []);
|
|
|
|
// 출처 중복 제거 (같은 doc_id)
|
|
let uniqueCitations = $derived.by(() => {
|
|
if (!data?.citations?.length) return [];
|
|
const seen = new Set<number>();
|
|
return data.citations.filter((c) => {
|
|
if (seen.has(c.doc_id)) return false;
|
|
seen.add(c.doc_id);
|
|
return true;
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<div class="bg-surface border border-default rounded-card p-4">
|
|
<!-- 헤더 -->
|
|
<div class="flex items-center justify-between gap-2 mb-2">
|
|
<div class="flex items-center gap-1.5">
|
|
<Sparkles size={12} class="text-accent" />
|
|
<span class="text-[10px] font-semibold tracking-wider uppercase text-dim">
|
|
내 자료 기준 답변
|
|
</span>
|
|
{#if data?.confidence}
|
|
<Badge tone={confidenceTone(data.confidence)} size="sm">
|
|
신뢰도 {confidenceLabel(data.confidence)}
|
|
</Badge>
|
|
{/if}
|
|
{#if data?.completeness === 'partial'}
|
|
<Badge tone="warning" size="sm">일부 답변</Badge>
|
|
{/if}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={onDismiss}
|
|
class="p-0.5 rounded text-dim hover:text-text transition-colors"
|
|
aria-label="답변 카드 접기"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 본문 -->
|
|
{#if loading}
|
|
<div class="space-y-2">
|
|
<Skeleton w="w-full" h="h-3" />
|
|
<Skeleton w="w-4/5" h="h-3" />
|
|
</div>
|
|
<p class="mt-3 text-[10px] text-dim flex items-center gap-1.5">
|
|
<span class="inline-block w-2.5 h-2.5 rounded-full border-2 border-dim border-t-accent animate-spin"></span>
|
|
근거 기반 답변 생성 중…
|
|
</p>
|
|
{:else if error}
|
|
<p class="text-xs text-dim">답변을 가져오지 못했습니다.</p>
|
|
{:else if data?.ai_answer}
|
|
<!-- 답변 텍스트 -->
|
|
<div class="text-sm leading-6 text-text">
|
|
{#each tokens as tok}
|
|
{#if tok.type === 'cite'}
|
|
{@const citation = data?.citations?.find((c) => c.n === tok.n)}
|
|
{#if citation}
|
|
<button
|
|
type="button"
|
|
class="inline text-accent font-semibold hover:underline px-0.5"
|
|
onclick={() => onCitationClick(citation.doc_id)}
|
|
title={citation.title || `문서 #${citation.doc_id}`}
|
|
>
|
|
{tok.raw}
|
|
</button>
|
|
{:else}
|
|
<span class="text-dim">{tok.raw}</span>
|
|
{/if}
|
|
{:else}
|
|
<span>{tok.value}</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- partial: 누락 측면 -->
|
|
{#if data.completeness === 'partial' && data.missing_aspects?.length}
|
|
<p class="mt-2 text-[10px] text-dim">
|
|
다루지 못한 부분: {data.missing_aspects.join(', ')}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- 출처 목록 (must-have) -->
|
|
{#if uniqueCitations.length > 0}
|
|
<div class="mt-3 pt-2 border-t border-default">
|
|
<p class="text-[10px] font-medium text-dim mb-1.5">출처</p>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#each uniqueCitations as citation}
|
|
<button
|
|
type="button"
|
|
onclick={() => onCitationClick(citation.doc_id)}
|
|
class="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded bg-surface text-text border border-default hover:border-accent hover:text-accent transition-colors"
|
|
title={citation.span_text}
|
|
>
|
|
<FileText size={10} />
|
|
<span class="max-w-[200px] truncate">
|
|
{citation.title || `문서 #${citation.doc_id}`}
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|