refactor(search)!: /ask 프론트 UI 제거 (검색 단일화 — AI답변=eid /chat)

/documents 인라인 AI카드(askSearch·AskAnswerCard·Sparkles→/ask) + /ask 페이지·컴포넌트(components/ask, AskAnswer/Evidence/Results) + 고아 util(isQuestion)·type(types/ask) 제거. /documents=순수 문서검색, AI답변은 eid /chat 사이드바로 일원화. dangling ref 0(grep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-27 12:48:13 +09:00
parent b6717c537f
commit 86a71ec4d1
8 changed files with 1 additions and 1056 deletions
@@ -1,160 +0,0 @@
<!--
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>
@@ -1,228 +0,0 @@
<!--
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>
@@ -1,91 +0,0 @@
<!--
AskEvidence.svelte — /ask 페이지 우측 sticky 패널.
⚠ 영구 룰 (Phase 3.4 plan):
`citation.full_snippet` 은 UI 에 직접 렌더 금지. debug 모드(`?debug=1`)
에서 hover tooltip 으로만 조건부 노출 가능.
이 규칙이 깨지면 backend span-precision UX 가치가 사라진다. 코드 리뷰에서
반드시 reject. span_text 만 본문으로 노출한다.
-->
<script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import { BookOpen } from 'lucide-svelte';
import type { AskResponse } from '$lib/types/ask';
interface Props {
data: AskResponse | null;
loading: boolean;
activeCitation: number | null;
registerCitation: (n: number, node: HTMLElement) => { destroy: () => void };
}
let { data, loading, activeCitation, registerCitation }: Props = $props();
let citations = $derived(data?.citations ?? []);
</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">
<BookOpen size={12} /> Evidence Highlights
</p>
<h3 class="mt-1 text-sm font-semibold text-text">인용 근거</h3>
</div>
{#if data && !loading}
<Badge tone="neutral" size="sm">{citations.length}</Badge>
{/if}
</div>
{#if loading}
<div class="space-y-3">
{#each Array(2) as _}
<div class="border border-default rounded-card p-4 space-y-2">
<Skeleton w="w-24" h="h-3" />
<Skeleton w="w-full" h="h-3" />
<Skeleton w="w-5/6" h="h-3" />
<Skeleton w="w-3/4" h="h-3" />
</div>
{/each}
</div>
{:else if citations.length === 0}
<EmptyState title="표시할 근거가 없습니다." class="py-6" />
{:else}
<div class="space-y-3">
{#each citations as citation (citation.n)}
{@const isActive = activeCitation === citation.n}
<article
class="border rounded-card p-4 transition-colors {isActive
? 'border-accent ring-2 ring-accent/20 bg-accent/5'
: 'border-default'}"
use:registerCitation={citation.n}
>
<div class="flex items-start gap-2">
<span class="text-accent font-bold text-sm shrink-0">[{citation.n}]</span>
<div class="flex-1 min-w-0">
<strong class="block text-sm text-text truncate">
{citation.title ?? `문서 ${citation.doc_id}`}
</strong>
{#if citation.section_title}
<p class="mt-0.5 text-xs text-dim truncate">{citation.section_title}</p>
{/if}
</div>
</div>
<!-- ⚠ span_text 만 렌더. full_snippet 금지 -->
<p class="mt-3 text-sm leading-relaxed text-text whitespace-pre-wrap">
{citation.span_text}
</p>
<div class="mt-3 flex gap-2 text-[10px] text-dim">
<span>relevance {citation.relevance.toFixed(2)}</span>
<span>rerank {citation.rerank_score.toFixed(2)}</span>
</div>
</article>
{/each}
</div>
{/if}
</section>
@@ -1,78 +0,0 @@
<!--
AskResults.svelte — /ask 페이지 하단 패널.
검색 결과 리스트. DocumentCard 재사용 X — SearchResult 필드 셋이 달라서
의존성 리스크 회피. inline 간단 카드로 title/score/snippet/section_title 표시.
클릭 시 `/documents/{id}` 로 이동.
-->
<script lang="ts">
import Badge from '$lib/components/ui/Badge.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import { FileText } from 'lucide-svelte';
import type { AskResponse } from '$lib/types/ask';
interface Props {
data: AskResponse | null;
loading: boolean;
}
let { data, loading }: Props = $props();
let results = $derived(data?.results ?? []);
</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">
<FileText size={12} /> Search Results
</p>
<h3 class="mt-1 text-sm font-semibold text-text">검색 결과</h3>
</div>
{#if data && !loading}
<Badge tone="neutral" size="sm">{data.total}</Badge>
{/if}
</div>
{#if loading}
<div class="space-y-3">
{#each Array(5) as _}
<div class="border border-default rounded-card p-4 space-y-2">
<Skeleton w="w-2/3" h="h-4" />
<Skeleton w="w-full" h="h-3" />
<Skeleton w="w-4/5" h="h-3" />
</div>
{/each}
</div>
{:else if results.length === 0}
<EmptyState title="검색 결과가 없습니다." class="py-6" />
{:else}
<div class="space-y-3">
{#each results as result (result.id)}
<a
href={`/documents/${result.id}`}
class="block border border-default rounded-card p-4 hover:border-accent hover:bg-surface-hover transition-colors"
>
<div class="flex items-start justify-between gap-3">
<strong class="text-sm text-text flex-1 min-w-0 truncate">
{result.title ?? `문서 ${result.id}`}
</strong>
<div class="flex gap-1.5 text-[10px] text-dim shrink-0">
<span>score {result.score.toFixed(2)}</span>
{#if result.rerank_score != null}
<span>rerank {result.rerank_score.toFixed(2)}</span>
{/if}
</div>
</div>
{#if result.section_title}
<p class="mt-1 text-xs text-dim truncate">{result.section_title}</p>
{/if}
{#if result.snippet}
<p class="mt-2 text-xs text-dim line-clamp-2">{result.snippet}</p>
{/if}
</a>
{/each}
</div>
{/if}
</section>
-84
View File
@@ -1,84 +0,0 @@
/**
* Phase 3.4: `/api/search/ask` 응답 타입.
*
* Backend Pydantic 모델 (`app/api/search.py::AskResponse`) 과 1:1 매칭.
* 필드 변경 시 양쪽 동시 수정 필수.
*/
export type SynthesisStatus =
| 'completed'
| 'timeout'
| 'skipped'
| 'no_evidence'
| 'parse_failed'
| 'llm_error'
| 'backend_unavailable';
export type Confidence = 'high' | 'medium' | 'low';
export interface Citation {
n: number;
chunk_id: number | null;
doc_id: number;
title: string | null;
section_title: string | null;
/** LLM이 추출한 50~300자 핵심 span. UI에서 이것만 노출. */
span_text: string;
/**
* 원본 800자 window.
*
* ⚠ UI 기본 경로에서 절대 렌더 금지. debug 모드에서 hover tooltip 용도로만
* 조건부 노출 가능. full_snippet을 보여주면 backend span-precision UX
* 가치가 사라진다 (plan §Evidence 표시 규칙).
*/
full_snippet: string;
relevance: number;
rerank_score: number;
}
export interface SearchResult {
id: number;
title: string | null;
ai_domain: string | null;
ai_summary: string | null;
file_format: string;
score: number;
snippet: string | null;
match_reason: string | null;
chunk_id: number | null;
chunk_index: number | null;
section_title: string | null;
rerank_score: number | null;
}
export type Completeness = 'full' | 'partial' | 'insufficient';
export interface ConfirmedItem {
aspect: string;
text: string;
citations: number[];
}
export interface AskResponse {
results: SearchResult[];
ai_answer: string | null;
citations: Citation[];
synthesis_status: SynthesisStatus;
synthesis_ms: number;
confidence: Confidence | null;
refused: boolean;
no_results_reason: string | null;
query: string;
total: number;
/** Phase 3.5a */
completeness: Completeness;
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;
}
-61
View File
@@ -1,61 +0,0 @@
/**
* 검색 쿼리가 "질문형"인지 판별 (규칙 기반).
* true이면 검색 결과 페이지에서 /api/search/ask 병렬 호출.
*
* false positive: /ask가 refused=true 반환하면 카드 숨김 → 무해.
* false negative: 기존 검색 결과만 표시 → 무해.
*/
export function isQuestion(q: string): boolean {
const trimmed = q.trim();
if (trimmed.length === 0) return false;
// 1. ?로 끝나면 단일 단어라도 허용 (왜?, 절차?)
if (trimmed.endsWith('?')) return true;
// ? 없으면 단일 단어 / 4자 미만 제외 (키워드 보호)
if (trimmed.length < 4) return false;
if (trimmed.split(/\s+/).length < 2) return false;
// 2. 한국어 질문 어미
const KO_ENDINGS = [
'인가요', '인가', '인지', '있나요', '있나',
'할까요', '할까', '될까요', '될까',
'뭐야', '뭔가', '뭘까', '뭔지', '뭐지', '뭐죠',
'는지', '은지', '일까',
'어때', '어떤가',
'됩니까', '합니까', '입니까', '나요', '까요',
'주세요', '알려줘', '설명해', '비교해',
];
for (const ending of KO_ENDINGS) {
if (trimmed.endsWith(ending)) return true;
}
// 3. 한국어 질문 시작어
const KO_STARTS = [
'어떻게', '왜', '무엇', '무슨', '뭐가', '누가',
'어디', '언제', '몇', '얼마나', '어떤', '어느',
];
for (const start of KO_STARTS) {
if (trimmed.startsWith(start)) return true;
}
// 4. 영어 질문 시작어 (대소문자 무시)
const EN_STARTS = [
'what', 'how', 'why', 'when', 'where', 'who', 'which',
'is', 'are', 'do', 'does', 'can', 'could', 'should', 'would',
'explain', 'describe', 'compare', 'tell me',
];
const lower = trimmed.toLowerCase();
for (const start of EN_STARTS) {
if (lower.startsWith(start + ' ')) return true;
}
// 5. 의미 패턴 (끝 단어)
const SEMANTIC_ENDINGS = ['차이', '비교', '설명', '요약', '정리', '방법', '절차'];
const lastWord = trimmed.split(/\s+/).pop() || '';
for (const pat of SEMANTIC_ENDINGS) {
if (lastWord === pat || lastWord.endsWith(pat)) return true;
}
return false;
}
-305
View File
@@ -1,305 +0,0 @@
<!--
/ask — Phase 3.4 Ask Pipeline Frontend.
URL-driven: `/ask?q=<encoded>[&backend=<alias>]` 가 진입점.
$effect 로 (q, backend) 변화 감지 → `/api/search/ask` 호출 →
3-panel 렌더 (Answer / Evidence / Results).
중복 호출 방지: lastKey (q+backend) 가드.
Backend selector (PR-3 of DS AI routing policy, 2026-05-23,
[[document-server-ai-routing-policy]]) — PR-DocSrv-Web-Ask-Selector-1 확장:
- `auto` (기본, URL param 없음 → router 의 rule + LLM triage)
- `mac-mini-default` (명시, Mac mini gemma-4-26b)
- `qwen-macbook` (명시, M5 Max Qwen3.6-27B; "This device" 토글 on 시만)
- `claude-cloud` (명시, 503 scaffold; VITE_ENABLE_CLOUD_BACKEND_DEV=true 시만)
- "This device" 토글: localStorage `ds_device_self_label = 'macbook-m5-max' | null`.
source IP 의존 0 (PR-0 round 2 발견: caddy 2-hop + X-Forwarded-For 미설정 →
DS 가 보는 source IP = LAN gateway, 신뢰 불가).
- 503 + error_reason ∈ {macbook_unavailable, provider_not_configured, router_*}
시 자동 fallback 금지. UI 가 친절한 메시지 + "Mac mini default 로 재요청" 버튼.
- legacy URL `?backend=default|gemma-macmini` 는 그대로 받아서 mac-mini-default 와 동등 처리.
-->
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
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, AlertCircle } from 'lucide-svelte';
import type { AskResponse } from '$lib/types/ask';
type BackendChoice = 'auto' | 'mac-mini-default' | 'qwen-macbook' | 'claude-cloud';
function parseBackend(raw: string | null): BackendChoice {
if (raw === 'qwen-macbook') return 'qwen-macbook';
if (raw === 'mac-mini-default') return 'mac-mini-default';
if (raw === 'claude-cloud') return 'claude-cloud';
// legacy aliases (?backend=default | gemma-macmini) → mac-mini-default 와 동등
if (raw === 'default' || raw === 'gemma-macmini') return 'mac-mini-default';
return 'auto';
}
// Build-time feature flag — Claude Cloud UI 노출 여부 (default false).
// VITE_ENABLE_CLOUD_BACKEND_DEV=true npm run build 시만 활성. 운영 토글 X
// (build-time 한계). DS runtime feature flag API migrate 는 후속 PR.
const CLOUD_DEV_ENABLED = import.meta.env.VITE_ENABLE_CLOUD_BACKEND_DEV === 'true';
const DEVICE_LABEL_KEY = 'ds_device_self_label';
const DEVICE_LABEL_M5_MAX = 'macbook-m5-max';
// ── state ───────────────────────────────────────────
let queryInput = $state('');
let selectedBackend = $state<BackendChoice>('auto');
let data = $state<AskResponse | null>(null);
let loading = $state(false);
let backendUnavailable = $state(false);
let backendUnavailableMessage = $state('');
// "I am on MacBook M5 Max" 토글. mount 시 localStorage 에서 복원.
let isMacBookM5Max = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const stored = window.localStorage.getItem(DEVICE_LABEL_KEY);
isMacBookM5Max = stored === DEVICE_LABEL_M5_MAX;
});
function toggleMacBookM5Max() {
isMacBookM5Max = !isMacBookM5Max;
if (typeof window === 'undefined') return;
if (isMacBookM5Max) {
window.localStorage.setItem(DEVICE_LABEL_KEY, DEVICE_LABEL_M5_MAX);
} else {
window.localStorage.removeItem(DEVICE_LABEL_KEY);
// 토글 off 시 qwen-macbook 선택돼 있었으면 auto 로 복귀 (선택권 박탈 X 명시 신호).
if (selectedBackend === 'qwen-macbook') {
selectedBackend = 'auto';
}
}
}
// 중복 호출 방지 가드 (hydration + reactive trigger 이중 발동 방지)
let lastKey = '';
// citation scroll 연동: Answer 가 [n] 클릭 → Evidence 카드로 이동 + highlight
const citationNodes = new Map<number, HTMLElement>();
let activeCitation = $state<number | null>(null);
function registerCitation(n: number, node: HTMLElement) {
citationNodes.set(n, node);
return {
destroy() {
citationNodes.delete(n);
},
};
}
function scrollToCitation(n: number) {
activeCitation = n;
const node = citationNodes.get(n);
node?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ── URL 빌더: backend !== 'auto' 일 때만 param 추가 ─────
function buildAskUrl(q: string, backend: BackendChoice): string {
const params = new URLSearchParams({ q });
if (backend !== 'auto') params.set('backend', backend);
return `/ask?${params.toString()}`;
}
// ── submit (URL-driven, back 자동) ──────────────────
function submit() {
const q = queryInput.trim();
if (!q) return;
goto(buildAskUrl(q, selectedBackend));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.isComposing) {
e.preventDefault();
submit();
}
}
// 503 후 "Mac mini default 로 재요청" — auto 로 reset (param 명시 제거).
function retryWithMacMiniDefault() {
const q = $page.url.searchParams.get('q') ?? queryInput.trim();
if (!q) return;
goto(`/ask?q=${encodeURIComponent(q)}`);
}
// PR-3 of routing policy — error_reason → 친절 메시지 매핑.
// silent fallback 금지 ([[feedback_no_silent_fallback_explicit_opt_in]]):
// 모든 503 case 는 명시 표시, 다른 backend 자동 재호출 X.
function friendlyErrorMessage(reason: string | undefined, detail: string): string {
switch (reason) {
case 'macbook_unavailable':
return 'MacBook M5 Max 가 응답하지 않습니다. 깨우거나 (launchctl start) Mac mini default 로 다시 요청하세요.';
case 'provider_not_configured':
return 'Claude Cloud 백엔드는 아직 구성되지 않았습니다 (2026-06-15 이후 별 PR 활성화 예정).';
default:
if (reason && reason.startsWith('router_')) {
return `라우터 호출 실패 (${reason}). Mac mini default 로 다시 요청하거나 잠시 후 재시도하세요.`;
}
if (reason && reason.startsWith('upstream_')) {
return `Upstream backend 가 응답하지 않습니다 (${reason}). 잠시 후 재시도하세요.`;
}
return detail || '답변 생성 실패';
}
}
// ── API 호출 ───────────────────────────────────────
async function runAsk(q: string, backend: BackendChoice) {
loading = true;
activeCitation = null;
backendUnavailable = false;
backendUnavailableMessage = '';
const path = backend !== 'auto'
? `/search/ask?q=${encodeURIComponent(q)}&backend=${encodeURIComponent(backend)}&limit=10`
: `/search/ask?q=${encodeURIComponent(q)}&limit=10`;
try {
data = await api<AskResponse>(path);
} catch (err) {
const apiErr = err as ApiError;
if (apiErr.status === 503) {
backendUnavailable = true;
backendUnavailableMessage = friendlyErrorMessage(apiErr.errorReason, apiErr.detail);
data = null;
} else {
addToast('error', apiErr.detail || '답변 생성 실패');
data = null;
}
} finally {
loading = false;
}
}
// ── 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;
backendUnavailable = false;
lastKey = '';
return;
}
const key = `${q}|${backend}`;
if (key === lastKey) return;
lastKey = key;
runAsk(q, backend);
});
</script>
<svelte:head>
<title>질문 - PKM</title>
</svelte:head>
<div class="h-full overflow-auto">
<!-- 상단 검색바 (sticky) -->
<div class="sticky top-0 z-10 bg-bg/80 backdrop-blur border-b border-default px-4 py-3">
<div class="flex flex-wrap items-center gap-2 max-w-[1680px] mx-auto">
<div class="relative flex-1 min-w-0">
<Search
size={14}
class="absolute left-3 top-1/2 -translate-y-1/2 text-dim pointer-events-none"
/>
<input
data-search-input
type="text"
bind:value={queryInput}
onkeydown={handleKeydown}
placeholder="질문을 입력하세요 (/ 키로 포커스)"
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>
<label
class="flex items-center gap-1.5 text-xs text-dim cursor-pointer select-none"
title="이 디바이스가 MacBook M5 Max 인 경우 체크 — This device (qwen-macbook) 옵션 활성화됩니다. localStorage 저장."
>
<input
type="checkbox"
checked={isMacBookM5Max}
onchange={toggleMacBookM5Max}
class="accent-accent"
/>
<span>This is M5 Max</span>
</label>
<select
bind:value={selectedBackend}
title="Backend 선택 silent fallback 0 정책 (선택한 backend 시도, 실패 503)."
class="py-2 px-2 bg-surface border border-default rounded-lg text-text text-xs focus:border-accent outline-none min-w-0 max-w-[42vw] truncate"
>
<option value="auto">Auto (router)</option>
<option value="mac-mini-default">Mac mini (default)</option>
<option
value="qwen-macbook"
disabled={!isMacBookM5Max}
title={isMacBookM5Max
? 'MacBook M5 Max Qwen3.6-27B (직접 호출)'
: 'Check "This is M5 Max" toggle to enable'}
>
{isMacBookM5Max ? 'This device (Qwen MacBook)' : 'This device (unavailable)'}
</option>
<option
value="claude-cloud"
disabled={!CLOUD_DEV_ENABLED}
title={CLOUD_DEV_ENABLED
? 'Claude Cloud (dev mode — returns 503 until activation PR)'
: 'Cloud backend not configured yet'}
>
Claude Cloud {CLOUD_DEV_ENABLED ? '(dev)' : '(unavailable)'}
</option>
</select>
</div>
</div>
<!-- 본문 -->
<div class="max-w-[1680px] mx-auto p-4">
{#if backendUnavailable}
<div class="py-16">
<EmptyState
icon={AlertCircle}
title="Backend 응답하지 않습니다"
description={backendUnavailableMessage}
>
<Button variant="primary" size="sm" onclick={retryWithMacMiniDefault}>
Mac mini (default) 로 재요청
</Button>
</EmptyState>
</div>
{:else if !queryInput && !loading && !data}
<div class="py-16">
<EmptyState
icon={Sparkles}
title="근거 기반 답변을 받아보세요"
description="질문을 입력하면 문서에서 근거를 찾아 인용 기반 답변을 생성합니다."
/>
</div>
{:else}
<div class="grid gap-4 lg:grid-cols-[1.2fr_0.9fr] items-start">
<!-- 좌: Answer + Results -->
<div class="flex flex-col gap-4 min-w-0">
<AskAnswer {data} {loading} onCitationClick={scrollToCitation} />
<AskResults {data} {loading} />
</div>
<!-- 우: Evidence (lg 이상 sticky) -->
<div class="lg:sticky lg:top-20 lg:self-start min-w-0">
<AskEvidence
{data}
{loading}
{activeCitation}
{registerCitation}
/>
</div>
</div>
{/if}
</div>
</div>
+1 -49
View File
@@ -8,7 +8,7 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { X, Plus, Trash2, Tag, FolderTree, Sparkles, ArrowUpDown } from 'lucide-svelte';
import { X, Plus, Trash2, Tag, FolderTree, ArrowUpDown } from 'lucide-svelte';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
import { isMdStatusVisible } from '$lib/utils/mdStatus';
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
@@ -22,9 +22,7 @@
import { useIsXl } from '$lib/composables/useMedia.svelte';
import { useListKeyboardNav } from '$lib/composables/useListKeyboardNav.svelte';
import { pLimit } from '$lib/utils/pLimit';
import { isQuestion } from '$lib/utils/isQuestion';
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
import AskAnswerCard from '$lib/components/AskAnswerCard.svelte';
const FORMATS = ['pdf', 'hwp', 'hwpx', 'md', 'docx', 'xlsx', 'png', 'jpg'];
@@ -48,30 +46,6 @@
let searchResults = $state(null);
let selectedDoc = $state(null);
// 이드 답변 상태 (질문형 검색)
let askData = $state(null);
let askLoading = $state(false);
let askError = $state(false);
let askDismissed = $state(false);
async function askSearch(q) {
askLoading = true; askError = false; askData = null;
try {
askData = await api(`/search/ask?q=${encodeURIComponent(q)}&limit=10`);
} catch {
askError = true; askData = null;
} finally {
askLoading = false;
}
}
let showAskCard = $derived(
!askDismissed && (
askLoading ||
(askData && !askData.refused && askData.ai_answer && askData.synthesis_status === 'completed')
)
);
// 인스펙터(우측) 토글 — xl+ inline, < xl Drawer.
const isXl = useIsXl();
let inspectorOpen = $state(
@@ -145,7 +119,6 @@
selectedDoc = null;
selectedIds = new Set();
kbIndex = 0;
askData = null; askLoading = false; askError = false; askDismissed = false;
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
if (urlQ) doSearch(urlQ, urlMode);
else loadDocuments();
@@ -191,7 +164,6 @@
async function doSearch(q, mode) {
loading = true;
if (isQuestion(q)) askSearch(q);
try {
const data = await api(`/search/?q=${encodeURIComponent(q)}&mode=${mode}&limit=50`);
searchResults = data.results;
@@ -406,13 +378,6 @@
<option value="trgm">부분매칭</option>
<option value="vector">의미검색</option>
</select>
{#if searchQuery.trim()}
<a
href={`/ask?q=${encodeURIComponent(searchQuery.trim())}`}
class="flex items-center px-2 py-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors"
title="이 쿼리로 AI 답변"
><Sparkles size={14} /></a>
{/if}
</div>
<!-- 필터 칩 row -->
@@ -483,19 +448,6 @@
{/if}
</div>
<!-- AI 답변 (질문형 검색) — 목록 상단 고정, 아래로 목록 스크롤 -->
{#if showAskCard}
<div class="px-3 py-2 shrink-0 border-b border-default max-h-[55vh] overflow-y-auto">
<AskAnswerCard
data={askData}
loading={askLoading}
error={askError}
onCitationClick={(docId) => goto(`/documents/${docId}`)}
onDismiss={() => { askDismissed = true; }}
/>
</div>
{/if}
<!-- 선택 toolbar -->
{#if selectionCount > 0}
<div class="flex flex-wrap items-center gap-2 px-3 py-2 shrink-0 bg-accent/10 border-y border-accent/30">