feat(ui): B-3 정보창 tier 자동 표시 + 대시보드 3종 카드
정보창 (AnalysisPanel):
- doc prop 추가. doc.ai_tldr / ai_bullets / ai_detail_summary / ai_inconsistencies
있으면 버튼 없이 자동 렌더 (Section A).
- tier 배지 (triage=흰 / deep=파랑) + tldr + bullets + detail 계층 카드.
- inconsistencies kind 별 아이콘: version_drift=Calendar / procedure_conflict=
GitBranch / source_conflict=Quote / missing_basis=HelpCircle. warning 톤.
- 기존 "고급 분석" 버튼 (/documents/{id}/analyze 4층 응답) 은 Section B 로 유지.
AIClassificationEditor:
- 제목 옆 tier 배지 ("깊이" accent / "짧음" neutral) — ai_analysis_tier 값 기준.
대시보드 (B-3 3종 카드):
- "에스컬레이션 비율 (24h)": escalated_to_26b / triage_total. 20% 초과 적색,
1% 미만 회색 (false negative 신호). reason 상위 4개 뱃지.
- "triage JSON 건강도 (24h)": error_code='triage_json_invalid' / triage_total.
5% 초과 적색 (프롬프트/모델 이슈).
- "Backlog Suppression (24h)": suppressed_reason IS NOT NULL / triage_total.
10% 초과 주황 (임계치 재조정 신호).
Backend:
- dashboard.py 에 TierHealthStack 모델 + analyze_events 24h 집계 쿼리.
- escalation_by_reason (unnest(escalation_reasons)) + escalation_by_domain
(subject_domain) 서브 집계.
Frontend types:
- stores/system.ts DashboardSummary 에 tier_health 옵셔널 필드 추가.
UI 는 PR-A shadow 기간에도 tier_health.triage_total > 0 조건으로 조건부 표시 —
데이터가 없으면 카드 자체가 숨겨져 첫 삽입 시 UX 충격 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,36 +1,69 @@
|
||||
<!--
|
||||
AnalysisPanel.svelte — 문서 상세 페이지 "빠른 분석" 패널 (Phase D.6).
|
||||
AnalysisPanel.svelte — 문서 상세 페이지 AI 분석 패널.
|
||||
|
||||
기본 접힌 상태 + "빠른 분석" 버튼.
|
||||
클릭 시 POST /api/documents/{docId}/analyze 호출 후 층별 결과 표시.
|
||||
docId 변경 시 state 완전 리셋 ($effect).
|
||||
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 청킹으로 생성. 버튼 클릭 필요.
|
||||
|
||||
※ 현재는 문서 앞부분(최대 12,000자)만 분석하는 "빠른 분석"이다.
|
||||
전체 문서 coverage가 필요한 "전체 분석"은 다음 iteration에서
|
||||
map-reduce 청킹 기반으로 별도 버튼으로 추가 예정.
|
||||
|
||||
상세 페이지의 editors stack에서 <Card>로 래핑되어 사용됨 (D.7).
|
||||
docId 변경 시 (B) 섹션 state 리셋.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
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 } from 'lucide-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 }: Props = $props();
|
||||
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);
|
||||
|
||||
// docId 변경 시 state 완전 리셋
|
||||
// (프론트 캐시: 같은 docId 재방문 시 재호출 방지. 서버 캐시와 독립)
|
||||
$effect(() => {
|
||||
const _id = docId;
|
||||
data = null;
|
||||
@@ -55,10 +88,75 @@
|
||||
</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}
|
||||
<p class="text-xs font-medium text-text leading-relaxed mb-2">
|
||||
{tldr}
|
||||
</p>
|
||||
{/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>
|
||||
@@ -71,9 +169,8 @@
|
||||
빠른 분석
|
||||
</Button>
|
||||
<p class="mt-1.5 text-[10px] text-dim leading-relaxed">
|
||||
문서 앞부분(최대 12,000자)을 Gemma 4로 구조화합니다. 약 10~40초 소요.
|
||||
<br/>
|
||||
<span class="text-faint">※ 전체 분석은 추후 제공 예정.</span>
|
||||
문서 앞부분(최대 12,000자)을 4층 구조 (evidence / explanation / examples /
|
||||
summary) 로 구조화합니다. 약 10~40초 소요.
|
||||
</p>
|
||||
{:else if loading}
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
{doc.importance}
|
||||
</Badge>
|
||||
{/if}
|
||||
<!-- B-3: tier 배지 (triage=4B 흰/deep=26B 파랑) -->
|
||||
{#if doc.ai_analysis_tier === 'deep'}
|
||||
<Badge tone="accent" size="sm" title="26B 가 깊은 요약까지 작성">깊이</Badge>
|
||||
{:else if doc.ai_analysis_tier === 'triage'}
|
||||
<Badge tone="neutral" size="sm" title="4B 짧은 분석만 완료">짧음</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -34,6 +34,15 @@ export interface QueueLag {
|
||||
oldest_pending_age_sec: number | null;
|
||||
}
|
||||
|
||||
export interface TierHealthStack {
|
||||
triage_total: number;
|
||||
escalated_total: number;
|
||||
escalation_by_reason: Record<string, number>;
|
||||
escalation_by_domain: Record<string, number>;
|
||||
triage_json_invalid: number;
|
||||
suppressed_total: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
today_added: number;
|
||||
today_by_domain: DomainCount[];
|
||||
@@ -50,6 +59,8 @@ export interface DashboardSummary {
|
||||
category_counts: Record<string, number>;
|
||||
library_pending_suggestions: number;
|
||||
queue_lag: QueueLag[];
|
||||
// B-3 — tier 관측성 3종 카드
|
||||
tier_health?: TierHealthStack;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
@@ -319,6 +319,75 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 3.6. tier 관측성 3종 카드 (B-3) ═══ -->
|
||||
{#if summary.tier_health && summary.tier_health.triage_total > 0}
|
||||
{@const th = summary.tier_health}
|
||||
{@const esc_rate = th.triage_total > 0 ? th.escalated_total / th.triage_total : 0}
|
||||
{@const json_rate = th.triage_total > 0 ? th.triage_json_invalid / th.triage_total : 0}
|
||||
{@const sup_rate = th.triage_total > 0 ? th.suppressed_total / th.triage_total : 0}
|
||||
{@const esc_tone = esc_rate > 0.20 ? 'text-error' : (esc_rate < 0.01 ? 'text-dim' : 'text-text')}
|
||||
{@const json_tone = json_rate > 0.05 ? 'text-error' : 'text-text'}
|
||||
{@const sup_tone = sup_rate > 0.10 ? 'text-warning' : 'text-text'}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-5">
|
||||
<!-- 에스컬레이션 비율 -->
|
||||
<Card class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">에스컬레이션 비율 (24h)</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {esc_tone}">
|
||||
{(esc_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
{th.escalated_total} / {th.triage_total}
|
||||
{#if esc_rate > 0.20}<span class="text-error ml-1">(튜닝 필요)</span>{/if}
|
||||
{#if esc_rate < 0.01}<span class="text-dim ml-1">(false negative?)</span>{/if}
|
||||
</p>
|
||||
{#if Object.keys(th.escalation_by_reason).length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each Object.entries(th.escalation_by_reason).slice(0, 4) as [reason, n]}
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded bg-surface-muted text-dim">
|
||||
{reason} {n}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- triage JSON 건강도 -->
|
||||
<Card class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">triage JSON 건강도 (24h)</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {json_tone}">
|
||||
{(json_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
깨짐 {th.triage_json_invalid} 건
|
||||
{#if json_rate > 0.05}<span class="text-error ml-1">(프롬프트 이슈 의심)</span>{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-faint mt-1">5% 초과 시 4B 프롬프트·모델 재검토</p>
|
||||
</Card>
|
||||
|
||||
<!-- Backlog Suppression -->
|
||||
<Card class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">Backlog Suppression (24h)</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {sup_tone}">
|
||||
{(sup_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p class="text-xs text-dim mt-1">
|
||||
억제 {th.suppressed_total} 건
|
||||
{#if sup_rate > 0.10}<span class="text-warning ml-1">(임계치 재조정 신호)</span>{/if}
|
||||
</p>
|
||||
<p class="text-[10px] text-faint mt-1">10% 초과 시 ratio/pending threshold 조정</p>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 4. 최근 활동 ═══ -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
<AIClassificationEditor {doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<AnalysisPanel docId={doc.id} />
|
||||
<AnalysisPanel docId={doc.id} doc={doc} />
|
||||
</Card>
|
||||
<Card>
|
||||
<FileInfoView {doc} />
|
||||
|
||||
Reference in New Issue
Block a user