From 86a71ec4d1178ca441b91d4e4a48851fd11849a0 Mon Sep 17 00:00:00 2001 From: hyungi Date: Sat, 27 Jun 2026 12:48:13 +0900 Subject: [PATCH] =?UTF-8?q?refactor(search)!:=20/ask=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20UI=20=EC=A0=9C=EA=B1=B0=20(=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=ED=99=94=20=E2=80=94=20AI=EB=8B=B5=EB=B3=80?= =?UTF-8?q?=3Deid=20/chat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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) --- .../src/lib/components/AskAnswerCard.svelte | 160 --------- .../src/lib/components/ask/AskAnswer.svelte | 228 ------------- .../src/lib/components/ask/AskEvidence.svelte | 91 ------ .../src/lib/components/ask/AskResults.svelte | 78 ----- frontend/src/lib/types/ask.ts | 84 ----- frontend/src/lib/utils/isQuestion.ts | 61 ---- frontend/src/routes/ask/+page.svelte | 305 ------------------ frontend/src/routes/documents/+page.svelte | 50 +-- 8 files changed, 1 insertion(+), 1056 deletions(-) delete mode 100644 frontend/src/lib/components/AskAnswerCard.svelte delete mode 100644 frontend/src/lib/components/ask/AskAnswer.svelte delete mode 100644 frontend/src/lib/components/ask/AskEvidence.svelte delete mode 100644 frontend/src/lib/components/ask/AskResults.svelte delete mode 100644 frontend/src/lib/types/ask.ts delete mode 100644 frontend/src/lib/utils/isQuestion.ts delete mode 100644 frontend/src/routes/ask/+page.svelte diff --git a/frontend/src/lib/components/AskAnswerCard.svelte b/frontend/src/lib/components/AskAnswerCard.svelte deleted file mode 100644 index e830493..0000000 --- a/frontend/src/lib/components/AskAnswerCard.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - - -
- -
-
- - - 내 자료 기준 답변 - - {#if data?.confidence} - - 신뢰도 {confidenceLabel(data.confidence)} - - {/if} - {#if data?.completeness === 'partial'} - 일부 답변 - {/if} -
- -
- - - {#if loading} -
- - -
-

- - 근거 기반 답변 생성 중… -

- {:else if error} -

답변을 가져오지 못했습니다.

- {:else if data?.ai_answer} - -
- {#each tokens as tok} - {#if tok.type === 'cite'} - {@const citation = data?.citations?.find((c) => c.n === tok.n)} - {#if citation} - - {:else} - {tok.raw} - {/if} - {:else} - {tok.value} - {/if} - {/each} -
- - - {#if data.completeness === 'partial' && data.missing_aspects?.length} -

- 다루지 못한 부분: {data.missing_aspects.join(', ')} -

- {/if} - - - {#if uniqueCitations.length > 0} -
-

출처

-
- {#each uniqueCitations as citation} - - {/each} -
-
- {/if} - {/if} -
diff --git a/frontend/src/lib/components/ask/AskAnswer.svelte b/frontend/src/lib/components/ask/AskAnswer.svelte deleted file mode 100644 index d125a63..0000000 --- a/frontend/src/lib/components/ask/AskAnswer.svelte +++ /dev/null @@ -1,228 +0,0 @@ - - - -
- -
-
-

- AI Answer -

-

근거 기반 답변

-
- - {#if data && !loading} -
- - 신뢰도 {confidenceLabel(data.confidence)} - - {#if backendChipLabel(data.backend_requested)} - - - {backendChipLabel(data.backend_requested)} - - - {/if} - - {STATUS_LABEL[data.synthesis_status]} - - {#if data.synthesis_ms > 0} - - {Math.round(data.synthesis_ms)}ms - - {/if} -
- {/if} -
- - - {#if loading} -
- - - -

- - 근거 기반 답변 생성 중… 약 15초 소요 -

-
- {:else if showFullAnswer && data} -
- {#each tokens as tok} - {#if tok.type === 'cite'} - - {:else} - {tok.value} - {/if} - {/each} -
- {:else if showPartial && data} - -
- 일부 답변 - - {#if data.ai_answer} -
- {#each tokens as tok} - {#if tok.type === 'cite'} - - {:else} - {tok.value} - {/if} - {/each} -
- {:else if data.confirmed_items?.length} -
-

✓ 답변 가능

-
    - {#each data.confirmed_items as item} -
  • - {item.aspect}: - {item.text} - {#each item.citations as n} - - {/each} -
  • - {/each} -
-
- {/if} - - {#if data.missing_aspects?.length} -
-

✗ 답변 불가

-
    - {#each data.missing_aspects as aspect} -
  • {aspect} (근거 없음)
  • - {/each} -
-
- {/if} - -
- -
-
- {:else if showWarning && data} - - - - {/if} -
diff --git a/frontend/src/lib/components/ask/AskEvidence.svelte b/frontend/src/lib/components/ask/AskEvidence.svelte deleted file mode 100644 index 55733c4..0000000 --- a/frontend/src/lib/components/ask/AskEvidence.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - - -
-
-
-

- Evidence Highlights -

-

인용 근거

-
- {#if data && !loading} - {citations.length}개 - {/if} -
- - {#if loading} -
- {#each Array(2) as _} -
- - - - -
- {/each} -
- {:else if citations.length === 0} - - {:else} -
- {#each citations as citation (citation.n)} - {@const isActive = activeCitation === citation.n} -
-
- [{citation.n}] -
- - {citation.title ?? `문서 ${citation.doc_id}`} - - {#if citation.section_title} -

{citation.section_title}

- {/if} -
-
- - -

- {citation.span_text} -

- -
- relevance {citation.relevance.toFixed(2)} - rerank {citation.rerank_score.toFixed(2)} -
-
- {/each} -
- {/if} -
diff --git a/frontend/src/lib/components/ask/AskResults.svelte b/frontend/src/lib/components/ask/AskResults.svelte deleted file mode 100644 index 1272988..0000000 --- a/frontend/src/lib/components/ask/AskResults.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - -
-
-
-

- Search Results -

-

검색 결과

-
- {#if data && !loading} - {data.total}개 - {/if} -
- - {#if loading} -
- {#each Array(5) as _} -
- - - -
- {/each} -
- {:else if results.length === 0} - - {:else} - - {/if} -
diff --git a/frontend/src/lib/types/ask.ts b/frontend/src/lib/types/ask.ts deleted file mode 100644 index 826d976..0000000 --- a/frontend/src/lib/types/ask.ts +++ /dev/null @@ -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; -} diff --git a/frontend/src/lib/utils/isQuestion.ts b/frontend/src/lib/utils/isQuestion.ts deleted file mode 100644 index dc928ce..0000000 --- a/frontend/src/lib/utils/isQuestion.ts +++ /dev/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; -} diff --git a/frontend/src/routes/ask/+page.svelte b/frontend/src/routes/ask/+page.svelte deleted file mode 100644 index e0a67e8..0000000 --- a/frontend/src/routes/ask/+page.svelte +++ /dev/null @@ -1,305 +0,0 @@ - - - - - 질문 - PKM - - -
- -
-
-
- - -
- - -
-
- - -
- {#if backendUnavailable} -
- - - -
- {:else if !queryInput && !loading && !data} -
- -
- {:else} -
- -
- - -
- - -
- -
-
- {/if} -
-
diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 606df39..bcdb676 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -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 @@ - {#if searchQuery.trim()} - - {/if} @@ -483,19 +448,6 @@ {/if} - - {#if showAskCard} -
- goto(`/documents/${docId}`)} - onDismiss={() => { askDismissed = true; }} - /> -
- {/if} - {#if selectionCount > 0}