Compare commits
3 Commits
feat/phase
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563f54d7d5 | ||
|
|
010e25cb23 | ||
|
|
bfdf33b442 |
@@ -1,4 +1,19 @@
|
||||
"""벡터 임베딩 워커 — GPU 서버 bge-m3 호출"""
|
||||
"""벡터 임베딩 워커 — GPU 서버 bge-m3 호출 (doc-level recall vector)
|
||||
|
||||
## 구조 원칙 (영구)
|
||||
|
||||
doc-level embedding 은 "요약 벡터" (recall 담당). chunk-level embedding (chunk_worker)
|
||||
이 precision 을 담당하는 hybrid 구조 (`retrieval_service._search_vector_docs` 참조).
|
||||
|
||||
**본문 일부를 임베딩 입력으로 쓰면 안 된다**. 500k자 교재의 앞 6000자는 표지+목차 —
|
||||
임베딩 품질이 쓰레기가 된다. 대신 AI 가 이미 생성한 `ai_summary` 를 중심으로 한
|
||||
metadata (title + summary + tags) 를 입력으로 사용한다.
|
||||
|
||||
이 선택의 이점:
|
||||
- 입력 길이 ~1500자 이하 → Ollama 기본 context 안전 (num_ctx 조정 불필요)
|
||||
- AI 요약은 "전체 문서의 압축 의미" → doc-level 역할에 정확히 부합
|
||||
- 태그는 상위 semantic signal → noise 없음
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -10,27 +25,59 @@ from models.document import Document
|
||||
|
||||
logger = setup_logger("embed_worker")
|
||||
|
||||
# 임베딩용 텍스트 최대 길이 (bge-m3: 8192 토큰)
|
||||
MAX_EMBED_TEXT = 6000
|
||||
# ─── 품질 가드 상수 ──────────────────────────────────
|
||||
MIN_SUMMARY_CHARS = 50 # 너무 짧은 요약은 저품질 — 본문 fallback 사용
|
||||
MAX_TAGS = 5 # 상위 N개만 (과도한 태그는 임베딩 노이즈)
|
||||
FALLBACK_PREFIX_CHARS = 800 # ai_summary 누락/저품질 시 본문 프리픽스
|
||||
|
||||
EMBED_MODEL_VERSION = "bge-m3"
|
||||
|
||||
|
||||
def _build_embed_input(doc: Document) -> str:
|
||||
"""doc-level recall vector 용 metadata 입력 빌더.
|
||||
|
||||
Returns:
|
||||
임베딩 모델에 보낼 문자열. 평균 ~500~1500자.
|
||||
|
||||
품질 가드:
|
||||
- ai_summary 가 MIN_SUMMARY_CHARS 미만이면 저품질로 보고 본문 fallback
|
||||
- tags 는 상위 MAX_TAGS 개만 (과도한 태그는 임베딩에 노이즈)
|
||||
- ai_domain 은 현 단계에서 제외 (taxonomy 품질이 안정화될 때까지)
|
||||
"""
|
||||
parts = [f"제목: {(doc.title or '').strip()}"]
|
||||
|
||||
summary = (doc.ai_summary or "").strip()
|
||||
use_summary = len(summary) >= MIN_SUMMARY_CHARS
|
||||
if use_summary:
|
||||
parts.append(f"요약: {summary}")
|
||||
|
||||
# tags: 리스트면 상위 MAX_TAGS, 문자열이면 그대로 (이상 케이스)
|
||||
if doc.ai_tags:
|
||||
if isinstance(doc.ai_tags, list):
|
||||
tags_list = [str(t).strip() for t in doc.ai_tags[:MAX_TAGS] if t]
|
||||
tags_str = ", ".join(tags_list)
|
||||
else:
|
||||
tags_str = str(doc.ai_tags)
|
||||
if tags_str:
|
||||
parts.append(f"키워드: {tags_str}")
|
||||
|
||||
# ai_summary 품질 미달 시 본문 프리픽스 fallback (최소 recall 확보)
|
||||
if not use_summary and doc.extracted_text:
|
||||
parts.append(f"본문: {doc.extracted_text[:FALLBACK_PREFIX_CHARS]}")
|
||||
|
||||
return "\n".join(p for p in parts if p).strip()
|
||||
|
||||
|
||||
async def process(document_id: int, session: AsyncSession) -> None:
|
||||
"""문서 벡터 임베딩 생성"""
|
||||
"""문서 벡터 임베딩 생성 (doc-level recall vector)"""
|
||||
doc = await session.get(Document, document_id)
|
||||
if not doc:
|
||||
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||
|
||||
if not doc.extracted_text:
|
||||
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||
|
||||
# title + 본문 앞부분을 결합하여 임베딩 입력 생성
|
||||
title_part = doc.title or ""
|
||||
text_part = doc.extracted_text[:MAX_EMBED_TEXT]
|
||||
embed_input = f"{title_part}\n\n{text_part}".strip()
|
||||
embed_input = _build_embed_input(doc)
|
||||
|
||||
if not embed_input:
|
||||
logger.warning(f"[임베딩] document_id={document_id}: 빈 텍스트, 스킵")
|
||||
logger.warning(f"[임베딩] document_id={document_id}: 빈 입력, 스킵")
|
||||
return
|
||||
|
||||
client = AIClient()
|
||||
@@ -39,6 +86,9 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
doc.embedding = vector
|
||||
doc.embed_model_version = EMBED_MODEL_VERSION
|
||||
doc.embedded_at = datetime.now(timezone.utc)
|
||||
logger.info(f"[임베딩] document_id={document_id}: {len(vector)}차원 벡터 생성")
|
||||
logger.info(
|
||||
f"[임베딩] document_id={document_id}: {len(vector)}차원 벡터 "
|
||||
f"(input_len={len(embed_input)}, has_summary={bool(doc.ai_summary)})"
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@@ -39,7 +39,8 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||
text = full_path.read_text(encoding="utf-8", errors="replace")
|
||||
doc.extracted_text = text
|
||||
# NUL 바이트 제거 (Postgres TEXT 저장 시 CharacterNotInRepertoireError 방지)
|
||||
doc.extracted_text = text.replace("\x00", "")
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "direct_read"
|
||||
logger.info(f"[텍스트] {doc.file_path} ({len(text)}자)")
|
||||
@@ -70,7 +71,8 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
doc.extracted_text = data.get("markdown", "")
|
||||
# NUL 바이트 제거 (Postgres TEXT 저장 시 CharacterNotInRepertoireError 방지)
|
||||
doc.extracted_text = data.get("markdown", "").replace("\x00", "")
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = EXTRACTOR_VERSION
|
||||
logger.info(f"[kordoc] {doc.file_path} ({len(doc.extracted_text)}자)")
|
||||
@@ -106,7 +108,8 @@ async def process(document_id: int, session: AsyncSession) -> None:
|
||||
out_file = tmp_dir / f"input_{document_id}.{out_ext}"
|
||||
if out_file.exists():
|
||||
text = out_file.read_text(encoding="utf-8", errors="replace")
|
||||
doc.extracted_text = text[:15000]
|
||||
# NUL 바이트 제거 (Postgres TEXT 저장 시 CharacterNotInRepertoireError 방지)
|
||||
doc.extracted_text = text.replace("\x00", "")[:15000]
|
||||
doc.extracted_at = datetime.now(timezone.utc)
|
||||
doc.extractor_version = "libreoffice"
|
||||
out_file.unlink()
|
||||
|
||||
@@ -133,7 +133,9 @@ async def consume_queue():
|
||||
if not item:
|
||||
logger.warning(f"[{stage}] queue_id={queue_id} 없음 (삭제됨?), skip")
|
||||
continue
|
||||
item.error_message = str(e)[:500]
|
||||
# 빈 메시지 방어: str → repr → 클래스명 순 fallback
|
||||
err_text = str(e) or repr(e) or type(e).__name__
|
||||
item.error_message = err_text[:500]
|
||||
if item.attempts >= item.max_attempts:
|
||||
item.status = "failed"
|
||||
logger.error(f"[{stage}] document_id={document_id} 영구 실패: {e}")
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
let { onupload = () => {} } = $props();
|
||||
|
||||
// home-caddy `request_body max_size 100MB` (infra_inventory.md D8 / Cloudflare 섹션 참조).
|
||||
// 100MB 초과 파일은 NAS PKM 폴더 직접 마운트 → file_watcher 5분 간격 자동 인덱싱 경로 사용.
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||
const NAS_FALLBACK_HINT = '대용량 파일은 NAS의 PKM 폴더에 직접 두면 file_watcher 가 5분 이내에 자동 인덱싱합니다.';
|
||||
|
||||
let dragging = $state(false);
|
||||
let uploading = $state(false);
|
||||
let uploadFiles = $state([]);
|
||||
@@ -56,7 +61,24 @@
|
||||
});
|
||||
|
||||
async function handleFiles(fileList) {
|
||||
const files = Array.from(fileList || []);
|
||||
const allFiles = Array.from(fileList || []);
|
||||
if (allFiles.length === 0) return;
|
||||
|
||||
// 사전 크기 검사 — 100MB 초과는 즉시 차단 + NAS file_watcher 안내
|
||||
const tooLarge = allFiles.filter(f => f.size > MAX_UPLOAD_BYTES);
|
||||
const files = allFiles.filter(f => f.size <= MAX_UPLOAD_BYTES);
|
||||
|
||||
if (tooLarge.length > 0) {
|
||||
const names = tooLarge
|
||||
.map(f => `${f.name} (${(f.size / 1024 / 1024).toFixed(1)}MB)`)
|
||||
.join(', ');
|
||||
addToast(
|
||||
'error',
|
||||
`100MB 초과 파일은 업로드 불가 (${tooLarge.length}건: ${names}). ${NAS_FALLBACK_HINT}`,
|
||||
10000
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
uploading = true;
|
||||
@@ -78,6 +100,14 @@
|
||||
} catch (err) {
|
||||
uploadFiles[i].status = 'failed';
|
||||
failed++;
|
||||
// 서버 측 413 (사전 검사 통과했지만 인프라 한도에 걸린 경우)
|
||||
if (err && err.status === 413) {
|
||||
addToast(
|
||||
'error',
|
||||
`${files[i].name}: 서버 거절 (Payload Too Large). ${NAS_FALLBACK_HINT}`,
|
||||
10000
|
||||
);
|
||||
}
|
||||
}
|
||||
uploadFiles = [...uploadFiles];
|
||||
}
|
||||
|
||||
144
frontend/src/lib/components/ask/AskAnswer.svelte
Normal file
144
frontend/src/lib/components/ask/AskAnswer.svelte
Normal file
@@ -0,0 +1,144 @@
|
||||
<!--
|
||||
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 오류',
|
||||
};
|
||||
|
||||
let tokens = $derived(data?.ai_answer ? splitAnswer(data.ai_answer) : []);
|
||||
let showAnswer = $derived(
|
||||
!!data && !!data.ai_answer && data.synthesis_status === 'completed' && !data.refused,
|
||||
);
|
||||
let showWarning = $derived(!!data && !showAnswer);
|
||||
</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>
|
||||
<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 showAnswer && 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 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>
|
||||
91
frontend/src/lib/components/ask/AskEvidence.svelte
Normal file
91
frontend/src/lib/components/ask/AskEvidence.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<!--
|
||||
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>
|
||||
78
frontend/src/lib/components/ask/AskResults.svelte
Normal file
78
frontend/src/lib/components/ask/AskResults.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
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>
|
||||
64
frontend/src/lib/types/ask.ts
Normal file
64
frontend/src/lib/types/ask.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 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;
|
||||
}
|
||||
@@ -80,6 +80,7 @@
|
||||
<SystemStatusDot />
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" href="/ask">질문</Button>
|
||||
<Button variant="ghost" size="sm" href="/news">뉴스</Button>
|
||||
<Button variant="ghost" size="sm" href="/inbox">Inbox</Button>
|
||||
<Button variant="ghost" size="sm" href="/settings">설정</Button>
|
||||
|
||||
150
frontend/src/routes/ask/+page.svelte
Normal file
150
frontend/src/routes/ask/+page.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<!--
|
||||
/ask — Phase 3.4 Ask Pipeline Frontend.
|
||||
|
||||
URL-driven: `/ask?q=<encoded>` 가 진입점. $effect 로 q 변화 감지 →
|
||||
`/api/search/ask` 호출 → 3-panel 렌더 (Answer / Evidence / Results).
|
||||
|
||||
중복 호출 방지: lastQuery 가드 (hydration + reactive trigger 시 같은 q 2번 발동 방지).
|
||||
-->
|
||||
<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 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 type { AskResponse } from '$lib/types/ask';
|
||||
|
||||
// ── state ───────────────────────────────────────────
|
||||
let queryInput = $state('');
|
||||
let data = $state<AskResponse | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
// 중복 호출 방지 가드 (hydration + reactive trigger 이중 발동 방지)
|
||||
let lastQuery = '';
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
// ── submit (URL-driven, back 자동) ──────────────────
|
||||
function submit() {
|
||||
const q = queryInput.trim();
|
||||
if (!q) return;
|
||||
goto(`/ask?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
// ── API 호출 ───────────────────────────────────────
|
||||
async function runAsk(q: string) {
|
||||
loading = true;
|
||||
activeCitation = null;
|
||||
try {
|
||||
data = await api<AskResponse>(
|
||||
`/search/ask?q=${encodeURIComponent(q)}&limit=10`,
|
||||
);
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
addToast('error', apiErr.detail || '답변 생성 실패');
|
||||
data = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL → runAsk (중복 가드) ────────────────────────
|
||||
$effect(() => {
|
||||
const q = $page.url.searchParams.get('q') ?? '';
|
||||
queryInput = q;
|
||||
if (!q) {
|
||||
data = null;
|
||||
loading = false;
|
||||
lastQuery = '';
|
||||
return;
|
||||
}
|
||||
if (q === lastQuery) return;
|
||||
lastQuery = q;
|
||||
runAsk(q);
|
||||
});
|
||||
</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 items-center gap-2 max-w-5xl mx-auto">
|
||||
<div class="relative flex-1">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
<div class="max-w-5xl mx-auto p-4">
|
||||
{#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>
|
||||
@@ -3,7 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree, Rows3, Rows2 } from 'lucide-svelte';
|
||||
import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree, Rows3, Rows2, Sparkles } from 'lucide-svelte';
|
||||
import DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||
import DocumentTable from '$lib/components/DocumentTable.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
@@ -441,6 +441,15 @@
|
||||
<option value="trgm">부분매칭</option>
|
||||
<option value="vector">의미검색</option>
|
||||
</select>
|
||||
{#if searchQuery.trim()}
|
||||
<a
|
||||
href={`/ask?q=${encodeURIComponent(searchQuery.trim())}`}
|
||||
class="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors text-xs"
|
||||
title="이 쿼리로 AI 답변 보기"
|
||||
>
|
||||
<Sparkles size={14} /> AI 답변
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={toggleViewMode}
|
||||
class="p-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user