3 Commits

Author SHA1 Message Date
Hyungi Ahn
563f54d7d5 fix(upload): 100MB 초과 파일 사전 차단 + NAS file_watcher 안내
home-caddy 의 request_body max_size 100MB 한도 (infra_inventory.md D8 /
Cloudflare 섹션 참조) 에 걸리는 업로드 시 사용자 콘솔에 의미 없는 413 만
나오던 문제. 이제:

1. 클라이언트 사전 검사: 100MB 초과 파일은 업로드 자체를 시도 안 하고
   즉시 toast 로 안내 (파일명 + 크기 + NAS 우회 경로)
2. 서버 fallback: 사전 검사를 통과했으나 인프라 한도에 걸려 413 응답이
   오는 경우에도 같은 안내 메시지

NAS 우회 경로: NAS 의 PKM 폴더에 직접 두면 file_watcher 가 5분 간격으로
자동 인덱싱. 이게 100MB+ 파일의 정식 처리 경로 (infra_inventory.md
Cloudflare 섹션의 413 정책).
2026-04-09 14:26:18 +09:00
Hyungi Ahn
010e25cb23 fix(queue): doc-level embed metadata 기반 + NUL 바이트 strip + 빈 예외 fallback
embed_worker:
- extracted_text[:6000] → title + ai_summary + tags(top 5) metadata 입력
- 500k자 문서의 표지+목차가 임베딩되는 구조적 버그 해결
- Ollama 기본 context 안전 (~1500자 이하), num_ctx 조정 불필요
- ai_summary < 50자 시 본문 800자 fallback
- ai_domain 은 초기 제외 (taxonomy 노이즈 방지)

extract_worker:
- kordoc / 직접 읽기 / LibreOffice 3 경로 모두 \x00 strip
- asyncpg CharacterNotInRepertoireError 재발 방지

queue_consumer:
- str(e) or repr(e) or type(e).__name__ fallback
- 빈 메시지 예외(24건 발생) 다음부터 클래스명이라도 기록

plan: ~/.claude/plans/quiet-meandering-nova.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:45:55 +09:00
Hyungi Ahn
bfdf33b442 feat(frontend): Phase 3.4 Ask pipeline UI (/ask 3-panel)
- routes/ask/+page.svelte: URL-driven orchestrator, lastQuery guard
  (hydration 중복 호출 방지), citation scroll 연동
- lib/components/ask/AskAnswer: answer body + clickable [n] +
  confidence/status Badge + warning EmptyState (no_results_reason +
  /documents?q=<same> 역링크)
- lib/components/ask/AskEvidence: span_text ONLY 렌더 (full_snippet
  금지 룰 컴포넌트 주석에 박음) + active highlight + doc-group ordering 유지
- lib/components/ask/AskResults: inline 카드 (DocumentCard 의존 회피)
- lib/types/ask.ts: backend AskResponse 스키마 1:1 매칭
- +layout.svelte: 탑 nav 질문 버튼 추가
- documents/+page.svelte: 검색바 옆 AI 답변 링크 (searchQuery 있을 때만)

plan: ~/.claude/plans/quiet-meandering-nova.md (Phase 3.4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:45:24 +09:00
10 changed files with 610 additions and 18 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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}")

View 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>

View 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>

View 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>

View 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;
}

View File

@@ -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>

View 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>

View File

@@ -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"