c086c9f85d
선행 PR-MacBook-RAG-Backend-1 (main a7b8f15) backend dispatcher 의 frontend
소비. /ask 페이지에 backend selector (default | qwen-macbook) + URL
?backend=qwen-macbook 지원 + 503 friendly empty state + "Default 로 재요청"
버튼 (backend param 명시 제거 → 무한 루프 0).
정책 (선행 PR 그대로 유지):
- default / backend 미지정 = Gemma Mac mini (현 path 변동 0, 기존 호출자 호환)
- backend=qwen-macbook = MacBook 명시 opt-in. unavailable 시 HTTP 503 +
error_reason=macbook_unavailable. Gemma 자동 fallback 0.
변경 4 파일:
- types/ask.ts: AskResponse 에 backend_requested / backend_used 필드 +
SynthesisStatus 에 backend_unavailable literal 추가
- api.ts: ApiError 에 errorReason 추가, parseDetail 이 503 body 의
error_reason 흡수 (다른 endpoint 영향 0)
- AskAnswer.svelte: backend_requested 명시 시 muted chip 표시
(default 호출은 미표시, 시각 noise 회피)
- routes/ask/+page.svelte: selector dropdown + URL state + 503 분기
Non-Goals (별 PR):
- localStorage / Settings preference (PR-DocSrv-Ask-Default-Pref-1)
- SSE streaming, Tool-calling ReAct
- shared secret / MacBook auth (Tailscale ACL only)
검증: docker compose build frontend 통과 (svelte-check + vite build).
lint:tokens 본 PR 변경 위반 0 (기존 62 건은 baseline stale debt, settings/login).
Spec: ~/.claude/plans/document-buzzing-codd.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
7.7 KiB
Svelte
229 lines
7.7 KiB
Svelte
<!--
|
|
AskAnswer.svelte — /ask 페이지 상단 패널.
|
|
|
|
Answer 본문 + clickable [n] citations + 신뢰도/상태 Badge.
|
|
status != completed 또는 refused=true → warning empty state +
|
|
no_results_reason + "검색 결과 확인하기" 역링크.
|
|
-->
|
|
<script lang="ts">
|
|
import Badge from '$lib/components/ui/Badge.svelte';
|
|
import Button from '$lib/components/ui/Button.svelte';
|
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
|
import { AlertTriangle, Sparkles } from 'lucide-svelte';
|
|
import type { AskResponse, Confidence, SynthesisStatus } from '$lib/types/ask';
|
|
|
|
interface Props {
|
|
data: AskResponse | null;
|
|
loading: boolean;
|
|
onCitationClick: (n: number) => void;
|
|
}
|
|
|
|
let { data, loading, onCitationClick }: Props = $props();
|
|
|
|
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 '없음';
|
|
}
|
|
|
|
const STATUS_LABEL: Record<SynthesisStatus, string> = {
|
|
completed: '답변 완료',
|
|
timeout: '답변 지연',
|
|
skipped: '답변 생략',
|
|
no_evidence: '근거 없음',
|
|
parse_failed: '형식 오류',
|
|
llm_error: 'AI 오류',
|
|
backend_unavailable: 'Backend 비가용',
|
|
};
|
|
|
|
/**
|
|
* backend chip label — `backend_requested` 가 명시 opt-in 인 경우만 표시.
|
|
* 미지정 (null/undefined) default 호출은 chip 없음 (시각 noise 회피).
|
|
*/
|
|
function backendChipLabel(backend: string | null | undefined): string | null {
|
|
if (!backend) return null;
|
|
if (backend === 'qwen-macbook') return 'Qwen 27B (MacBook)';
|
|
if (backend === 'gemma-macmini') return 'Gemma 26B (Mac mini)';
|
|
return backend;
|
|
}
|
|
|
|
let tokens = $derived(data?.ai_answer ? splitAnswer(data.ai_answer) : []);
|
|
let showFullAnswer = $derived(
|
|
!!data && !!data.ai_answer && data.completeness === 'full'
|
|
&& data.synthesis_status === 'completed' && !data.refused,
|
|
);
|
|
let showPartial = $derived(
|
|
!!data && data.completeness === 'partial' && !data.refused,
|
|
);
|
|
let showWarning = $derived(!!data && !showFullAnswer && !showPartial);
|
|
</script>
|
|
|
|
<section class="bg-surface border border-default rounded-card p-5">
|
|
<!-- 헤더 -->
|
|
<div class="flex items-start justify-between gap-3 mb-4">
|
|
<div>
|
|
<p class="text-[10px] font-semibold tracking-wider uppercase text-dim flex items-center gap-1.5">
|
|
<Sparkles size={12} /> AI Answer
|
|
</p>
|
|
<h2 class="mt-1 text-base font-semibold text-text">근거 기반 답변</h2>
|
|
</div>
|
|
|
|
{#if data && !loading}
|
|
<div class="flex flex-wrap gap-1.5">
|
|
<Badge tone={confidenceTone(data.confidence)} size="sm">
|
|
신뢰도 {confidenceLabel(data.confidence)}
|
|
</Badge>
|
|
{#if backendChipLabel(data.backend_requested)}
|
|
<span title={`backend_requested=${data.backend_requested} / backend_used=${data.backend_used ?? 'null'}`}>
|
|
<Badge tone="neutral" size="sm">
|
|
{backendChipLabel(data.backend_requested)}
|
|
</Badge>
|
|
</span>
|
|
{/if}
|
|
<Badge tone="neutral" size="sm">
|
|
{STATUS_LABEL[data.synthesis_status]}
|
|
</Badge>
|
|
{#if data.synthesis_ms > 0}
|
|
<Badge tone="neutral" size="sm">
|
|
{Math.round(data.synthesis_ms)}ms
|
|
</Badge>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- 본문 -->
|
|
{#if loading}
|
|
<div class="space-y-3">
|
|
<Skeleton w="w-3/4" h="h-4" />
|
|
<Skeleton w="w-full" h="h-4" />
|
|
<Skeleton w="w-5/6" h="h-4" />
|
|
<p class="mt-4 text-xs text-dim flex items-center gap-2">
|
|
<span class="inline-block w-3 h-3 rounded-full border-2 border-dim border-t-accent animate-spin"></span>
|
|
근거 기반 답변 생성 중… 약 15초 소요
|
|
</p>
|
|
</div>
|
|
{:else if showFullAnswer && data}
|
|
<div class="text-sm leading-7 text-text">
|
|
{#each tokens as tok}
|
|
{#if tok.type === 'cite'}
|
|
<button
|
|
type="button"
|
|
class="inline-block align-baseline text-accent font-semibold hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring rounded px-0.5"
|
|
onclick={() => onCitationClick(tok.n)}
|
|
aria-label={`인용 ${tok.n}번 보기`}
|
|
>
|
|
{tok.raw}
|
|
</button>
|
|
{:else}
|
|
<span>{tok.value}</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else if showPartial && data}
|
|
<!-- Phase 3.5a: question-aligned partial structure -->
|
|
<div>
|
|
<Badge tone="warning" size="sm">일부 답변</Badge>
|
|
|
|
{#if data.ai_answer}
|
|
<div class="mt-3 text-sm leading-7 text-text">
|
|
{#each tokens as tok}
|
|
{#if tok.type === 'cite'}
|
|
<button
|
|
type="button"
|
|
class="inline-block align-baseline text-accent font-semibold hover:underline rounded px-0.5"
|
|
onclick={() => onCitationClick(tok.n)}
|
|
>{tok.raw}</button>
|
|
{:else}
|
|
<span>{tok.value}</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else if data.confirmed_items?.length}
|
|
<div class="mt-3">
|
|
<h4 class="text-xs font-semibold text-dim uppercase tracking-wider">✓ 답변 가능</h4>
|
|
<ul class="mt-2 space-y-2">
|
|
{#each data.confirmed_items as item}
|
|
<li class="text-sm text-text">
|
|
<strong class="text-accent">{item.aspect}:</strong>
|
|
<span>{item.text}</span>
|
|
{#each item.citations as n}
|
|
<button
|
|
type="button"
|
|
class="text-accent font-semibold hover:underline px-0.5"
|
|
onclick={() => onCitationClick(n)}
|
|
>[{n}]</button>
|
|
{/each}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if data.missing_aspects?.length}
|
|
<div class="mt-4 border-t border-default pt-3">
|
|
<h4 class="text-xs font-semibold text-dim uppercase tracking-wider">✗ 답변 불가</h4>
|
|
<ul class="mt-2 space-y-1">
|
|
{#each data.missing_aspects as aspect}
|
|
<li class="text-sm text-dim">{aspect} <span class="text-[10px]">(근거 없음)</span></li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mt-4">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
href={`/documents?q=${encodeURIComponent(data.query)}`}
|
|
>
|
|
검색 결과 확인하기
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else if showWarning && data}
|
|
<EmptyState
|
|
icon={AlertTriangle}
|
|
title={data.refused && data.no_results_reason
|
|
? data.no_results_reason
|
|
: (data.no_results_reason ?? '관련 근거를 찾지 못했습니다.')}
|
|
description="검색 결과를 직접 확인해 보세요."
|
|
>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
href={`/documents?q=${encodeURIComponent(data.query)}`}
|
|
>
|
|
검색 결과 확인하기
|
|
</Button>
|
|
</EmptyState>
|
|
{/if}
|
|
</section>
|