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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user