2d6d1b8e8a
본문 $$수식$$가 raw로 노출되던 문제: marked-katex 토크나이저가 개요 anchor splice/런타임 환경 영향으로 미발화 → marked 이전에 katex.renderToString 으로 직접 렌더 후 placeholder 복원(위치·인접 무관). TL;DR(ai_tldr)도 plain-text 보간이라 마크다운 미렌더 → renderDocMarkdown 경유로 교체(+summary-md 스타일). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
223 lines
7.5 KiB
Svelte
223 lines
7.5 KiB
Svelte
<!--
|
|
AnalysisPanel.svelte — 문서 상세 페이지 AI 분석 패널.
|
|
|
|
PR-B B-3 (2026-04-24): 두 섹션 구성.
|
|
(A) tier triage 자동 표시 — doc.ai_tldr / ai_bullets / ai_detail_summary /
|
|
ai_inconsistencies 가 있으면 버튼 없이 자동 렌더. classify_worker 파이프
|
|
라인 산출물이라 별도 호출 없이 문서 진입 즉시 보임.
|
|
(B) 고급 분석 (빠른 분석) — 기존 POST /api/documents/{id}/analyze 경로.
|
|
layer 4개를 map-reduce 청킹으로 생성. 버튼 클릭 필요.
|
|
|
|
docId 변경 시 (B) 섹션 state 리셋.
|
|
-->
|
|
<script lang="ts">
|
|
import { api } from '$lib/api';
|
|
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
|
|
import Badge from '$lib/components/ui/Badge.svelte';
|
|
import Button from '$lib/components/ui/Button.svelte';
|
|
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
|
import { Sparkles, RotateCcw, CalendarClock, GitBranch, Quote, HelpCircle } from 'lucide-svelte';
|
|
import type { AnalyzeResponse } from '$lib/types/analyze';
|
|
|
|
interface Inconsistency {
|
|
kind: 'version_drift' | 'procedure_conflict' | 'source_conflict' | 'missing_basis';
|
|
desc: string;
|
|
}
|
|
|
|
interface DocTierFields {
|
|
ai_tldr?: string | null;
|
|
ai_bullets?: string[] | null;
|
|
ai_detail_summary?: string | null;
|
|
ai_inconsistencies?: Inconsistency[] | null;
|
|
ai_analysis_tier?: 'triage' | 'deep' | null;
|
|
}
|
|
|
|
interface Props {
|
|
docId: number;
|
|
doc?: DocTierFields | null;
|
|
}
|
|
|
|
let { docId, doc }: Props = $props();
|
|
|
|
// ─── (A) tier triage 자동 표시 ───
|
|
let tldr = $derived(doc?.ai_tldr ?? null);
|
|
let bullets = $derived(doc?.ai_bullets ?? null);
|
|
let detail = $derived(doc?.ai_detail_summary ?? null);
|
|
let inconsistencies = $derived(doc?.ai_inconsistencies ?? null);
|
|
let tier = $derived(doc?.ai_analysis_tier ?? null);
|
|
let hasTier = $derived(!!tldr || !!detail || (bullets && bullets.length > 0));
|
|
|
|
const INC_ICON: Record<string, any> = {
|
|
version_drift: CalendarClock,
|
|
procedure_conflict: GitBranch,
|
|
source_conflict: Quote,
|
|
missing_basis: HelpCircle,
|
|
};
|
|
const INC_LABEL: Record<string, string> = {
|
|
version_drift: '개정/후속 차이',
|
|
procedure_conflict: '절차 충돌',
|
|
source_conflict: '출처 간 차이',
|
|
missing_basis: '근거 부족',
|
|
};
|
|
|
|
// ─── (B) 고급 분석 (기존 /analyze) ───
|
|
let data = $state<AnalyzeResponse | null>(null);
|
|
let loading = $state(false);
|
|
let error = $state<string | null>(null);
|
|
|
|
$effect(() => {
|
|
const _id = docId;
|
|
data = null;
|
|
loading = false;
|
|
error = null;
|
|
});
|
|
|
|
async function runAnalysis() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
data = await api<AnalyzeResponse>(`/documents/${docId}/analyze`, {
|
|
method: 'POST',
|
|
});
|
|
} catch {
|
|
error = '분석 결과를 정리하지 못했습니다. 다시 시도해 주세요.';
|
|
data = null;
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div>
|
|
<!-- ─── (A) tier triage 자동 표시 (B-3) ─── -->
|
|
{#if hasTier}
|
|
<div class="mb-3 pb-3 border-b border-default/60">
|
|
<div class="flex items-center justify-between gap-2 mb-2">
|
|
<h4 class="text-xs font-semibold text-dim uppercase flex items-center gap-1">
|
|
<Sparkles size={12} class="text-accent" />
|
|
AI 요약
|
|
</h4>
|
|
{#if tier === 'deep'}
|
|
<Badge tone="accent" size="sm">깊은 분석 (26B)</Badge>
|
|
{:else if tier === 'triage'}
|
|
<Badge tone="neutral" size="sm">짧은 분석 (4B)</Badge>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if tldr}
|
|
<div class="summary-md text-xs font-medium text-text leading-relaxed mb-2">{@html renderDocMarkdown(tldr)}</div>
|
|
{/if}
|
|
|
|
{#if bullets && bullets.length > 0}
|
|
<ul class="list-disc pl-4 space-y-0.5 text-xs text-text leading-relaxed mb-2">
|
|
{#each bullets as b}
|
|
<li>{b}</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if detail}
|
|
<div class="mt-2">
|
|
<p class="text-[10px] font-medium text-accent uppercase tracking-wider mb-1">
|
|
상세
|
|
</p>
|
|
<p class="text-xs text-text leading-relaxed whitespace-pre-wrap">
|
|
{detail}
|
|
</p>
|
|
</div>
|
|
{:else if tier === 'triage'}
|
|
<p class="mt-1.5 text-[10px] text-faint">
|
|
상세 요약 대기 중 (긴 문서/핵심 결정 문서일 때만 26B 생성).
|
|
</p>
|
|
{/if}
|
|
|
|
{#if inconsistencies && inconsistencies.length > 0}
|
|
<div class="mt-3 space-y-1.5">
|
|
<p class="text-[10px] font-medium text-warning uppercase tracking-wider">
|
|
일관성 이슈
|
|
</p>
|
|
{#each inconsistencies as it}
|
|
{@const Icon = INC_ICON[it.kind] ?? HelpCircle}
|
|
<div class="flex items-start gap-1.5 text-xs text-warning">
|
|
<Icon size={12} class="mt-0.5 shrink-0" />
|
|
<div>
|
|
<span class="font-medium">{INC_LABEL[it.kind] ?? it.kind}</span>
|
|
<span class="text-text">: {it.desc}</span>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ─── (B) 기존 고급 분석 ─── -->
|
|
<div class="flex items-center justify-between gap-2 mb-1.5">
|
|
<h4 class="text-xs font-semibold text-dim uppercase flex items-center gap-1">
|
|
<Sparkles size={12} class="text-accent" />
|
|
고급 분석
|
|
</h4>
|
|
{#if data?.cached}
|
|
<Badge tone="neutral" size="sm">캐시</Badge>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !data && !loading && !error}
|
|
<!-- 기본 접힌 상태: 버튼만 -->
|
|
<Button variant="secondary" size="sm" icon={Sparkles} onclick={runAnalysis}>
|
|
빠른 분석
|
|
</Button>
|
|
<p class="mt-1.5 text-[10px] text-dim leading-relaxed">
|
|
문서 앞부분(최대 12,000자)을 4층 구조 (evidence / explanation / examples /
|
|
summary) 로 구조화합니다. 약 10~40초 소요.
|
|
</p>
|
|
{:else if loading}
|
|
<div class="space-y-2">
|
|
<Skeleton w="w-20" h="h-3" />
|
|
<Skeleton w="w-full" h="h-12" />
|
|
<Skeleton w="w-20" h="h-3" />
|
|
<Skeleton w="w-full" h="h-12" />
|
|
</div>
|
|
<p class="mt-2 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-error mb-2">{error}</p>
|
|
<Button variant="ghost" size="sm" icon={RotateCcw} onclick={runAnalysis}>
|
|
다시 시도
|
|
</Button>
|
|
{:else if data}
|
|
<div class="space-y-3">
|
|
{#each data.layers as layer}
|
|
<div>
|
|
<p class="text-[10px] font-medium text-accent uppercase tracking-wider mb-1">
|
|
{layer.title}
|
|
</p>
|
|
<p class="text-xs text-text leading-relaxed whitespace-pre-wrap">
|
|
{layer.content}
|
|
</p>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{#if data.truncated}
|
|
<p class="mt-2.5 text-[10px] text-warning">
|
|
※ 원문 12,000자 초과 — 앞부분만 분석됨. 전체 분석은 추후 제공 예정.
|
|
</p>
|
|
{/if}
|
|
<div class="mt-2.5 flex items-center justify-between">
|
|
<span class="text-[10px] text-dim">
|
|
{Math.round(data.elapsed_ms)}ms · {data.layers.length}개 층
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onclick={runAnalysis}
|
|
class="text-[10px] text-dim hover:text-text flex items-center gap-0.5"
|
|
title="다시 분석"
|
|
>
|
|
<RotateCcw size={10} /> 재분석
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|