diff --git a/app/api/dashboard.py b/app/api/dashboard.py index 777f67e..d591437 100644 --- a/app/api/dashboard.py +++ b/app/api/dashboard.py @@ -49,6 +49,22 @@ class QueueLag(BaseModel): oldest_pending_age_sec: int | None # 가장 오래된 pending 의 created_at 기준 경과 (초) +class TierHealthStack(BaseModel): + """PR-B B-3 — tier 관측성 3종 카드 소스 (24h 윈도우). + + 대시보드 카드: + - "에스컬레이션 비율": escalated_total / triage_total (>20% 적색, <1% 회색) + - "triage JSON 건강도": triage_json_invalid / triage_total (>5% 적색) + - "Backlog Suppression": suppressed_total / triage_total (>10% 주황) + """ + triage_total: int = 0 + escalated_total: int = 0 + escalation_by_reason: dict[str, int] = {} # long_context / low_confidence / deep_requested / self_declare + escalation_by_domain: dict[str, int] = {} # safety_reference / news_item / ... + triage_json_invalid: int = 0 # error_code='triage_json_invalid' + suppressed_total: int = 0 # suppressed_reason IS NOT NULL + + class DashboardResponse(BaseModel): today_added: int today_by_domain: list[DomainCount] @@ -66,6 +82,8 @@ class DashboardResponse(BaseModel): category_counts: dict[str, int] = {} library_pending_suggestions: int = 0 queue_lag: list[QueueLag] = [] + # PR-B B-3 — tier 관측성 + tier_health: TierHealthStack = TierHealthStack() @router.get("/", response_model=DashboardResponse) @@ -198,6 +216,47 @@ async def get_dashboard( for row in lag_result.all() ] + # ─── PR-B B-3 — tier 관측성 (24h) ─── + tier_rows = (await session.execute(text(""" + SELECT + COUNT(*) FILTER (WHERE mode = 'summary_triage') AS triage_total, + COUNT(*) FILTER (WHERE mode = 'summary_triage' AND escalated_to_26b = true) AS escalated_total, + COUNT(*) FILTER (WHERE mode = 'summary_triage' AND error_code = 'triage_json_invalid') AS json_invalid, + COUNT(*) FILTER (WHERE mode = 'summary_triage' AND suppressed_reason IS NOT NULL) AS suppressed_total + FROM analyze_events + WHERE created_at > NOW() - INTERVAL '24 hours' + """))).one() + + reason_rows = await session.execute(text(""" + SELECT unnest(escalation_reasons) AS reason, COUNT(*) AS n + FROM analyze_events + WHERE created_at > NOW() - INTERVAL '24 hours' + AND mode = 'summary_triage' + AND escalated_to_26b = true + GROUP BY 1 ORDER BY 2 DESC + """)) + escalation_by_reason = {r[0]: r[1] for r in reason_rows if r[0]} + + domain_rows = await session.execute(text(""" + SELECT subject_domain, COUNT(*) AS n + FROM analyze_events + WHERE created_at > NOW() - INTERVAL '24 hours' + AND mode = 'summary_triage' + AND escalated_to_26b = true + AND subject_domain IS NOT NULL + GROUP BY 1 ORDER BY 2 DESC + """)) + escalation_by_domain = {r[0]: r[1] for r in domain_rows} + + tier_health = TierHealthStack( + triage_total=int(tier_rows.triage_total or 0), + escalated_total=int(tier_rows.escalated_total or 0), + triage_json_invalid=int(tier_rows.json_invalid or 0), + suppressed_total=int(tier_rows.suppressed_total or 0), + escalation_by_reason=escalation_by_reason, + escalation_by_domain=escalation_by_domain, + ) + return DashboardResponse( today_added=today_added, today_by_domain=[ @@ -227,4 +286,5 @@ async def get_dashboard( category_counts=category_counts, library_pending_suggestions=library_pending_suggestions, queue_lag=queue_lag, + tier_health=tier_health, ) diff --git a/frontend/src/lib/components/AnalysisPanel.svelte b/frontend/src/lib/components/AnalysisPanel.svelte index 3fed308..557c644 100644 --- a/frontend/src/lib/components/AnalysisPanel.svelte +++ b/frontend/src/lib/components/AnalysisPanel.svelte @@ -1,36 +1,69 @@
+ + {#if hasTier} +
+
+

+ + AI 요약 +

+ {#if tier === 'deep'} + 깊은 분석 (26B) + {:else if tier === 'triage'} + 짧은 분석 (4B) + {/if} +
+ + {#if tldr} +

+ {tldr} +

+ {/if} + + {#if bullets && bullets.length > 0} + + {/if} + + {#if detail} +
+

+ 상세 +

+

+ {detail} +

+
+ {:else if tier === 'triage'} +

+ 상세 요약 대기 중 (긴 문서/핵심 결정 문서일 때만 26B 생성). +

+ {/if} + + {#if inconsistencies && inconsistencies.length > 0} +
+

+ 일관성 이슈 +

+ {#each inconsistencies as it} + {@const Icon = INC_ICON[it.kind] ?? HelpCircle} +
+ +
+ {INC_LABEL[it.kind] ?? it.kind} + : {it.desc} +
+
+ {/each} +
+ {/if} +
+ {/if} + +

- 빠른 분석 + 고급 분석

{#if data?.cached} 캐시 @@ -71,9 +169,8 @@ 빠른 분석

- 문서 앞부분(최대 12,000자)을 Gemma 4로 구조화합니다. 약 10~40초 소요. -
- ※ 전체 분석은 추후 제공 예정. + 문서 앞부분(최대 12,000자)을 4층 구조 (evidence / explanation / examples / + summary) 로 구조화합니다. 약 10~40초 소요.

{:else if loading}
diff --git a/frontend/src/lib/components/editors/AIClassificationEditor.svelte b/frontend/src/lib/components/editors/AIClassificationEditor.svelte index 6a7d7e3..eb86640 100644 --- a/frontend/src/lib/components/editors/AIClassificationEditor.svelte +++ b/frontend/src/lib/components/editors/AIClassificationEditor.svelte @@ -38,6 +38,12 @@ {doc.importance} {/if} + + {#if doc.ai_analysis_tier === 'deep'} + 깊이 + {:else if doc.ai_analysis_tier === 'triage'} + 짧음 + {/if}
{/if} diff --git a/frontend/src/lib/stores/system.ts b/frontend/src/lib/stores/system.ts index d442de4..74bfa25 100644 --- a/frontend/src/lib/stores/system.ts +++ b/frontend/src/lib/stores/system.ts @@ -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; + escalation_by_domain: Record; + 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; library_pending_suggestions: number; queue_lag: QueueLag[]; + // B-3 — tier 관측성 3종 카드 + tier_health?: TierHealthStack; } const POLL_INTERVAL_MS = 60_000; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6b7a176..c4d0a56 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -319,6 +319,75 @@
{/if} + + {#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'} +
+ + +
+

에스컬레이션 비율 (24h)

+ +
+

+ {(esc_rate * 100).toFixed(1)}% +

+

+ {th.escalated_total} / {th.triage_total} + {#if esc_rate > 0.20}(튜닝 필요){/if} + {#if esc_rate < 0.01}(false negative?){/if} +

+ {#if Object.keys(th.escalation_by_reason).length > 0} +
+ {#each Object.entries(th.escalation_by_reason).slice(0, 4) as [reason, n]} + + {reason} {n} + + {/each} +
+ {/if} +
+ + + +
+

triage JSON 건강도 (24h)

+ +
+

+ {(json_rate * 100).toFixed(1)}% +

+

+ 깨짐 {th.triage_json_invalid} 건 + {#if json_rate > 0.05}(프롬프트 이슈 의심){/if} +

+

5% 초과 시 4B 프롬프트·모델 재검토

+
+ + + +
+

Backlog Suppression (24h)

+ +
+

+ {(sup_rate * 100).toFixed(1)}% +

+

+ 억제 {th.suppressed_total} 건 + {#if sup_rate > 0.10}(임계치 재조정 신호){/if} +

+

10% 초과 시 ratio/pending threshold 조정

+
+
+ {/if} +
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index dabd921..becdf49 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -246,7 +246,7 @@ - +