Files
hyungi_document_server/frontend/src/lib/components/ask/AskAnswer.svelte
T
hyungi c086c9f85d feat(ask): /ask backend selector + 503 macbook_unavailable UI
선행 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>
2026-05-22 13:47:41 +00:00

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>