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>
This commit is contained in:
hyungi
2026-05-22 13:40:11 +00:00
parent 51c3f6df10
commit c086c9f85d
4 changed files with 129 additions and 25 deletions
+18 -7
View File
@@ -84,20 +84,31 @@ export type ApiError = {
detail: string;
errorCode?: string;
errorMessage?: string;
/**
* PR-DocSrv-Web-Ask-Selector-1: backend_unavailable 503 path 가
* body level 에 `error_reason` (예: `macbook_unavailable`) 을 둠. 본 필드는
* 그 raw 값. 다른 endpoint 응답에 `error_reason` 이 없으면 undefined.
*/
errorReason?: string;
};
function parseDetail(body: unknown, fallback: string): {
detail: string;
errorCode?: string;
errorMessage?: string;
errorReason?: string;
} {
if (body && typeof body === 'object' && 'detail' in body) {
const d = (body as { detail: unknown }).detail;
if (typeof d === 'string' && d) return { detail: d };
if (d && typeof d === 'object') {
const obj = d as { error_code?: string; message?: string };
const message = obj.message || fallback;
return { detail: message, errorCode: obj.error_code, errorMessage: obj.message };
if (body && typeof body === 'object') {
const top = body as { detail?: unknown; error_reason?: unknown };
const errorReason = typeof top.error_reason === 'string' ? top.error_reason : undefined;
if ('detail' in body) {
const d = top.detail;
if (typeof d === 'string' && d) return { detail: d, errorReason };
if (d && typeof d === 'object') {
const obj = d as { error_code?: string; message?: string };
const message = obj.message || fallback;
return { detail: message, errorCode: obj.error_code, errorMessage: obj.message, errorReason };
}
}
}
return { detail: fallback };
@@ -60,8 +60,20 @@
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'
@@ -88,6 +100,13 @@
<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>
+8 -1
View File
@@ -11,7 +11,8 @@ export type SynthesisStatus =
| 'skipped'
| 'no_evidence'
| 'parse_failed'
| 'llm_error';
| 'llm_error'
| 'backend_unavailable';
export type Confidence = 'high' | 'medium' | 'low';
@@ -74,4 +75,10 @@ export interface AskResponse {
covered_aspects: string[] | null;
missing_aspects: string[] | null;
confirmed_items: ConfirmedItem[] | null;
/**
* PR-MacBook-RAG-Backend-1: backend dispatcher metadata.
* backend 미지정 호출은 둘 다 null (기존 호출자 호환). 명시 opt-in 시만 채워짐.
*/
backend_requested?: string | null;
backend_used?: string | null;
}
+84 -17
View File
@@ -1,10 +1,18 @@
<!--
/ask — Phase 3.4 Ask Pipeline Frontend.
URL-driven: `/ask?q=<encoded>` 가 진입점. $effect 로 q 변화 감지 →
`/api/search/ask` 호출 → 3-panel 렌더 (Answer / Evidence / Results).
URL-driven: `/ask?q=<encoded>[&backend=qwen-macbook]` 가 진입점.
$effect 로 (q, backend) 변화 감지 → `/api/search/ask` 호출 →
3-panel 렌더 (Answer / Evidence / Results).
중복 호출 방지: lastQuery 가드 (hydration + reactive trigger 시 같은 q 2번 발동 방지).
중복 호출 방지: lastKey (q+backend) 가드.
Backend selector (PR-DocSrv-Web-Ask-Selector-1):
- `default` (기본 Gemma Mac mini, URL param 없음)
- `qwen-macbook` (사용자 명시 opt-in, MacBook)
- localStorage / Settings preference 미사용 (별 PR `PR-DocSrv-Ask-Default-Pref-1`)
- 503 + error_reason=macbook_unavailable 시 자동 fallback 금지,
사용자가 MacBook 깨우거나 Default 로 재요청.
-->
<script lang="ts">
import { page } from '$app/stores';
@@ -12,19 +20,29 @@
import { api, type ApiError } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import AskAnswer from '$lib/components/ask/AskAnswer.svelte';
import AskEvidence from '$lib/components/ask/AskEvidence.svelte';
import AskResults from '$lib/components/ask/AskResults.svelte';
import { Sparkles, Search } from 'lucide-svelte';
import { Sparkles, Search, AlertCircle } from 'lucide-svelte';
import type { AskResponse } from '$lib/types/ask';
type BackendChoice = 'default' | 'qwen-macbook';
function parseBackend(raw: string | null): BackendChoice {
return raw === 'qwen-macbook' ? 'qwen-macbook' : 'default';
}
// ── state ───────────────────────────────────────────
let queryInput = $state('');
let selectedBackend = $state<BackendChoice>('default');
let data = $state<AskResponse | null>(null);
let loading = $state(false);
let backendUnavailable = $state(false);
let backendUnavailableMessage = $state('');
// 중복 호출 방지 가드 (hydration + reactive trigger 이중 발동 방지)
let lastQuery = '';
let lastKey = '';
// citation scroll 연동: Answer 가 [n] 클릭 → Evidence 카드로 이동 + highlight
const citationNodes = new Map<number, HTMLElement>();
@@ -45,11 +63,18 @@
node?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ── URL 빌더: backend !== 'default' 일 때만 param 추가 ─────
function buildAskUrl(q: string, backend: BackendChoice): string {
const params = new URLSearchParams({ q });
if (backend !== 'default') params.set('backend', backend);
return `/ask?${params.toString()}`;
}
// ── submit (URL-driven, back 자동) ──────────────────
function submit() {
const q = queryInput.trim();
if (!q) return;
goto(`/ask?q=${encodeURIComponent(q)}`);
goto(buildAskUrl(q, selectedBackend));
}
function handleKeydown(e: KeyboardEvent) {
@@ -59,18 +84,36 @@
}
}
// 503 후 "Default 로 재요청" — backend param 명시 제거.
// localStorage preference 도입 시 본 버튼은 preference 무시 invariant 보존 필요.
function retryWithDefault() {
const q = $page.url.searchParams.get('q') ?? queryInput.trim();
if (!q) return;
goto(`/ask?q=${encodeURIComponent(q)}`);
}
// ── API 호출 ───────────────────────────────────────
async function runAsk(q: string) {
async function runAsk(q: string, backend: BackendChoice) {
loading = true;
activeCitation = null;
backendUnavailable = false;
backendUnavailableMessage = '';
const path = backend !== 'default'
? `/search/ask?q=${encodeURIComponent(q)}&backend=${encodeURIComponent(backend)}&limit=10`
: `/search/ask?q=${encodeURIComponent(q)}&limit=10`;
try {
data = await api<AskResponse>(
`/search/ask?q=${encodeURIComponent(q)}&limit=10`,
);
data = await api<AskResponse>(path);
} catch (err) {
const apiErr = err as ApiError;
addToast('error', apiErr.detail || '답변 생성 실패');
data = null;
if (apiErr.status === 503 && apiErr.errorReason === 'macbook_unavailable') {
backendUnavailable = true;
backendUnavailableMessage = apiErr.detail
|| 'MacBook 이 응답하지 않습니다. 깨우거나 Default (Gemma) 로 다시 요청하세요.';
data = null;
} else {
addToast('error', apiErr.detail || '답변 생성 실패');
data = null;
}
} finally {
loading = false;
}
@@ -79,16 +122,20 @@
// ── URL → runAsk (중복 가드) ────────────────────────
$effect(() => {
const q = $page.url.searchParams.get('q') ?? '';
const backend = parseBackend($page.url.searchParams.get('backend'));
queryInput = q;
selectedBackend = backend;
if (!q) {
data = null;
loading = false;
lastQuery = '';
backendUnavailable = false;
lastKey = '';
return;
}
if (q === lastQuery) return;
lastQuery = q;
runAsk(q);
const key = `${q}|${backend}`;
if (key === lastKey) return;
lastKey = key;
runAsk(q, backend);
});
</script>
@@ -114,12 +161,32 @@
class="w-full pl-9 pr-3 py-2 bg-surface border border-default rounded-lg text-text text-sm focus:border-accent outline-none"
/>
</div>
<select
bind:value={selectedBackend}
title="Backend 선택 MacBook 사용자 명시 opt-in 시만 호출됩니다 (꺼져 있으면 503)."
class="py-2 px-2 bg-surface border border-default rounded-lg text-text text-xs focus:border-accent outline-none"
>
<option value="default">Default (Gemma)</option>
<option value="qwen-macbook">Qwen 27B (MacBook)</option>
</select>
</div>
</div>
<!-- 본문 -->
<div class="max-w-5xl mx-auto p-4">
{#if !queryInput && !loading && !data}
{#if backendUnavailable}
<div class="py-16">
<EmptyState
icon={AlertCircle}
title="MacBook 응답하지 않습니다"
description={backendUnavailableMessage}
>
<Button variant="primary" size="sm" onclick={retryWithDefault}>
Default (Gemma) 로 재요청
</Button>
</EmptyState>
</div>
{:else if !queryInput && !loading && !data}
<div class="py-16">
<EmptyState
icon={Sparkles}