Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5bc1f773d | |||
| d007ad5492 | |||
| b6a4821cac | |||
| b461559d2f | |||
| 9b9790f05d | |||
| b49596135e | |||
| 0a82a5b1bc | |||
| 74e29e510e | |||
| c1555fd6ab | |||
| 540bc00dba | |||
| 30c235e4c1 | |||
| cd439b0ff4 | |||
| 595f4b7d5e | |||
| b630c31077 |
@@ -680,7 +680,12 @@ class SectionItem(BaseModel):
|
||||
level: int | None = None
|
||||
node_type: str | None = None # window | chapter_split | clause_split | section_split | null
|
||||
is_leaf: bool
|
||||
parent_id: int | None = None # 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent.
|
||||
# 프런트 collapseWindows 가 비인접 window 를 split-parent 에 흡수할 때 사용.
|
||||
char_start: int | None = None # md_content 내 heading offset(UTF-16). jump-target 만 값, 그 외 None (Path B)
|
||||
text: str | None = None # 절 본문 = 청크 원문. 대형 split 문서는 md_content 가 앞 5만 자만 보존
|
||||
# (marker LARGE_DOC_MD_CONTENT_HEAD_CHARS)이고 char_start 도 NULL 이라
|
||||
# md_content 슬라이스로는 본문이 비므로, 청크 text 를 직접 렌더한다.
|
||||
section_type: str | None = None
|
||||
summary: str | None = None # status='summarized' 인 분석행에만, 그 외 None
|
||||
confidence: float | None = None
|
||||
@@ -719,12 +724,12 @@ async def get_document_sections(
|
||||
await session.execute(
|
||||
sql_text(
|
||||
"""
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, char_start,
|
||||
section_type, summary, confidence
|
||||
SELECT chunk_id, section_title, heading_path, level, node_type, is_leaf, parent_id, char_start,
|
||||
text, section_type, summary, confidence
|
||||
FROM (
|
||||
SELECT DISTINCT ON (c.id)
|
||||
c.id AS chunk_id, c.chunk_index, c.section_title, c.heading_path,
|
||||
c.level, c.node_type, c.is_leaf, c.char_start,
|
||||
c.level, c.node_type, c.is_leaf, c.parent_id, c.char_start, c.text,
|
||||
a.section_type,
|
||||
CASE WHEN a.status = 'summarized' THEN a.summary ELSE NULL END AS summary,
|
||||
a.confidence
|
||||
|
||||
@@ -59,6 +59,20 @@ class SummarizeEta(BaseModel):
|
||||
eta_minutes: int | None
|
||||
|
||||
|
||||
class MachineDone(BaseModel):
|
||||
"""머신 1대의 summarize 완료 실적 (분담 표시용)."""
|
||||
done_1h: int
|
||||
done_today: int
|
||||
|
||||
|
||||
class SummarizeByMachine(BaseModel):
|
||||
"""summarize 풀의 머신별 완료 실적 분담 — 보드 레인의 '맥미니 vs 맥북'
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 계산하던 값의 노출
|
||||
(ds-board-merged A-1, 신규 수집 SQL 0)."""
|
||||
macmini: MachineDone
|
||||
macbook: MachineDone
|
||||
|
||||
|
||||
class TrendBucket(BaseModel):
|
||||
"""summarize 24h 추이 버킷 — hour 는 KST "HH:00" 라벨."""
|
||||
hour: str
|
||||
@@ -93,6 +107,7 @@ class QueueOverviewResponse(BaseModel):
|
||||
machines: list[MachineCard]
|
||||
stages: list[StageRow]
|
||||
summarize_eta: SummarizeEta
|
||||
summarize_by_machine: SummarizeByMachine
|
||||
trend_24h: list[TrendBucket]
|
||||
totals: Totals
|
||||
|
||||
|
||||
@@ -213,6 +213,16 @@ def build_summarize_eta(stage_stats: dict[str, dict]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def build_summarize_by_machine(summarize_split: dict[str, dict]) -> dict:
|
||||
"""summarize 머신별 완료 실적 분담 (macmini vs macbook) — 보드 레인의
|
||||
오프로드 가시화용. rows_to_summarize_split 이 이미 만든 값을 응답 형태로
|
||||
투영(done_1h/done_today 만, done_15m 은 내부 state 판정 전용이라 제외)."""
|
||||
def m(key: str) -> dict:
|
||||
s = summarize_split.get(key, {})
|
||||
return {"done_1h": int(s.get("done_1h", 0)), "done_today": int(s.get("done_today", 0))}
|
||||
return {"macmini": m("macmini"), "macbook": m("macbook")}
|
||||
|
||||
|
||||
def build_trend(
|
||||
inflow_buckets: dict[str, int],
|
||||
done_buckets: dict[str, int],
|
||||
@@ -292,6 +302,7 @@ def compose_overview(
|
||||
),
|
||||
"stages": build_stages(stage_stats),
|
||||
"summarize_eta": build_summarize_eta(stage_stats),
|
||||
"summarize_by_machine": build_summarize_by_machine(summarize_split),
|
||||
"trend_24h": build_trend(inflow_buckets, done_buckets, now_kst),
|
||||
"totals": build_totals(stage_stats),
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts">
|
||||
// 처리 머신 보드 v2 — 파이프라인 흐름 뷰 (plan ds-board-engines-1, R2 통합안).
|
||||
// 메인 = 좌→우 흐름 노드(병목 amber·실패 뱃지), 노드 클릭 = 상세 패널(안1 변형),
|
||||
// 실패 뱃지 클릭 = 실패 처리 드로어 (재시도/건너뛰기 — 영구 실패의 유일한 조치 경로).
|
||||
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어 열 때).
|
||||
// 처리 머신 보드 v3 — 통합안 (plan ds-board-merged: C2 머신레인 + C3 번다운/정직ETA).
|
||||
// · 머신 3레인(GPU/맥미니/맥북) = "누가 일하나" + 요약 오프로드(맥북 합류) 가시화
|
||||
// · 지배 백로그 번다운 패널 = "언제 끝나나" + 유입 차감한 정직 ETA(summarize_eta)
|
||||
// · 신선도 '갱신 N초 전' + stale 경고 / 실패 드로어·상세 패널은 v2 자산 재사용.
|
||||
// 데이터 = GET /api/queue/overview (60s 폴링 store) + GET /api/queue/failed (드로어).
|
||||
import { api } from '$lib/api';
|
||||
import { refreshQueueOverview } from '$lib/stores/queueOverview';
|
||||
import { refreshQueueOverview, queueUpdatedAt } from '$lib/stores/queueOverview';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import {
|
||||
AUX_NODES,
|
||||
FLOW_NODES,
|
||||
MACHINE_META,
|
||||
type FlowNodeDef,
|
||||
type FlowMachine,
|
||||
etaShort,
|
||||
flowStageLabel,
|
||||
formatAgeSec,
|
||||
@@ -20,6 +22,7 @@
|
||||
FailedItem,
|
||||
FailedListResponse,
|
||||
MachineCurrentItem,
|
||||
MachineOverview,
|
||||
QueueOverview,
|
||||
QueueStageRow,
|
||||
RetryResponse,
|
||||
@@ -82,14 +85,6 @@
|
||||
);
|
||||
const totalFailed = $derived(overview.totals.failed);
|
||||
|
||||
// 머신 스트립 — overview.machines 의 state/처리율 + 정적 모델 메타
|
||||
const machineStrip = $derived(
|
||||
overview.machines.map((m) => ({
|
||||
...m,
|
||||
meta: MACHINE_META[m.key],
|
||||
})),
|
||||
);
|
||||
|
||||
// ─── 선택 상태 (노드 상세 / 실패 드로어 — 동시에 하나만) ───
|
||||
let selected = $state<string | null>(null);
|
||||
let failOpen = $state(false);
|
||||
@@ -194,22 +189,105 @@
|
||||
await Promise.all([loadFailures(), refreshQueueOverview()]);
|
||||
}
|
||||
|
||||
// ─── trend_24h 스파크라인 (summarize 유입 vs 소화 — API 가 주는데 미렌더이던 슬롯) ───
|
||||
const spark = $derived.by(() => {
|
||||
// ─── 머신 레인 (C2) — mainNodes 를 머신별로 그룹 + 머신 카드(state/처리율) 결합 ───
|
||||
const machineByKey = $derived(
|
||||
new Map<FlowMachine, MachineOverview>(overview.machines.map((m) => [m.key as FlowMachine, m])),
|
||||
);
|
||||
const LANE_ORDER: FlowMachine[] = ['gpu', 'macmini', 'macbook'];
|
||||
const lanes = $derived(
|
||||
LANE_ORDER.map((key) => ({
|
||||
key,
|
||||
meta: MACHINE_META[key],
|
||||
card: machineByKey.get(key) ?? null,
|
||||
nodes: mainNodes.filter((n) => n.def.machine === key),
|
||||
})),
|
||||
);
|
||||
|
||||
// 요약 오프로드 분담 — 맥미니 vs 맥북 (A-1 summarize_by_machine)
|
||||
const split = $derived(overview.summarize_by_machine);
|
||||
const splitTotal1h = $derived(Math.max(1, split.macmini.done_1h + split.macbook.done_1h));
|
||||
const macbookSharePct = $derived(Math.round((split.macbook.done_1h / splitTotal1h) * 100));
|
||||
// 맥북이 요약을 실제로 가져가는 중인가 (합류 표식 게이트)
|
||||
const offloadActive = $derived(split.macbook.done_1h > 0);
|
||||
|
||||
// ─── 지배 백로그 = 요약. 정직 ETA(유입 차감) — summarize_eta ───
|
||||
const eta = $derived(overview.summarize_eta);
|
||||
// 정직 ETA 라벨: eta_minutes null = 유입이 소화를 앞섬(소진 불가)
|
||||
const honestEtaLabel = $derived(
|
||||
eta.pending === 0
|
||||
? '비어 있음'
|
||||
: eta.eta_minutes != null
|
||||
? etaShort(eta.eta_minutes)
|
||||
: '소진 불가',
|
||||
);
|
||||
const honestEtaWarn = $derived(eta.pending > 0 && eta.eta_minutes == null);
|
||||
|
||||
/** 단계별 정직 ETA(순소화율) — 노드용. 유입>소화면 null(소진 불가) */
|
||||
function netEtaLabel(n: NodeStats): string | null {
|
||||
if (n.pending === 0) return '한가';
|
||||
const net = n.done1h - n.created1h;
|
||||
if (net > 0) return etaShort(Math.round((n.pending / net) * 60));
|
||||
if (n.created1h > n.done1h) return '유입 우세';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── 신선도 (B-4) — '갱신 N초 전' + stale 경고 (폴링 60s) ───
|
||||
let now = $state(Date.now());
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
const ageSec = $derived(
|
||||
$queueUpdatedAt != null ? Math.max(0, Math.round((now - $queueUpdatedAt) / 1000)) : null,
|
||||
);
|
||||
const stale = $derived(ageSec != null && ageSec > 90);
|
||||
const freshLabel = $derived(
|
||||
ageSec == null
|
||||
? '갱신 대기'
|
||||
: ageSec < 60
|
||||
? `갱신 ${ageSec}초 전`
|
||||
: `갱신 ${Math.round(ageSec / 60)}분 전`,
|
||||
);
|
||||
|
||||
// ─── 24h 번다운 (C3) — 요약 유입 vs 소화 + 맥북 합류 변곡점 마커 ───
|
||||
const burn = $derived.by(() => {
|
||||
const t = overview.trend_24h;
|
||||
if (!t || t.length === 0) return null;
|
||||
const max = Math.max(1, ...t.map((b) => Math.max(b.inflow, b.done)));
|
||||
const w = 120;
|
||||
const h = 24;
|
||||
const w = 300;
|
||||
const h = 64;
|
||||
const step = w / Math.max(1, t.length - 1);
|
||||
const pts = (sel: (b: (typeof t)[number]) => number) =>
|
||||
t.map((b, i) => `${(i * step).toFixed(1)},${(h - (sel(b) / max) * (h - 3) + 1).toFixed(1)}`).join(' ');
|
||||
return { inflow: pts((b) => b.inflow), done: pts((b) => b.done) };
|
||||
const y = (v: number) => (h - (v / max) * (h - 8) + 4).toFixed(1);
|
||||
const line = (sel: (b: (typeof t)[number]) => number) =>
|
||||
t.map((b, i) => `${(i * step).toFixed(1)},${y(sel(b))}`).join(' ');
|
||||
const doneLine = line((b) => b.done);
|
||||
const area = `0,${h} ${doneLine} ${w.toFixed(1)},${h}`;
|
||||
// 합류 변곡점 = done 최대 버킷 (맥북 야간 drain 합류 추정)
|
||||
let mi = 0;
|
||||
t.forEach((b, i) => {
|
||||
if (b.done > t[mi].done) mi = i;
|
||||
});
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
area,
|
||||
doneLine,
|
||||
inflowLine: line((b) => b.inflow),
|
||||
markX: (mi * step).toFixed(1),
|
||||
markHour: t[mi].hour,
|
||||
markDone: t[mi].done,
|
||||
peak: max,
|
||||
};
|
||||
});
|
||||
|
||||
// 머신 상태 dot 색 클래스
|
||||
function dotClass(state: string): string {
|
||||
return state === 'active' ? 'bg-success' : state === 'deferred' ? 'bg-warning' : 'bg-faint';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-5">
|
||||
<!-- 헤더: 타이틀 + 요약 24h 스파크라인 + 실패 합계 -->
|
||||
<!-- 헤더: 타이틀 + 신선도 + 실패 합계 -->
|
||||
<div class="flex items-center justify-between gap-3 mb-3">
|
||||
<div class="text-[11px] font-bold text-dim uppercase tracking-wider">처리 머신</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -219,80 +297,107 @@
|
||||
onclick={openFailures}
|
||||
>실패 {totalFailed}건 처리</button>
|
||||
{/if}
|
||||
{#if spark}
|
||||
<div class="flex items-center gap-2 text-[10px] text-faint tabular-nums" title="요약(summarize) 단계 24시간 — 유입(회색) vs 소화(녹색)">
|
||||
<svg width="120" height="24" viewBox="0 0 120 24" class="block">
|
||||
<polyline points={spark.inflow} fill="none" stroke="currentColor" stroke-width="1.5" class="text-faint" />
|
||||
<polyline points={spark.done} fill="none" stroke="currentColor" stroke-width="1.5" class="text-success" />
|
||||
</svg>
|
||||
<span>요약 24h 유입/소화</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="flex items-center gap-1.5 text-[10px] tabular-nums {stale ? 'text-warning' : 'text-faint'}" title="60초 폴링">
|
||||
<span class="w-1.5 h-1.5 rounded-full {stale ? 'bg-warning' : 'bg-success'}"></span>
|
||||
{freshLabel}{#if stale} · 갱신 지연{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 머신 스트립 -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{#each machineStrip as m (m.key)}
|
||||
<div class="flex items-center gap-2 bg-surface border border-default rounded-full px-3.5 py-1.5 text-xs">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {m.state === 'active' ? 'bg-success' : m.state === 'deferred' ? 'bg-warning' : 'bg-faint'}"></span>
|
||||
<span class="font-bold text-text">{m.meta?.label ?? m.label}</span>
|
||||
<span class="text-[10px] text-faint font-mono">{m.meta?.model}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums">{formatRate(m.done_1h)}/h</span>
|
||||
{#if m.key === 'macbook' && m.deferred_pending > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {m.deferred_pending}</span>
|
||||
{/if}
|
||||
<!-- 지배 백로그 스트립 (요약) + 정직 ETA -->
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 bg-surface border border-warning/50 rounded-card px-3.5 py-2 mb-3">
|
||||
<span class="text-[9px] font-bold text-warning border border-warning/60 rounded-full px-2 py-px">지배 백로그</span>
|
||||
<span class="text-xs font-bold text-text">요약</span>
|
||||
<span class="text-[11px] text-dim tabular-nums">대기 <b class="text-text">{eta.pending.toLocaleString()}</b> · 순소화 <b class="text-text">{formatRate(eta.done_rate_1h)}</b>/h · 유입 {formatRate(eta.inflow_rate_1h)}/h</span>
|
||||
<span class="ml-auto flex items-center gap-1.5 border rounded-full px-2.5 py-0.5 {honestEtaWarn ? 'border-warning text-warning' : 'border-accent text-accent'}">
|
||||
<span class="text-[10px] font-semibold">정직 ETA</span>
|
||||
<span class="text-xs font-bold tabular-nums">{honestEtaLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 머신 레인 (누가 일하나 + 요약 오프로드) -->
|
||||
<div class="grid gap-2 mb-3">
|
||||
{#each lanes as lane (lane.key)}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-2.5">
|
||||
<div class="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {dotClass(lane.card?.state ?? 'idle')}"></span>
|
||||
<span class="text-[9px] font-bold rounded px-1.5 py-px mtag-{lane.key}">{lane.meta.label}</span>
|
||||
<span class="text-[10px] text-faint font-mono">{lane.meta.model}</span>
|
||||
<span class="text-[11px] text-dim tabular-nums ml-1">{formatRate(lane.card?.done_1h ?? 0)}/h</span>
|
||||
{#if lane.key === 'macbook' && (lane.card?.deferred_pending ?? 0) > 0}
|
||||
<span class="text-[10px] font-semibold text-warning tabular-nums">보류 {lane.card?.deferred_pending}</span>
|
||||
{/if}
|
||||
{#if lane.card?.state === 'deferred'}
|
||||
<span class="text-[9px] text-warning">잠듦 — 요약은 맥미니로 복귀</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-stretch gap-1.5 flex-wrap">
|
||||
{#each lane.nodes as n (n.def.key)}
|
||||
{@const idle = n.pending + n.processing + n.doneToday + n.failed === 0}
|
||||
<button
|
||||
class="relative text-left rounded-lg border px-2.5 py-1.5 transition-colors cursor-pointer hover:bg-surface-hover min-w-[96px]
|
||||
{idle ? 'border-dashed border-default opacity-55' : n.inflowDominant ? 'border-warning' : 'border-default'}
|
||||
{selected === n.def.key ? 'node-sel' : ''}"
|
||||
onclick={() => toggleNode(n.def.key)}
|
||||
title="{n.def.label} — 클릭하면 상세"
|
||||
>
|
||||
{#if n.failed > 0}
|
||||
<span class="absolute -top-1.5 -right-1 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5">{n.failed}</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">
|
||||
{n.def.label}
|
||||
{#if n.processing > 0}<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse"></span>{/if}
|
||||
</div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{n.pending.toLocaleString()}<span class="text-[9px] text-faint font-normal ml-0.5">대기</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}</div>
|
||||
{#if n.def.key === 'summarize'}
|
||||
<div class="mt-1 h-1 w-full rounded-full overflow-hidden flex" title="맥미니 {split.macmini.done_1h}/h · 맥북 {split.macbook.done_1h}/h">
|
||||
<span class="block h-full mtag-macmini-bar" style="width:{100 - macbookSharePct}%"></span>
|
||||
<span class="block h-full mtag-macbook-bar" style="width:{macbookSharePct}%"></span>
|
||||
</div>
|
||||
<div class="text-[9px] text-faint tabular-nums whitespace-nowrap mt-0.5">맥미니 {split.macmini.done_1h} · 맥북 {split.macbook.done_1h}/h</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if lane.key === 'macbook' && offloadActive}
|
||||
<button
|
||||
class="text-left rounded-lg border border-dashed border-warning/50 px-2.5 py-1.5 cursor-pointer hover:bg-surface-hover min-w-[96px]"
|
||||
onclick={() => toggleNode('summarize')}
|
||||
title="맥북이 요약을 맥미니에서 가져와 처리 중"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-[11px] font-semibold text-text whitespace-nowrap">요약 합류 <span class="text-[8px] font-bold text-warning">OFFLOAD</span></div>
|
||||
<div class="text-sm font-extrabold tabular-nums leading-tight text-text">{split.macbook.done_1h}<span class="text-[9px] text-faint font-normal ml-0.5">/h</span></div>
|
||||
<div class="text-[9px] text-dim tabular-nums whitespace-nowrap">요약의 {macbookSharePct}% 담당</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- 흐름 노드 — pt/px 헤드룸 = 실패 뱃지(-top/-right 돌출)가 스크롤 컨테이너에 잘리지 않게 -->
|
||||
<div class="flex items-stretch overflow-x-auto pt-2.5 pb-1 px-2 -mx-2">
|
||||
{#each mainNodes as n, i (n.def.key)}
|
||||
{#if i > 0}
|
||||
<div class="flex items-center text-faint text-sm px-1.5 shrink-0" aria-hidden="true">→</div>
|
||||
{/if}
|
||||
<div
|
||||
class="relative bg-surface border-[1.5px] rounded-card px-3 py-2.5 min-w-[124px] shrink-0 text-left transition-colors cursor-pointer hover:bg-surface-hover
|
||||
{n.inflowDominant ? 'border-warning' : n.etaMinutes != null && n.def.stages.includes('chunk') ? 'border-success' : 'border-default'}
|
||||
{selected === n.def.key ? 'node-sel' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => toggleNode(n.def.key)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNode(n.def.key); } }}
|
||||
title="{n.def.label} — 클릭하면 상세"
|
||||
>
|
||||
{#if n.failed > 0}
|
||||
<button
|
||||
class="absolute -top-2 -right-1.5 text-[9px] font-extrabold bg-error text-white rounded-full px-1.5 py-px shadow cursor-pointer"
|
||||
onclick={(e) => { e.stopPropagation(); openFailures(); }}
|
||||
title="실패 {n.failed}건 — 클릭하면 실패 처리"
|
||||
>{n.failed}</button>
|
||||
{/if}
|
||||
<span class="inline-block text-[9px] font-bold rounded px-1.5 py-px mb-1.5 mtag-{n.def.machine}">
|
||||
{MACHINE_META[n.def.machine].label} · {n.def.engine}
|
||||
</span>
|
||||
<div class="text-xs font-bold text-text flex items-center gap-1.5">
|
||||
{n.def.label}
|
||||
{#if n.processing > 0}
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-accent animate-pulse" title="처리 중 {n.processing}"></span>
|
||||
{/if}
|
||||
{#if n.inflowDominant}
|
||||
<span class="text-[9px] font-bold text-warning">유입 우세</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-base font-extrabold tabular-nums tracking-tight leading-tight mt-0.5 text-text">
|
||||
{n.pending.toLocaleString()}
|
||||
</div>
|
||||
<div class="text-[10px] text-dim tabular-nums">
|
||||
{formatRate(n.done1h)}/h · 오늘 {n.doneToday.toLocaleString()}
|
||||
{#if n.etaMinutes != null && !n.inflowDominant && n.pending > 0}
|
||||
· <span class="text-accent font-semibold">{etaShort(n.etaMinutes)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- 번다운 / ETA 패널 -->
|
||||
{#if burn}
|
||||
<div class="bg-surface border border-default rounded-card px-3.5 py-3 mb-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[11px] font-bold text-text">요약 백로그 24시간</span>
|
||||
<span class="text-[9px] text-faint">유입(회색) vs 소화(녹색)</span>
|
||||
{#if offloadActive}<span class="text-[9px] text-warning ml-auto">맥북 합류 {burn.markHour} — 소화 급증</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<svg viewBox="0 0 {burn.w} {burn.h}" class="block w-full" style="height:64px" preserveAspectRatio="none" role="img" aria-label="요약 백로그 24시간 번다운">
|
||||
<polygon points={burn.area} fill="currentColor" class="text-success" opacity="0.12" />
|
||||
<polyline points={burn.inflowLine} fill="none" stroke="currentColor" stroke-width="1.2" class="text-faint" />
|
||||
<polyline points={burn.doneLine} fill="none" stroke="currentColor" stroke-width="1.6" class="text-success" />
|
||||
{#if offloadActive}
|
||||
<line x1={burn.markX} y1="0" x2={burn.markX} y2={burn.h} stroke="currentColor" stroke-width="1" stroke-dasharray="2 2" class="text-warning" opacity="0.7" />
|
||||
{/if}
|
||||
</svg>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 pt-2 border-t border-default text-[10px] text-dim tabular-nums">
|
||||
{#each mainNodes.filter((n) => n.pending > 0 && n.def.key !== 'summarize') as n (n.def.key)}
|
||||
<span class="whitespace-nowrap">{n.def.label} 대기 <b class="text-text">{n.pending.toLocaleString()}</b>{#if netEtaLabel(n)} · <span class="text-accent font-semibold">{netEtaLabel(n)}</span>{/if}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 보조 라인 -->
|
||||
<p class="text-[10px] text-faint mt-1.5 tabular-nums">
|
||||
@@ -413,6 +518,9 @@
|
||||
.mtag-gpu { background: #e7eef6; color: #3b6ea5; }
|
||||
.mtag-macmini { background: #efe9f7; color: #8a5fbf; }
|
||||
.mtag-macbook { background: #f7eedd; color: #b07a10; }
|
||||
/* 요약 오프로드 분담 막대 채움 (맥미니 보라 / 맥북 황) */
|
||||
.mtag-macmini-bar { background: #8a5fbf; }
|
||||
.mtag-macbook-bar { background: #b07a10; }
|
||||
.node-sel { outline: 2px solid #3b6ea5; outline-offset: 1px; }
|
||||
.detail-frame { border-color: #3b6ea5; }
|
||||
.detail-head { background: #e7eef6; }
|
||||
|
||||
@@ -43,14 +43,17 @@
|
||||
{@const open = selectedId === s.chunk_id}
|
||||
{@const active = activeKey != null && activeKey === s.chunk_id}
|
||||
{@const typeLabel = sectionTypeLabel(s.section_type)}
|
||||
{@const depth = Math.max(0, (s.level ?? 1) - 1)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
|
||||
aria-expanded={open}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
style="padding-left:{8 + depth * 13}px"
|
||||
class={[
|
||||
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
'w-full text-left pr-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
|
||||
depth > 0 ? 'text-[11px]' : '',
|
||||
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,11 @@ let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let subscriberCount = 0;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
|
||||
// 마지막 성공 갱신 시각(epoch ms) — 보드 신선도 '갱신 N초 전' + stale 경고용
|
||||
// (ds-board-merged B-4). 실패(null 수렴) 시엔 갱신 안 함 → age 가 늘어 stale 로 드러남.
|
||||
const updatedAt = writable<number | null>(null);
|
||||
export const queueUpdatedAt = { subscribe: updatedAt.subscribe };
|
||||
|
||||
const internal = writable<QueueOverview | null>(null, (_set) => {
|
||||
subscriberCount += 1;
|
||||
if (subscriberCount === 1 && browser) {
|
||||
@@ -54,7 +59,9 @@ export async function refreshQueueOverview(): Promise<void> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
internal.set(await fetchOverview());
|
||||
const ov = await fetchOverview();
|
||||
internal.set(ov);
|
||||
if (ov) updatedAt.set(Date.now()); // 성공 시에만 신선도 갱신 (실패=stale 유지)
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
|
||||
@@ -43,13 +43,19 @@ export interface SummarizeEta {
|
||||
eta_minutes: number | null;
|
||||
}
|
||||
|
||||
/** 시간당 유입 vs 소화 (이번 트랙 미렌더 — 후속 추세 위젯 슬롯) */
|
||||
/** 시간당 유입 vs 소화 (요약 24h 추이) */
|
||||
export interface TrendPoint {
|
||||
hour: string;
|
||||
inflow: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
/** summarize 머신별 완료 실적 분담 (오프로드 가시화 — ds-board-merged A-1) */
|
||||
export interface SummarizeByMachine {
|
||||
macmini: { done_1h: number; done_today: number };
|
||||
macbook: { done_1h: number; done_today: number };
|
||||
}
|
||||
|
||||
export interface QueueTotals {
|
||||
pending: number;
|
||||
processing: number;
|
||||
@@ -72,6 +78,7 @@ export interface QueueStageRow {
|
||||
export interface QueueOverview {
|
||||
machines: MachineOverview[];
|
||||
summarize_eta: SummarizeEta;
|
||||
summarize_by_machine: SummarizeByMachine;
|
||||
trend_24h: TrendPoint[];
|
||||
stages: QueueStageRow[];
|
||||
totals: QueueTotals;
|
||||
|
||||
@@ -83,6 +83,74 @@ test('[C2] collapseWindows: split-parent + window 들 → rail 1행, 대표=spli
|
||||
assert.equal(out[0].fragmentCount, 2, 'window 조각 수 = 2 (split-parent 자신 제외)');
|
||||
});
|
||||
|
||||
test('collapseWindows: bodyText — 정상 leaf 는 자기 본문, split-parent 는 window 본문만 이어붙임', () => {
|
||||
// 정상 leaf → 자기 text 가 본문
|
||||
const leaf = collapseWindows([sec({ heading_path: 'Intro', node_type: null, text: '서론 본문' })]);
|
||||
assert.equal(leaf[0].bodyText, '서론 본문');
|
||||
|
||||
// split-parent(heading 줄뿐) + window 2개 → window 본문만 순서대로 합침(헤딩 제외)
|
||||
const split = collapseWindows([
|
||||
sec({ heading_path: 'Article 5', node_type: 'chapter_split', is_leaf: false, char_start: 120, text: '# Article 5' }),
|
||||
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각1' }),
|
||||
sec({ heading_path: 'Article 5', node_type: 'window', is_leaf: true, text: '본문 조각2' }),
|
||||
]);
|
||||
assert.equal(split.length, 1);
|
||||
assert.equal(split[0].bodyText, '본문 조각1\n\n본문 조각2', 'split-parent heading 제외, window 본문만 합침');
|
||||
|
||||
// legacy window 런(선행 split-parent 없음) → 첫 window 자기 본문 + 흡수 조각
|
||||
const legacy = collapseWindows([
|
||||
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p1' }),
|
||||
sec({ heading_path: 'Pearson', node_type: 'window', text: 'p2' }),
|
||||
]);
|
||||
assert.equal(legacy.length, 1);
|
||||
assert.equal(legacy[0].bodyText, 'p1\n\np2');
|
||||
});
|
||||
|
||||
test('collapseWindows: 절-레벨 분석 집계 — windowed 절은 window 멤버에서 type 다수결/conf 평균/summaries 합본', () => {
|
||||
// split-parent(분석 없음) + window 3개(요약·유형·신뢰도 보유) → 대표에 집계
|
||||
const out = collapseWindows([
|
||||
sec({ heading_path: 'Sec A', node_type: 'section_split', is_leaf: false, char_start: 10, text: '# Sec A', section_type: null, summary: null, confidence: null }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b1', section_type: 'requirement', summary: '요약1', confidence: 0.9 }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b2', section_type: 'requirement', summary: '요약2', confidence: 0.8 }),
|
||||
sec({ heading_path: 'Sec A', node_type: 'window', text: 'b3', section_type: 'overview', summary: '', confidence: 1.0 }),
|
||||
]);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].sectionType, 'requirement', '다수결 = requirement(2) > overview(1)');
|
||||
assert.ok(Math.abs(out[0].confidence! - 0.9) < 1e-9, '평균 (0.9+0.8+1.0)/3 = 0.9');
|
||||
assert.deepEqual(out[0].summaries, ['요약1', '요약2'], '빈 요약 제외, 순서 유지');
|
||||
|
||||
// 단일 leaf 는 대표 자신의 분석
|
||||
const single = collapseWindows([sec({ heading_path: 'X', node_type: null, text: 'body', section_type: 'definition', summary: '정의 요약', confidence: 0.7 })]);
|
||||
assert.equal(single[0].sectionType, 'definition');
|
||||
assert.equal(single[0].confidence, 0.7);
|
||||
assert.deepEqual(single[0].summaries, ['정의 요약']);
|
||||
|
||||
// 분석 전혀 없는 절 → null/빈
|
||||
const none = collapseWindows([sec({ heading_path: 'Y', node_type: null, text: 'body' })]);
|
||||
assert.equal(none[0].sectionType, null);
|
||||
assert.equal(none[0].confidence, null);
|
||||
assert.deepEqual(none[0].summaries, []);
|
||||
});
|
||||
|
||||
test('collapseWindows: 비인접 window 도 parent_id 로 split-parent 에 흡수 (빈 split 행 방지)', () => {
|
||||
// 실데이터 버그: split-parent(chunk_index 1143)와 그 window(1233~)가 비인접 → 인접 흡수 실패로
|
||||
// 빈 split 행 + 별도 window-그룹 행 2개로 쪼개짐. parent_id 링크로 정확히 합친다.
|
||||
const out = collapseWindows([
|
||||
sec({ chunk_id: 10, heading_path: 'FOREWORD', node_type: 'section_split', is_leaf: false, char_start: 5, text: '# FOREWORD' }),
|
||||
sec({ chunk_id: 11, heading_path: 'POLICY', node_type: null, text: '정책 본문' }), // 사이에 낀 다른 절
|
||||
sec({ chunk_id: 12, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각1', section_type: 'overview', summary: '요약A', confidence: 0.9 }),
|
||||
sec({ chunk_id: 13, heading_path: 'FOREWORD', node_type: 'window', parent_id: 10, text: '서문 조각2', section_type: 'overview', summary: '요약B', confidence: 0.8 }),
|
||||
]);
|
||||
assert.equal(out.length, 2, 'FOREWORD(split, window 흡수) + POLICY = 2행 (빈 split 행 없음)');
|
||||
assert.equal(out[0].section.chunk_id, 10, '대표 = split-parent(char_start 보유)');
|
||||
assert.equal(out[0].bodyText, '서문 조각1\n\n서문 조각2', '비인접 window 본문을 split-parent 에 흡수');
|
||||
assert.equal(out[0].fragmentCount, 2);
|
||||
assert.equal(out[0].sectionType, 'overview');
|
||||
assert.deepEqual(out[0].summaries, ['요약A', '요약B']);
|
||||
assert.equal(out[1].section.chunk_id, 11, '사이 낀 절은 별도 행 유지');
|
||||
assert.equal(out[1].bodyText, '정책 본문');
|
||||
});
|
||||
|
||||
test('groupOrFlat: 적은 그룹 + 낮은 기타% → group (5140-류)', () => {
|
||||
// 3 top segment × 4 = 12절, window 없음 → group_count 3, 기타 0%
|
||||
const sections: DocumentSection[] = [];
|
||||
|
||||
@@ -14,8 +14,12 @@ export interface DocumentSection {
|
||||
level: number | null;
|
||||
node_type: string | null; // 'window' | 'chapter_split' | 'clause_split' | 'section_split' | null
|
||||
is_leaf: boolean;
|
||||
/** 트리 부모 chunk_id. window child 의 parent_id = 그 split-parent (비인접 흡수에 사용). */
|
||||
parent_id?: number | null;
|
||||
/** md_content 내 heading offset(UTF-16). jump-target 만 값, window-child/preamble/Path A = null (Path B). */
|
||||
char_start?: number | null;
|
||||
/** 절 본문 = 청크 원문. split-parent 는 heading 줄뿐, window child 가 실 본문 보유. */
|
||||
text?: string | null;
|
||||
section_type: string | null;
|
||||
summary: string | null;
|
||||
confidence: number | null;
|
||||
@@ -25,6 +29,17 @@ export interface DocumentSection {
|
||||
export interface OutlineItem {
|
||||
section: DocumentSection;
|
||||
fragmentCount: number; // >1 이면 "(n조각)" 배지
|
||||
/** 대표 + 흡수된 window child 들의 본문을 순서대로 이어붙인 논리 절 전체 본문.
|
||||
* split-parent 는 heading 줄(text)을 본문에서 제외(제목과 중복) — window 본문만 합친다. */
|
||||
bodyText: string;
|
||||
/** 집계된 절-레벨 분석. windowed 절은 분석이 window child(chunk_section_analysis)에 붙고
|
||||
* 대표=split-parent 엔 없으므로 멤버에서 집계한다. 단일 절은 대표 자신의 값.
|
||||
* - sectionType: 멤버 section_type 다수결(동률=첫 등장)
|
||||
* - confidence: 멤버 confidence 평균
|
||||
* - summaries: 멤버 요약(빈 것 제외, chunk_index 순) — 단일=1개, windowed=N개(부분별 요약) */
|
||||
sectionType: string | null;
|
||||
confidence: number | null;
|
||||
summaries: string[];
|
||||
}
|
||||
|
||||
export interface OutlineGroup {
|
||||
@@ -107,22 +122,78 @@ function topSegment(s: DocumentSection): string {
|
||||
* fragmentCount: split-parent 대표는 0 에서 시작(자신은 조각 아님) + 흡수 child 수 = 실제 조각 수;
|
||||
* legacy window 대표는 1 에서 시작(자신이 첫 조각).
|
||||
*/
|
||||
/** 멤버 section_type 다수결(동률은 첫 등장 우선). 비어있으면 null. */
|
||||
function majorityType(types: (string | null)[]): string | null {
|
||||
const vals = types.filter((t): t is string => !!t);
|
||||
if (!vals.length) return null;
|
||||
const count = new Map<string, number>();
|
||||
for (const t of vals) count.set(t, (count.get(t) ?? 0) + 1);
|
||||
let best: string | null = null;
|
||||
let bestN = -1;
|
||||
for (const t of vals) {
|
||||
const n = count.get(t)!;
|
||||
if (n > bestN) { bestN = n; best = t; } // 첫 등장 우선 tie-break
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function collapseWindows(sections: DocumentSection[]): OutlineItem[] {
|
||||
const out: OutlineItem[] = [];
|
||||
const members: DocumentSection[][] = []; // out[i] 의 멤버(대표 + 흡수된 window child)
|
||||
const repByChunkId = new Map<number, number>(); // split-parent chunk_id → out index (window 가 parent_id 로 흡수)
|
||||
|
||||
// window child 본문/멤버를 out[idx] 대표에 흡수.
|
||||
const absorb = (idx: number, s: DocumentSection) => {
|
||||
out[idx].fragmentCount += 1;
|
||||
const t = (s.text ?? '').trim();
|
||||
if (t) out[idx].bodyText = out[idx].bodyText ? `${out[idx].bodyText}\n\n${t}` : t;
|
||||
members[idx].push(s);
|
||||
};
|
||||
|
||||
for (const s of sections) {
|
||||
const prev = out[out.length - 1];
|
||||
const h = cleanHeading(s.heading_path);
|
||||
const prevAbsorbs =
|
||||
prev &&
|
||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||
h !== '' &&
|
||||
cleanHeading(prev.section.heading_path) === h;
|
||||
if (s.node_type === 'window' && prevAbsorbs) {
|
||||
prev!.fragmentCount += 1; // window child 흡수 — 대표(split-parent 우선)는 그대로 유지
|
||||
if (s.node_type === 'window') {
|
||||
// 1) parent_id 로 split-parent 대표에 흡수 — split-parent 와 window 가 chunk_index 상 비인접일 수
|
||||
// 있으므로(예: 헤딩 1143, window 1233) 인접 가정 대신 트리 부모 링크로 정확히 연결한다.
|
||||
let idx = s.parent_id != null ? repByChunkId.get(s.parent_id) ?? -1 : -1;
|
||||
// 2) fallback: 인접 대표(legacy window run / 같은 heading split)면 흡수
|
||||
if (idx < 0) {
|
||||
const prev = out[out.length - 1];
|
||||
const h = cleanHeading(s.heading_path);
|
||||
if (
|
||||
prev &&
|
||||
(prev.section.node_type === 'window' || !!prev.section.node_type?.endsWith('_split')) &&
|
||||
h !== '' &&
|
||||
cleanHeading(prev.section.heading_path) === h
|
||||
) {
|
||||
idx = out.length - 1;
|
||||
}
|
||||
}
|
||||
if (idx >= 0) {
|
||||
absorb(idx, s);
|
||||
continue;
|
||||
}
|
||||
// 3) legacy: 부모 없는 window → 자기 대표(자기 본문으로 시작)
|
||||
out.push({ section: s, fragmentCount: 1, bodyText: s.text ?? '', sectionType: null, confidence: null, summaries: [] });
|
||||
members.push([s]);
|
||||
} else {
|
||||
out.push({ section: s, fragmentCount: s.node_type?.endsWith('_split') ? 0 : 1 });
|
||||
const isSplit = !!s.node_type?.endsWith('_split');
|
||||
// split-parent 의 text 는 heading 줄뿐 → 본문에서 제외(window 가 본문 보유). 그 외엔 자기 본문으로 시작.
|
||||
out.push({
|
||||
section: s, fragmentCount: isSplit ? 0 : 1, bodyText: isSplit ? '' : (s.text ?? ''),
|
||||
sectionType: null, confidence: null, summaries: [],
|
||||
});
|
||||
members.push([s]);
|
||||
if (isSplit) repByChunkId.set(s.chunk_id, out.length - 1); // window 가 parent_id 로 찾아 흡수
|
||||
}
|
||||
}
|
||||
// 멤버에서 절-레벨 분석 집계 (windowed 절: 대표 split-parent 엔 분석 없고 window 들이 보유).
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const mem = members[i];
|
||||
out[i].sectionType = majorityType(mem.map((m) => m.section_type));
|
||||
const confs = mem.map((m) => m.confidence).filter((c): c is number => c != null);
|
||||
out[i].confidence = confs.length ? confs.reduce((a, b) => a + b, 0) / confs.length : null;
|
||||
out[i].summaries = mem.map((m) => (m.summary ?? '').trim()).filter((x) => x !== '');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { Info, X, Plus, Trash2, Tag, FolderTree, Sparkles, ChevronLeft, ArrowUpDown } from 'lucide-svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import { X, Plus, Trash2, Tag, FolderTree, Sparkles, ArrowUpDown } from 'lucide-svelte';
|
||||
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
|
||||
import { isMdStatusVisible } from '$lib/utils/mdStatus';
|
||||
import UploadDropzone from '$lib/components/UploadDropzone.svelte';
|
||||
@@ -233,15 +232,12 @@
|
||||
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
|
||||
}
|
||||
|
||||
async function selectDoc(doc) {
|
||||
if (selectedDoc?.id === doc.id) { selectedDoc = null; return; }
|
||||
selectedDoc = doc; // 즉시 표시(리더 + 기본 인스펙터)
|
||||
// 인스펙터 풀 메타 하이드레이션 — 검색 결과(SearchResult)는 메타가 빈약(태그/크기/하위/md상태/읽음 없음).
|
||||
// 풀 문서를 조회해 채운다(기존 GET /documents/{id}, 백엔드 무변). 리스트 모드도 md상태 등 보강.
|
||||
try {
|
||||
const full = await api(`/documents/${doc.id}`);
|
||||
if (selectedDoc?.id === doc.id) selectedDoc = { ...doc, ...full };
|
||||
} catch { /* 실패 시 기본 정보 유지 */ }
|
||||
// 문서 열기 = 개선된 상세 페이지(D3 절 구조 탐색기)로 이동.
|
||||
// 사용자 결정: "개선된 페이지가 앞으로 표시되야지" — 인라인 미리보기 폐기.
|
||||
// /documents = 브라우즈/검색/필터/일괄 목록, 문서 열기 = /documents/[id] D3 리더.
|
||||
function selectDoc(doc) {
|
||||
if (!doc) return;
|
||||
goto(`/documents/${doc.id}`);
|
||||
}
|
||||
|
||||
// bulk 선택
|
||||
@@ -386,8 +382,8 @@
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
|
||||
<!-- ═══ 좌: 리스트 컬럼 ═══ -->
|
||||
<div class="{selectedDoc ? 'hidden lg:flex' : 'flex'} flex-col w-full lg:w-[340px] lg:shrink-0 lg:border-r border-default min-h-0">
|
||||
<!-- ═══ 문서 목록 (풀폭 중앙) — 클릭 시 D3 상세로 이동 ═══ -->
|
||||
<div class="flex flex-col w-full max-w-5xl mx-auto min-h-0">
|
||||
<UploadDropzone onupload={loadDocuments} />
|
||||
|
||||
<!-- 검색바 -->
|
||||
@@ -487,6 +483,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- AI 답변 (질문형 검색) — 목록 상단 고정, 아래로 목록 스크롤 -->
|
||||
{#if showAskCard}
|
||||
<div class="px-3 py-2 shrink-0 border-b border-default max-h-[55vh] overflow-y-auto">
|
||||
<AskAnswerCard
|
||||
data={askData}
|
||||
loading={askLoading}
|
||||
error={askError}
|
||||
onCitationClick={(docId) => goto(`/documents/${docId}`)}
|
||||
onDismiss={() => { askDismissed = true; }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 선택 toolbar -->
|
||||
{#if selectionCount > 0}
|
||||
<div class="flex flex-wrap items-center gap-2 px-3 py-2 shrink-0 bg-accent/10 border-y border-accent/30">
|
||||
@@ -587,47 +596,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 중앙: 리더 ═══ -->
|
||||
<div class="{selectedDoc ? 'flex' : 'hidden lg:flex'} flex-1 min-w-0 flex-col min-h-0">
|
||||
{#if selectedDoc}
|
||||
<!-- 리더 상단 바: (모바일) 뒤로 / (lg) 인스펙터 토글 -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 shrink-0 border-b border-default bg-sidebar">
|
||||
<button type="button" onclick={() => { selectedDoc = null; if (ui.isDrawerOpen('meta')) ui.closeDrawer(); }}
|
||||
class="lg:hidden flex items-center gap-1 text-xs text-accent-hover font-medium" aria-label="목록으로">
|
||||
<ChevronLeft size={15} /> 문서
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<button type="button" onclick={toggleInfoPanel} aria-pressed={isPanelActive} title="문서 정보"
|
||||
class="p-1.5 rounded-lg border transition-colors {isPanelActive ? 'border-accent text-accent bg-accent/10' : 'border-default text-dim hover:text-accent hover:border-accent'}">
|
||||
<Info size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<DocumentViewer doc={selectedDoc} />
|
||||
</div>
|
||||
{:else if showAskCard}
|
||||
<div class="p-4 lg:p-6 overflow-y-auto">
|
||||
<AskAnswerCard
|
||||
data={askData}
|
||||
loading={askLoading}
|
||||
error={askError}
|
||||
onCitationClick={(docId) => goto(`/documents/${docId}`)}
|
||||
onDismiss={() => { askDismissed = true; }}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="hidden lg:flex flex-1 items-center justify-center text-dim text-sm">
|
||||
왼쪽에서 문서를 선택하세요
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ═══ 우: 인스펙터 (xl+ inline) ═══ -->
|
||||
{#if selectedDoc && inspectorOpen}
|
||||
<aside class="hidden xl:flex flex-col w-[300px] shrink-0 border-l border-default bg-sidebar overflow-y-auto" aria-label="문서 정보">
|
||||
{@render inspector(selectedDoc)}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- < xl 폴백: Drawer (정보 하단/측면 시트) -->
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script>
|
||||
// Phase E.2 — detail 페이지 inline 편집.
|
||||
// 기존 read-only 메타 패널(L138–201)을 editors/* 스택으로 교체.
|
||||
// + E.3 관련 문서 stub, + 헤더 affordance row.
|
||||
// 문서 상세 /documents/[id] — 확정 시안(d3-deepened) 스타일을 그대로 포팅, 데이터만 바인딩.
|
||||
// 데스크탑: 상단 헤더 띠 + [좌 절 트리(색바+연결선)][중 절 집중 뷰][우 슬림 레일]. 절 없으면 fallback.
|
||||
// 모바일: 헤더 + 나란한 토글 pill(절구조|인사이트) + 본문 절 카드 연속(+탭 이동). 편집/필기/네비 보존.
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, getAccessToken } from '$lib/api';
|
||||
import { isMdSuccess } from '$lib/utils/mdStatus';
|
||||
import { resolveAnchorMap } from '$lib/utils/resolveAnchorMap';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { ExternalLink, Download, Link2, FileText, PenLine, X, ChevronLeft, ChevronRight, Check } from 'lucide-svelte';
|
||||
import { ChevronRight, FileText } from 'lucide-svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
|
||||
@@ -23,97 +21,57 @@
|
||||
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
|
||||
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
|
||||
import AIClassificationEditor from '$lib/components/editors/AIClassificationEditor.svelte';
|
||||
import FileInfoView from '$lib/components/editors/FileInfoView.svelte';
|
||||
import ProcessingStatusView from '$lib/components/editors/ProcessingStatusView.svelte';
|
||||
import LibraryPathEditor from '$lib/components/editors/LibraryPathEditor.svelte';
|
||||
import DocumentDangerZone from '$lib/components/editors/DocumentDangerZone.svelte';
|
||||
import AnalysisPanel from '$lib/components/AnalysisPanel.svelte';
|
||||
import ReadCounter from '$lib/components/ReadCounter.svelte';
|
||||
import SectionOutline from '$lib/components/SectionOutline.svelte';
|
||||
import Tabs from '$lib/components/ui/Tabs.svelte';
|
||||
import { cleanHeading, pathSegments, sectionTypeLabel, collapseWindows } from '$lib/utils/headingPath';
|
||||
import { domainLabel } from '$lib/utils/domainSlug';
|
||||
|
||||
marked.use({ mangle: false, headerIds: false });
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
return DOMPurify.sanitize(marked(text || ''), {
|
||||
USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'script'], FORBID_ATTR: ['onerror', 'onclick'], ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
}
|
||||
|
||||
let doc = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null); // 'not_found' | 'network' | null
|
||||
let rawMarkdown = $state(''); // fallback: extracted_text 없을 때 원본 .md
|
||||
|
||||
let error = $state(null);
|
||||
let rawMarkdown = $state('');
|
||||
let docId = $derived($page.params.id);
|
||||
|
||||
// 손글씨 노트 (자료별 1:1) — "필기" 토글 시 사이드 캔버스 띄움.
|
||||
// 필기
|
||||
let noteOpen = $state(false);
|
||||
let noteStrokes = $state(null); // { version, strokes }
|
||||
let noteStrokes = $state(null);
|
||||
let noteLoaded = $state(false);
|
||||
async function ensureNoteLoaded() {
|
||||
if (noteLoaded) return;
|
||||
try {
|
||||
const r = await api(`/documents/${docId}/note`);
|
||||
noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] };
|
||||
} catch {
|
||||
noteStrokes = { version: 1, strokes: [] };
|
||||
}
|
||||
try { const r = await api(`/documents/${docId}/note`); noteStrokes = r.strokes_json && r.strokes_json.strokes ? r.strokes_json : { version: 1, strokes: [] }; }
|
||||
catch { noteStrokes = { version: 1, strokes: [] }; }
|
||||
noteLoaded = true;
|
||||
}
|
||||
async function saveNote(strokesJson) {
|
||||
try {
|
||||
await api(`/documents/${docId}/note`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ strokes_json: strokesJson }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('필기 저장 실패', err);
|
||||
}
|
||||
}
|
||||
async function toggleNote() {
|
||||
if (!noteOpen) await ensureNoteLoaded();
|
||||
noteOpen = !noteOpen;
|
||||
}
|
||||
async function saveNote(s) { try { await api(`/documents/${docId}/note`, { method: 'PUT', body: JSON.stringify({ strokes_json: s }) }); } catch (e) { console.warn(e); } }
|
||||
async function toggleNote() { if (!noteOpen) await ensureNoteLoaded(); noteOpen = !noteOpen; }
|
||||
|
||||
// 인접 자료 (같은 library_path 내 이전/다음) — 학습 흐름 네비게이션
|
||||
// 인접 자료
|
||||
let neighbors = $state({ prev: null, next: null });
|
||||
async function loadNeighbors() {
|
||||
try {
|
||||
neighbors = await api(`/documents/${docId}/library-neighbors`);
|
||||
} catch {
|
||||
neighbors = { prev: null, next: null };
|
||||
}
|
||||
async function loadNeighbors() { try { neighbors = await api(`/documents/${docId}/library-neighbors`); } catch { neighbors = { prev: null, next: null }; } }
|
||||
async function readAndGoNext() {
|
||||
try { await api(`/documents/${docId}/read`, { method: 'POST' }); addToast('success', '1회독 완료'); }
|
||||
catch (err) { addToast('error', err?.detail || '회독 기록 실패'); return; }
|
||||
if (neighbors.next) goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
|
||||
// 절(hier section) 목차 — 본문 로드와 독립, 실패(404 포함) 무해.
|
||||
// reqId guard: 문서 전환 race 시 stale 결과가 새 문서에 붙지 않게.
|
||||
// 절 목차
|
||||
let sections = $state([]);
|
||||
let hasSections = $derived(sections.length > 0);
|
||||
// 과대 절은 builder 가 window 조각(같은 제목·is_leaf)으로 분해하고 부모를 heading 만 남긴 split-parent 로
|
||||
// 강등한다(예: 5180 = 27개 논리 절 → 562 window). raw sections 를 그대로 그리면 동일 제목 수백 행으로
|
||||
// 파편화되므로, collapseWindows 로 논리 절 1개(대표=split-parent, bodyText=window 본문 합본)로 합친다.
|
||||
let outline = $derived(collapseWindows(sections));
|
||||
async function loadSections() {
|
||||
const reqId = docId;
|
||||
try {
|
||||
const r = await api(`/documents/${reqId}/sections`);
|
||||
if (reqId === docId) sections = r?.sections ?? [];
|
||||
} catch {
|
||||
if (reqId === docId) sections = []; // Phase 1 미배포 시 404 → 목차 숨김(graceful)
|
||||
}
|
||||
}
|
||||
|
||||
// "1회독 완료 + 다음 자료로" 한 번에
|
||||
async function readAndGoNext() {
|
||||
try {
|
||||
await api(`/documents/${docId}/read`, { method: 'POST' });
|
||||
addToast('success', '1회독 완료');
|
||||
} catch (err) {
|
||||
addToast('error', err?.detail || '회독 기록 실패');
|
||||
return;
|
||||
}
|
||||
if (neighbors.next) {
|
||||
goto(`/documents/${neighbors.next.id}`);
|
||||
}
|
||||
try { const r = await api(`/documents/${reqId}/sections`); if (reqId === docId) sections = r?.sections ?? []; }
|
||||
catch { if (reqId === docId) sections = []; }
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -121,87 +79,26 @@
|
||||
doc = await api(`/documents/${docId}`);
|
||||
const vt = doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format);
|
||||
if ((vt === 'markdown' || vt === 'hwp-markdown') && !doc.extracted_text) {
|
||||
try {
|
||||
const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`);
|
||||
if (resp.ok) rawMarkdown = await resp.text();
|
||||
} catch (e) {
|
||||
rawMarkdown = '';
|
||||
}
|
||||
try { const resp = await fetch(`/api/documents/${docId}/file?token=${getAccessToken()}`); if (resp.ok) rawMarkdown = await resp.text(); } catch { rawMarkdown = ''; }
|
||||
}
|
||||
} catch (err) {
|
||||
error = err?.status === 404 ? 'not_found' : 'network';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
// 자료실 자료면 인접 자료 미리 fetch (학습 흐름 네비)
|
||||
} catch (err) { error = err?.status === 404 ? 'not_found' : 'network'; }
|
||||
finally { loading = false; }
|
||||
if (doc && doc.category === 'library') loadNeighbors();
|
||||
if (doc) loadSections();
|
||||
});
|
||||
|
||||
let viewerType = $derived(
|
||||
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
|
||||
);
|
||||
let viewerType = $derived(doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none');
|
||||
let canShowMarkdown = $derived(!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim()));
|
||||
// 절 본문은 청크 text(절별 원문)에서 오므로 md_content 성공/존재와 무관.
|
||||
// hasSections 만으로 절뷰 사용 → partial / 대형 split(md_content 5만 자 절단) 문서도 절뷰 표시.
|
||||
let useSectionView = $derived(hasSections);
|
||||
|
||||
// PDF 분기 전용: marker_worker 가 만든 canonical markdown 이 있으면 기본으로 그것을 보여줌.
|
||||
// Phase 1B 산출물의 95% 가 PDF 라 1D pilot 평가가 실사용 화면 기반이 되도록 markdown-first.
|
||||
// 사용자가 "PDF 원본" 토글하면 iframe. lastDocId 로 문서 전환만 감지해서 사용자 토글이
|
||||
// reactive cycle 에 덮이지 않도록 보호.
|
||||
let pdfViewMode = $state('markdown'); // 'markdown' | 'pdf'
|
||||
let pdfViewMode = $state('markdown');
|
||||
let lastDocId = $state(null);
|
||||
let canShowMarkdown = $derived(
|
||||
!!(isMdSuccess(doc?.md_status) && doc?.md_content?.trim())
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!doc) return;
|
||||
if (doc.id !== lastDocId) {
|
||||
lastDocId = doc.id;
|
||||
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
|
||||
}
|
||||
// 같은 문서 안에서 markdown 이 사라지면 (success → failed 재처리 등) PDF 로 보호.
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') {
|
||||
pdfViewMode = 'pdf';
|
||||
}
|
||||
});
|
||||
|
||||
// ── 개요 점프 (경로 B: BE char_start primary + string-match 폴백) ──
|
||||
// 이 사이트는 항상 md_content basis(canShowMarkdown && doc.md_content) → trustBE=true.
|
||||
// BE char_start 가 있으면 채택, 비면(non-PASS/미백필) resolveAnchorMap 내부에서 buildAnchorMap 로 폴백.
|
||||
let anchorMap = $derived(
|
||||
hasSections && canShowMarkdown && doc?.md_content
|
||||
? resolveAnchorMap(doc.md_content, sections, { trustBE: true }).anchors
|
||||
: {}
|
||||
);
|
||||
let activeKey = $state(null);
|
||||
function jumpToSection(chunkId) {
|
||||
const el = document.getElementById(`sec-${chunkId}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
|
||||
$effect(() => {
|
||||
void anchorMap; // 문서/섹션 변화 시 재바인딩
|
||||
if (typeof window === 'undefined') return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
let cur = null;
|
||||
document.querySelectorAll('.md-anchor').forEach((a) => {
|
||||
if (a.getBoundingClientRect().top <= 120) cur = a;
|
||||
});
|
||||
if (cur) {
|
||||
const m = cur.id.match(/^sec-(\d+)$/);
|
||||
if (m) activeKey = Number(m[1]);
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
if (doc.id !== lastDocId) { lastDocId = doc.id; pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf'; }
|
||||
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
|
||||
});
|
||||
|
||||
function getViewerType(format) {
|
||||
@@ -213,338 +110,373 @@
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
// E.2 affordance row 핸들러
|
||||
// 절 집중/모바일 상태
|
||||
let selectedSectionId = $state(null);
|
||||
let mTree = $state(false);
|
||||
let mIns = $state(false);
|
||||
let manageOpen = $state(false);
|
||||
$effect(() => { if (outline.length && !outline.some((it) => it.section.chunk_id === selectedSectionId)) selectedSectionId = outline[0].section.chunk_id; });
|
||||
let selectedItem = $derived(outline.find((it) => it.section.chunk_id === selectedSectionId) ?? outline[0] ?? null);
|
||||
let selectedSection = $derived(selectedItem?.section ?? null);
|
||||
let selIdx = $derived(outline.findIndex((it) => it.section.chunk_id === selectedItem?.section?.chunk_id));
|
||||
// 절 본문 = 청크 원문(it.bodyText, window 조각 합본) 직접 렌더. 과거 char_start 로 md_content 를
|
||||
// 슬라이스했으나, 대형 split 문서는 md_content 가 앞 5만 자만 보존되고 char_start 도 NULL 이라 본문이
|
||||
// 비었다. 청크 text 는 절 전체를 담으므로(절 보유 문서 344개, 본문 합 평균 68KB·max 1.6MB) 그대로 렌더.
|
||||
function bodyHtml(it) { return it?.bodyText ? renderMd(it.bodyText) : ''; }
|
||||
let selectedBodyHtml = $derived(bodyHtml(selectedItem));
|
||||
// 모바일 연속 카드: 본문은 '본문 보기' 펼칠 때만 파싱(논리 절 수백 개 × marked 즉시 파싱 회피).
|
||||
let mBodyOpen = $state({});
|
||||
|
||||
// 절 유형 색 (시안: 정의 청 / 절차 올리브 / 요건 황)
|
||||
const TYPE_META = {
|
||||
definition: { label: '정의', en: 'definition', color: '#2f7d8f' },
|
||||
procedure: { label: '절차', en: 'procedure', color: '#7a8b3f' },
|
||||
requirement: { label: '요건', en: 'requirement', color: '#b5840a' },
|
||||
};
|
||||
function typeMeta(t) { return TYPE_META[t] ?? { label: sectionTypeLabel(t) || '', en: t || '', color: '#9aa090' }; }
|
||||
function isLowConf(c) { return c != null && c < 0.5; }
|
||||
function isMidLow(c) { return c != null && c < 0.6; }
|
||||
function confColor(c) { return c == null ? '#9aa090' : c < 0.6 ? '#b5840a' : '#1f9d6b'; }
|
||||
function secTitle(s) { return cleanHeading(s.section_title) || pathSegments(s.heading_path).at(-1) || '(제목 없음)'; }
|
||||
function secDepth(s) { return Math.max(0, (s.level ?? 1) - 1); }
|
||||
function confPct(c) { return c == null ? 0 : Math.round(c * 100); }
|
||||
|
||||
// 도메인 색 (시안 도메인 팔레트)
|
||||
const DOMAIN_COLOR = { Industrial_Safety: '#b5840a', Engineering: '#2f7d8f', Programming: '#3d7256', General: '#7a8b3f', Reference: '#8a6a3f', Philosophy: '#7a6a9b' };
|
||||
function domainColor(d) { return DOMAIN_COLOR[(d || '').split('/')[0]] ?? '#697061'; }
|
||||
function fmtColor(f) { return f === 'pdf' ? '#c0564a' : f === 'md' ? '#5a8f7a' : ['m4a', 'mp3', 'wav'].includes(f) ? '#8a6aa5' : f === 'html' ? '#c2911f' : '#697061'; }
|
||||
|
||||
let quality = $derived(doc?.md_extraction_quality?.metrics ?? doc?.md_extraction_quality ?? null);
|
||||
|
||||
function copyLink() {
|
||||
const url = `${window.location.origin}/documents/${docId}`;
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => addToast('success', '링크 복사됨'))
|
||||
.catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
|
||||
function downloadOriginal() {
|
||||
window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.open(`/api/documents/${docId}/preview?token=${getAccessToken()}&download=true`);
|
||||
}
|
||||
|
||||
function handleDocDelete() {
|
||||
addToast('success', '문서가 삭제되어 목록으로 이동합니다.');
|
||||
goto('/documents');
|
||||
navigator.clipboard.writeText(`${window.location.origin}/documents/${docId}`).then(() => addToast('success', '링크 복사됨')).catch(() => addToast('error', '복사 실패'));
|
||||
}
|
||||
function downloadOriginal() { window.open(`/api/documents/${docId}/file?token=${getAccessToken()}&download=true`); }
|
||||
function handleDocDelete() { addToast('success', '문서가 삭제되어 목록으로 이동합니다.'); goto('/documents'); }
|
||||
</script>
|
||||
|
||||
<div class="p-4 lg:p-6">
|
||||
<!-- ════ 좌 트리 (시안: 색바 + 연결선 + 활성 + 저신뢰 경고) ════ -->
|
||||
{#snippet treeNav(jumpMode)}
|
||||
<div class="d3tree" style="font-size:14px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#697061;letter-spacing:.4px;">절 구조</div>
|
||||
<span style="font-size:10.5px;color:#9aa090;font-variant-numeric:tabular-nums;">{outline.length}절</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px 8px;margin-bottom:11px;padding-bottom:10px;border-bottom:1px solid #dde3d6;">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#2f7d8f;"></span>정의</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#7a8b3f;"></span>절차</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#697061;"><span style="width:8px;height:8px;border-radius:2px;background:#b5840a;"></span>요건</span>
|
||||
</div>
|
||||
{#each outline as it (it.section.chunk_id)}
|
||||
{@const s = it.section}
|
||||
{@const tm = typeMeta(it.sectionType)}
|
||||
{@const active = !jumpMode && s.chunk_id === selectedSection?.chunk_id}
|
||||
{@const child = secDepth(s) > 0}
|
||||
{@const low = isMidLow(it.confidence)}
|
||||
<svelte:element this={jumpMode ? 'a' : 'div'} href={jumpMode ? `#m-sec-${s.chunk_id}` : undefined} role="button" tabindex="0"
|
||||
onclick={() => !jumpMode && (selectedSectionId = s.chunk_id)}
|
||||
onkeydown={(e) => { if (!jumpMode && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectedSectionId = s.chunk_id; } }}
|
||||
class="d3node {child ? 'd3child' : ''} {active ? 'd3active' : ''}"
|
||||
style="display:block;border:1px solid {active ? '#4f8a6b' : low ? '#e7d49a' : 'transparent'};border-radius:9px;padding:{child ? '6px 8px' : '7px 8px'};margin-bottom:2px;{low ? 'background:#fbf6e6;' : ''}text-decoration:none;cursor:pointer;">
|
||||
<div style="display:flex;align-items:center;gap:7px;">
|
||||
<span style="width:3px;height:{child ? '13px' : '16px'};border-radius:2px;background:{tm.color};flex-shrink:0;"></span>
|
||||
<span class="d3title" style="font-size:{child ? '11.5px' : '12.5px'};flex:1;min-width:0;{child ? 'color:#697061;' : ''}{active ? 'color:#3d7256;font-weight:600;' : ''}overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{secTitle(s)}</span>
|
||||
{#if low}
|
||||
<span class="d3warn" title="저신뢰 절" style="display:inline-flex;width:14px;height:14px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;">!</span>
|
||||
{:else if !child}
|
||||
<span title="신뢰도 {it.confidence != null ? it.confidence.toFixed(2) : '—'}" style="width:7px;height:7px;border-radius:50%;background:{confColor(it.confidence)};flex-shrink:0;"></span>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
{/each}
|
||||
{#if quality}
|
||||
<div style="margin-top:12px;padding-top:10px;border-top:1px solid #dde3d6;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;margin-bottom:7px;letter-spacing:.3px;">추출 품질</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;font-size:10.5px;color:#697061;font-variant-numeric:tabular-nums;">
|
||||
{#if quality.headings != null}<span>headings <b style="color:#23291f;">{quality.headings}</b></span>{/if}
|
||||
{#if quality.tables != null}<span>tables <b style="color:#23291f;">{quality.tables}</b></span>{/if}
|
||||
{#if quality.images != null}<span>images <b style="color:#23291f;">{quality.images}</b></span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 절 집중 뷰 (데스크탑 중앙) ════ -->
|
||||
{#snippet focusView()}
|
||||
{#if selectedSection}
|
||||
{@const tm = typeMeta(selectedItem?.sectionType)}
|
||||
{@const conf = selectedItem?.confidence ?? null}
|
||||
{@const summaries = selectedItem?.summaries ?? []}
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:#9aa090;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<span class="truncate" style="max-width:200px;">{doc.title}</span>
|
||||
{#each pathSegments(selectedSection.heading_path) as seg}<span style="color:#c8d6c0;">/</span><span style="color:#697061;font-weight:600;">{seg}</span>{/each}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:13px;">
|
||||
<h2 style="margin:0;font-size:22px;font-weight:700;color:#23291f;line-height:1.3;flex:1;min-width:180px;">{secTitle(selectedSection)}</h2>
|
||||
{#if tm.label}<span style="display:inline-flex;align-items:center;gap:5px;padding:4px 11px;border-radius:999px;background:{tm.color}1a;border:1px solid {tm.color}55;font-size:12px;color:{tm.color};font-weight:600;"><span style="width:8px;height:8px;border-radius:2px;background:{tm.color};"></span>{tm.label} {tm.en}</span>{/if}
|
||||
</div>
|
||||
{#if conf != null}
|
||||
<div style="display:flex;align-items:center;gap:9px;margin-bottom:18px;">
|
||||
<span style="font-size:11px;color:#697061;font-weight:600;flex-shrink:0;">신뢰도</span>
|
||||
<div style="flex:1;max-width:300px;height:7px;border-radius:999px;background:#e3ebdf;overflow:hidden;"><div style="width:{confPct(conf)}%;height:100%;background:{confColor(conf)};border-radius:999px;"></div></div>
|
||||
<span style="font-size:13px;font-weight:700;color:{confColor(conf)};font-variant-numeric:tabular-nums;flex-shrink:0;">{conf.toFixed(2)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLowConf(conf)}
|
||||
<div style="display:flex;align-items:flex-start;gap:8px;background:#faf3e2;border:1px solid #ecdca3;border-radius:10px;padding:10px 12px;margin-bottom:16px;font-size:12.5px;color:#8a6306;"><span style="flex-shrink:0;width:16px;height:16px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 절 — 표·수식 추출이 불완전할 수 있습니다. 정확한 내용은 원본을 확인하세요.</span></div>
|
||||
{/if}
|
||||
{#if summaries.length}
|
||||
<div style="background:#ecf0e8;border-left:3px solid #4f8a6b;border-radius:0 10px 10px 0;padding:14px 16px;margin-bottom:20px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.6px;margin-bottom:6px;">절 요약{#if summaries.length > 1} · {summaries.length}개 부분{/if}</div>
|
||||
{#if summaries.length === 1}
|
||||
<div style="font-size:15.5px;line-height:1.6;color:#23291f;white-space:pre-line;">{summaries[0]}</div>
|
||||
{:else}
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:8px;">
|
||||
{#each summaries as sm, i}<li style="font-size:13.5px;line-height:1.55;color:#23291f;display:flex;gap:8px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedBodyHtml}
|
||||
<div class="prose prose-base max-w-none text-text">{@html selectedBodyHtml}</div>
|
||||
{:else}
|
||||
<p style="color:#9aa090;font-size:14px;font-style:italic;">이 절의 본문은 추출되지 않았습니다. 헤더의 '원본'에서 확인하세요.</p>
|
||||
{/if}
|
||||
<div style="display:flex;justify-content:space-between;gap:10px;margin-top:20px;padding-top:14px;border-top:1px solid #dde3d6;">
|
||||
{#if selIdx > 0}
|
||||
{@const pv = outline[selIdx - 1].section}
|
||||
<button type="button" onclick={() => (selectedSectionId = pv.chunk_id)} style="font-size:12px;color:#697061;border:1px solid #dde3d6;border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;">← {secTitle(pv)}</button>
|
||||
{:else}<span></span>{/if}
|
||||
{#if selIdx >= 0 && selIdx < outline.length - 1}
|
||||
{@const nxIt = outline[selIdx + 1]}
|
||||
{@const nx = nxIt.section}
|
||||
<button type="button" onclick={() => (selectedSectionId = nx.chunk_id)} style="font-size:12px;color:{isMidLow(nxIt.confidence) ? '#8a6306' : '#697061'};border:1px solid {isMidLow(nxIt.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:9px;padding:8px 12px;background:#fff;cursor:pointer;display:inline-flex;align-items:center;gap:6px;">{#if isMidLow(nxIt.confidence)}<span style="display:inline-flex;width:13px;height:13px;border-radius:50%;background:#b5840a;color:#fff;align-items:center;justify-content:center;font-size:8px;font-weight:700;">!</span>{/if}{secTitle(nx)} →</button>
|
||||
{:else}<span></span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 우 슬림 레일 (시안 카드 스타일) ════ -->
|
||||
{#snippet rail()}
|
||||
<div style="display:flex;flex-direction:column;gap:11px;font-size:14px;">
|
||||
{#if doc.ai_tldr || doc.ai_summary}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:7px;">TL;DR</div>
|
||||
<div style="font-size:12px;line-height:1.5;color:#23291f;">{doc.ai_tldr || doc.ai_summary}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_bullets && doc.ai_bullets.length}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">핵심점</div>
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:7px;">
|
||||
{#each doc.ai_bullets as b}<li style="font-size:12px;line-height:1.4;display:flex;gap:6px;"><span style="color:#b5840a;font-weight:700;flex-shrink:0;">·</span><span style="flex:1;min-width:0;color:#23291f;">{b}</span></li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_detail_summary}
|
||||
<div style="background:#f4f7f1;border:1px solid #c8d6c0;border-radius:14px;padding:13px;">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:7px;">
|
||||
<span style="font-size:10.5px;font-weight:700;color:#3d7256;letter-spacing:.4px;">심층</span>
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span style="font-size:9px;color:#fff;background:#4f8a6b;border-radius:999px;padding:1px 7px;font-weight:600;">DEEP</span>{/if}
|
||||
</div>
|
||||
<div style="font-size:11.5px;line-height:1.5;color:#23291f;white-space:pre-line;">{doc.ai_detail_summary}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_inconsistencies && doc.ai_inconsistencies.length}
|
||||
<div style="background:#fbf6e6;border:1px solid #e7d49a;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#8a6306;letter-spacing:.4px;margin-bottom:7px;">불일치 {doc.ai_inconsistencies.length}</div>
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:5px;">{#each doc.ai_inconsistencies as inc}<li style="font-size:11.5px;line-height:1.45;color:#23291f;">· {typeof inc === 'string' ? inc : inc.desc || inc.kind}</li>{/each}</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_domain}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">분류</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font-size:11.5px;">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">도메인</span><span style="display:inline-flex;align-items:center;gap:5px;color:#23291f;font-weight:600;text-align:right;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span></div>
|
||||
{#if doc.ai_sub_group}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">하위</span><span style="color:#23291f;font-weight:600;">{doc.ai_sub_group}</span></div>{/if}
|
||||
{#if doc.ai_analysis_tier}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">tier</span><span style="color:#3d7256;font-weight:600;">{doc.ai_analysis_tier}</span></div>{/if}
|
||||
{#if doc.ai_confidence != null}<div style="display:flex;justify-content:space-between;gap:8px;"><span style="color:#697061;">신뢰도</span><span style="color:#1f9d6b;font-weight:700;font-variant-numeric:tabular-nums;">{doc.ai_confidence.toFixed(2)}</span></div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if doc.ai_tags && doc.ai_tags.length}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:8px;">태그</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px;">{#each doc.ai_tags as t}<span style="font-size:11px;padding:3px 8px;border-radius:999px;background:#fff;border:1px solid #dde3d6;color:#697061;">{t}</span>{/each}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px;">
|
||||
<div style="font-size:10.5px;font-weight:700;color:#697061;letter-spacing:.4px;margin-bottom:6px;">관련 문서</div>
|
||||
<div style="font-size:11px;color:#9aa090;line-height:1.5;">벡터 유사도 기반 — 준비 중</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- ════ 절 카드 (모바일 연속 본문) ════ -->
|
||||
{#snippet sectionCard(it)}
|
||||
{@const s = it.section}
|
||||
{@const tm = typeMeta(it.sectionType)}
|
||||
<div id="m-sec-{s.chunk_id}" style="scroll-margin-top:12px;background:#f4f7f1;border:1px solid {isLowConf(it.confidence) ? '#e7d49a' : '#dde3d6'};border-radius:14px;padding:14px 15px;">
|
||||
<div style="display:flex;align-items:center;gap:7px;margin-bottom:7px;">
|
||||
<h2 style="margin:0;font-size:16px;font-weight:700;color:#23291f;flex:1;min-width:0;line-height:1.3;">{secTitle(s)}</h2>
|
||||
{#if tm.label}<span style="flex-shrink:0;font-size:10.5px;font-weight:650;padding:2px 8px;border-radius:999px;background:{tm.color}1a;color:{tm.color};white-space:nowrap;">{tm.label}</span>{/if}
|
||||
</div>
|
||||
{#if isLowConf(it.confidence)}
|
||||
<div style="display:flex;align-items:flex-start;gap:7px;background:#faf3e2;border:1px solid #ecdca3;border-radius:9px;padding:8px 10px;margin-bottom:10px;font-size:12px;color:#8a6306;"><span style="flex-shrink:0;width:15px;height:15px;border-radius:50%;border:1.5px solid #b5840a;color:#b5840a;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;margin-top:1px;">!</span><span>저신뢰 — 표·수식 추출 불완전, 원본 확인 권장</span></div>
|
||||
{/if}
|
||||
{#if it.summaries.length}
|
||||
<div style="border-left:3px solid #4f8a6b;background:#ecf0e8;border-radius:0 8px 8px 0;padding:9px 12px;margin-bottom:12px;">
|
||||
<div style="font-size:9.5px;font-weight:700;color:#3d7256;letter-spacing:.5px;margin-bottom:3px;">절 요약{#if it.summaries.length > 1} · {it.summaries.length}개 부분{/if}</div>
|
||||
{#if it.summaries.length === 1}
|
||||
<div style="font-size:13.5px;line-height:1.55;color:#23291f;white-space:pre-line;">{it.summaries[0]}</div>
|
||||
{:else}
|
||||
<ul style="margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;">{#each it.summaries as sm, i}<li style="font-size:12.5px;line-height:1.5;color:#23291f;display:flex;gap:6px;"><span style="flex-shrink:0;color:#7a8b3f;font-weight:700;font-variant-numeric:tabular-nums;">{i + 1}</span><span style="white-space:pre-line;">{sm}</span></li>{/each}</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if it.bodyText}
|
||||
<details class="m-secbody" ontoggle={(e) => { if (e.currentTarget.open) mBodyOpen[s.chunk_id] = true; }}>
|
||||
<summary style="cursor:pointer;list-style:none;font-size:12px;color:#697061;padding:5px 0;user-select:none;display:flex;align-items:center;gap:5px;">본문 보기 <span class="m-chev" style="transition:transform .16s;color:#9aa090;">›</span></summary>
|
||||
{#if mBodyOpen[s.chunk_id]}<div class="prose prose-sm max-w-none text-text" style="margin-top:6px;">{@html bodyHtml(it)}</div>{/if}
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div style="background:#e7ebe4;min-height:100%;" class="p-4 lg:p-6">
|
||||
<div style="max-width:1360px;margin:0 auto;">
|
||||
<!-- breadcrumb -->
|
||||
<div class="flex items-center gap-2 text-sm mb-4 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a>
|
||||
<span class="text-faint">/</span>
|
||||
<div class="flex items-center gap-2 text-sm mb-3 text-dim">
|
||||
<a href="/documents" class="hover:text-text">문서</a><span class="text-faint">/</span>
|
||||
<span class="truncate max-w-md text-text">{doc?.title || '로딩...'}</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
</div>
|
||||
<Skeleton h="h-96" rounded="card" />
|
||||
{:else if error === 'not_found'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 찾을 수 없습니다"
|
||||
description="삭제되었거나 접근 권한이 없을 수 있습니다."
|
||||
>
|
||||
<Button variant="ghost" size="sm" href="/documents">목록으로 돌아가기</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="문서를 찾을 수 없습니다" description="삭제되었거나 접근 권한이 없을 수 있습니다."><Button variant="ghost" size="sm" href="/documents">목록으로</Button></EmptyState>
|
||||
{:else if error === 'network'}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="문서를 불러올 수 없습니다"
|
||||
description="네트워크 오류가 발생했습니다."
|
||||
>
|
||||
<Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="문서를 불러올 수 없습니다" description="네트워크 오류"><Button variant="secondary" size="sm" onclick={() => location.reload()}>다시 시도</Button></EmptyState>
|
||||
{:else if doc}
|
||||
<div class="mx-auto grid grid-cols-1 gap-6 {hasSections ? 'max-w-7xl xl:grid-cols-[18rem_minmax(0,1fr)_20rem]' : 'max-w-6xl lg:grid-cols-3'}">
|
||||
{#if hasSections}
|
||||
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
|
||||
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
|
||||
<Card>
|
||||
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
|
||||
</Card>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<!-- 본문 (좌측 목차 없을 때 lg 2/3) -->
|
||||
<div class="{hasSections ? '' : 'lg:col-span-2'} space-y-4">
|
||||
{#if hasSections}
|
||||
<!-- xl 미만: 절 목차 접이식 -->
|
||||
<details class="xl:hidden">
|
||||
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
|
||||
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
|
||||
</details>
|
||||
{/if}
|
||||
<!-- Affordance row -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
Synology 편집
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Download} onclick={downloadOriginal}>
|
||||
원본 다운로드
|
||||
</Button>
|
||||
{#if doc.preview_status === 'ready'}
|
||||
<Button variant="secondary" size="sm" icon={FileText} onclick={downloadPdf}>
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" icon={Link2} onclick={copyLink}>
|
||||
링크 복사
|
||||
</Button>
|
||||
{#if doc.category === 'library'}
|
||||
<Button
|
||||
variant={noteOpen ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
icon={noteOpen ? X : PenLine}
|
||||
onclick={toggleNote}
|
||||
>
|
||||
{noteOpen ? '필기 닫기' : '필기'}
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- ════ 상단 띠: 문서 헤더 (시안) ════ -->
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-bottom:14px;">
|
||||
<div style="display:flex;align-items:flex-start;gap:13px;flex-wrap:wrap;">
|
||||
<div style="width:40px;height:40px;border-radius:10px;background:{fmtColor(doc.file_format)};color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:10.5px;letter-spacing:.5px;flex-shrink:0;text-transform:uppercase;">{doc.file_format}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-size:17px;font-weight:700;line-height:1.35;color:#23291f;">{doc.title}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center;">
|
||||
{#if doc.ai_domain}<span style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#23291f;"><span style="width:7px;height:7px;border-radius:50%;background:{domainColor(doc.ai_domain)};"></span>{domainLabel(doc.ai_domain)}</span>{/if}
|
||||
{#if doc.ai_sub_group}<span style="padding:3px 9px;border-radius:999px;background:#fff;border:1px solid #dde3d6;font-size:11.5px;color:#697061;">{doc.ai_sub_group}</span>{/if}
|
||||
{#if doc.ai_analysis_tier === 'deep'}<span style="padding:3px 9px;border-radius:999px;background:#4f8a6b;color:#fff;font-size:11.5px;font-weight:600;letter-spacing:.3px;">tier DEEP</span>{/if}
|
||||
{#if doc.ai_confidence != null}<span style="padding:3px 9px;border-radius:999px;background:#e3ebdf;border:1px solid #c8d6c0;font-size:11.5px;color:#3d7256;font-variant-numeric:tabular-nums;">신뢰도 {doc.ai_confidence.toFixed(2)}</span>{/if}
|
||||
{#if canShowMarkdown}<span style="padding:3px 9px;border-radius:999px;background:#eafaf2;border:1px solid #b8e3cc;font-size:11.5px;color:#1f9d6b;">PDF→MD success</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;">
|
||||
{#if doc.edit_url}<button type="button" onclick={() => window.open(doc.edit_url, '_blank')} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">Synology</button>{/if}
|
||||
<button type="button" onclick={downloadOriginal} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">원본</button>
|
||||
<button type="button" onclick={copyLink} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">링크</button>
|
||||
{#if doc.category === 'library'}<button type="button" onclick={toggleNote} style="font-size:11.5px;color:{noteOpen ? '#fff' : '#697061'};border:1px solid {noteOpen ? '#4f8a6b' : '#dde3d6'};border-radius:8px;padding:5px 9px;background:{noteOpen ? '#4f8a6b' : '#fff'};cursor:pointer;">{noteOpen ? '필기 닫기' : '필기'}</button>{/if}
|
||||
<button type="button" onclick={() => (manageOpen = !manageOpen)} style="font-size:11.5px;color:#697061;border:1px solid #dde3d6;border-radius:8px;padding:5px 9px;background:#fff;cursor:pointer;">관리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰어 — 모바일 가독성: 본문 폰트 키우고 line-height 늘림 -->
|
||||
<Card class="min-h-[500px]">
|
||||
{#if useSectionView}
|
||||
<!-- 데스크탑(xl+): 3영역 -->
|
||||
<div class="hidden xl:grid" style="grid-template-columns:252px minmax(0,1fr) 336px;gap:13px;align-items:start;">
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:13px 11px;position:sticky;top:14px;max-height:calc(100vh-2rem);overflow-y:auto;">{@render treeNav(false)}</div>
|
||||
<div style="min-width:0;"><div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:20px 22px;">{@render focusView()}</div></div>
|
||||
<div style="position:sticky;top:14px;">{@render rail()}</div>
|
||||
</div>
|
||||
|
||||
<!-- 모바일(<xl): 나란한 토글 pill + 패널 + 본문 연속 -->
|
||||
<div class="xl:hidden">
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px;position:sticky;top:0;z-index:5;background:#e7ebe4;padding:6px 0;">
|
||||
<button type="button" onclick={() => (mTree = !mTree)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mTree ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mTree ? '#4f8a6b' : '#dde3d6'};color:{mTree ? '#23291f' : '#697061'};">절 구조 <span style="font-size:10px;color:#9aa090;font-weight:500;">{outline.length}절</span><span style="transition:transform .16s;transform:rotate({mTree ? 90 : 0}deg);color:#9aa090;font-weight:700;">›</span></button>
|
||||
<button type="button" onclick={() => (mIns = !mIns)} style="flex:1;display:flex;align-items:center;justify-content:space-between;gap:6px;border-radius:10px;padding:9px 12px;font-size:12.5px;font-weight:600;cursor:pointer;background:{mIns ? '#e3ebdf' : '#f4f7f1'};border:1px solid {mIns ? '#4f8a6b' : '#dde3d6'};color:{mIns ? '#23291f' : '#697061'};">인사이트<span style="transition:transform .16s;transform:rotate({mIns ? 90 : 0}deg);color:#9aa090;font-weight:700;">›</span></button>
|
||||
</div>
|
||||
{#if mTree}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:6px;margin-bottom:10px;">{@render treeNav(true)}</div>{/if}
|
||||
{#if mIns}<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:12px;padding:13px 14px;margin-bottom:10px;">{@render rail()}</div>{/if}
|
||||
<div style="display:flex;flex-direction:column;gap:10px;">{#each outline as it (it.section.chunk_id)}{@render sectionCard(it)}{/each}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 절 없음 fallback: 절이 없어도 인사이트는 항상 보이게 (모바일=인사이트 상단 / 데스크탑=우측 레일) -->
|
||||
{#snippet fbViewer()}
|
||||
<div style="min-width:0;background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:18px 20px;min-height:360px;">
|
||||
{#if !hasSections && canShowMarkdown}<p style="font-size:11px;color:#9aa090;margin-bottom:12px;">이 문서는 절 분석이 없어 전체 본문으로 표시합니다. 위/옆 인사이트는 그대로 제공됩니다.</p>{/if}
|
||||
{#if viewerType === 'markdown' || viewerType === 'hwp-markdown'}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
anchorMap={anchorMap}
|
||||
extractedText={doc.extracted_text || rawMarkdown}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text || rawMarkdown} class="prose prose-base max-w-none" />
|
||||
{:else if viewerType === 'pdf'}
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<MarkdownStatusBadge
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
/>
|
||||
{#if canShowMarkdown}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'markdown')}
|
||||
>
|
||||
Markdown
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'}
|
||||
onclick={() => (pdfViewMode = 'pdf')}
|
||||
>
|
||||
PDF 원본
|
||||
</Button>
|
||||
{/if}
|
||||
<MarkdownStatusBadge mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} />
|
||||
{#if canShowMarkdown}<Button size="sm" variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'markdown')}>Markdown</Button><Button size="sm" variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'} onclick={() => (pdfViewMode = 'pdf')}>PDF 원본</Button>{/if}
|
||||
</div>
|
||||
{#if pdfViewMode === 'markdown' && canShowMarkdown}
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={doc.md_status}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="prose prose-invert prose-base lg:prose-sm max-w-none"
|
||||
/>
|
||||
{:else}
|
||||
<iframe
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
class="w-full h-[80vh] rounded"
|
||||
title={doc.title}
|
||||
></iframe>
|
||||
{/if}
|
||||
<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={doc.md_status} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />
|
||||
{:else}<iframe src="/api/documents/{doc.id}/file?token={getAccessToken()}" class="w-full h-[80vh] rounded" title={doc.title}></iframe>{/if}
|
||||
{:else if viewerType === 'image'}
|
||||
<img
|
||||
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
|
||||
alt={doc.title}
|
||||
class="max-w-full rounded"
|
||||
/>
|
||||
<img src="/api/documents/{doc.id}/file?token={getAccessToken()}" alt={doc.title} class="max-w-full rounded" />
|
||||
{:else if viewerType === 'synology'}
|
||||
<EmptyState
|
||||
icon={ExternalLink}
|
||||
title="Synology Office 문서"
|
||||
description="외부 편집기에서 열어야 합니다."
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
href={doc.edit_url || 'https://link.hyungi.net'}
|
||||
target="_blank"
|
||||
>
|
||||
새 창에서 열기
|
||||
</Button>
|
||||
</EmptyState>
|
||||
<EmptyState icon={FileText} title="Synology Office 문서" description="외부 편집기에서 열어야 합니다."><Button variant="primary" size="sm" href={doc.edit_url || 'https://link.hyungi.net'} target="_blank">새 창에서 열기</Button></EmptyState>
|
||||
{:else if viewerType === 'article'}
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text mb-3">{doc.title}</h1>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
|
||||
<span>출처: {doc.source_channel}</span>
|
||||
<span class="text-faint">·</span>
|
||||
<span>
|
||||
{new Date(doc.created_at).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{#if doc.md_content || doc.extracted_text}
|
||||
<!-- article = 텍스트 네이티브(markdown 변환 비대상). md_status='skipped' 라도
|
||||
"Markdown 제외" badge 를 띄우지 않도록 mdStatus 미전달(badge 는 mdStatus 로만 구동). -->
|
||||
<MarkdownDoc
|
||||
documentId={doc.id}
|
||||
mdContent={doc.md_content}
|
||||
mdFrontmatter={doc.md_frontmatter}
|
||||
mdStatus={null}
|
||||
mdExtractionError={doc.md_extraction_error}
|
||||
mdExtractionQuality={doc.md_extraction_quality}
|
||||
extractedText={doc.extracted_text}
|
||||
class="mb-6"
|
||||
/>
|
||||
{/if}
|
||||
{#if doc.edit_url}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={ExternalLink}
|
||||
href={doc.edit_url}
|
||||
target="_blank"
|
||||
>
|
||||
원문 보기
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="인앱 미리보기 미지원"
|
||||
description="포맷: {doc.file_format}"
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
{#if doc.md_content || doc.extracted_text}<MarkdownDoc documentId={doc.id} mdContent={doc.md_content} mdFrontmatter={doc.md_frontmatter} mdStatus={null} mdExtractionError={doc.md_extraction_error} mdExtractionQuality={doc.md_extraction_quality} extractedText={doc.extracted_text} class="prose prose-base max-w-none" />{/if}
|
||||
{#if doc.edit_url}<div class="mt-4"><Button variant="primary" size="sm" href={doc.edit_url} target="_blank">원문 보기</Button></div>{/if}
|
||||
{:else}<EmptyState icon={FileText} title="인앱 미리보기 미지원" description="포맷: {doc.file_format}" />{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- 손글씨 노트 패드 (자료실 자료, "필기" 토글 시) -->
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<Card class="overflow-hidden p-0">
|
||||
<div class="h-[60vh] min-h-[400px] flex flex-col">
|
||||
<HandwriteCanvas
|
||||
sessionId={doc.id}
|
||||
initialStrokes={noteStrokes}
|
||||
onChange={(strokes) => saveNote(strokes)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
<!-- 데스크탑: 본문 | 인사이트 레일 -->
|
||||
<div class="hidden xl:grid xl:grid-cols-[minmax(0,1fr)_336px] gap-3.5 items-start">
|
||||
{@render fbViewer()}
|
||||
<div style="position:sticky;top:14px;">{@render rail()}</div>
|
||||
</div>
|
||||
<!-- 모바일: 인사이트(상단 상시) + 본문 -->
|
||||
<div class="xl:hidden">
|
||||
<div style="margin-bottom:12px;">{@render rail()}</div>
|
||||
{@render fbViewer()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 오른쪽 — 메타 Tabs [정보 | AI | 관리] (카드 11개 수직 스프롤 해소) -->
|
||||
<aside class="min-w-0">
|
||||
<Card>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'info', label: '정보' },
|
||||
{ id: 'ai', label: 'AI' },
|
||||
{ id: 'manage', label: '관리' },
|
||||
]}
|
||||
>
|
||||
{#snippet children(tab)}
|
||||
<div class="pt-3 space-y-4">
|
||||
{#if tab === 'info'}
|
||||
{#if doc.category === 'library'}
|
||||
<ReadCounter
|
||||
documentId={doc.id}
|
||||
initialCount={doc.read_count ?? 0}
|
||||
initialLastReadAt={doc.last_read_at ?? null}
|
||||
/>
|
||||
{/if}
|
||||
<FileInfoView {doc} />
|
||||
<ProcessingStatusView {doc} />
|
||||
{:else if tab === 'ai'}
|
||||
<AnalysisPanel docId={doc.id} doc={doc} />
|
||||
<AIClassificationEditor {doc} />
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-dim uppercase mb-1.5">관련 문서</h4>
|
||||
<!-- TODO(backend): GET /documents/{id}/related?limit=10 (벡터 유사도) -->
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="추후 지원"
|
||||
description="관련 문서 추천은 backend 연동 후 제공됩니다."
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
<div class="pt-2 border-t border-default">
|
||||
<DocumentDangerZone {doc} ondelete={handleDocDelete} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
<!-- 관리 (편집/삭제) — 헤더 '관리'로 토글 -->
|
||||
{#if manageOpen}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;padding:16px 18px;margin-top:14px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#697061;margin-bottom:12px;letter-spacing:.3px;">관리 · 분류 편집</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<AIClassificationEditor {doc} />
|
||||
<LibraryPathEditor {doc} />
|
||||
<NoteEditor {doc} />
|
||||
<EditUrlEditor {doc} />
|
||||
<TagsEditor {doc} />
|
||||
</div>
|
||||
<div class="pt-3 mt-3 border-t border-default"><DocumentDangerZone {doc} ondelete={handleDocDelete} /></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if noteOpen && doc.category === 'library' && noteLoaded}
|
||||
<div style="background:#f4f7f1;border:1px solid #dde3d6;border-radius:14px;overflow:hidden;margin-top:14px;"><div class="h-[60vh] min-h-[400px] flex flex-col"><HandwriteCanvas sessionId={doc.id} initialStrokes={noteStrokes} onChange={(s) => saveNote(s)} /></div></div>
|
||||
{/if}
|
||||
|
||||
<!-- 모바일 sticky 하단 바 — 자료실 자료의 학습 흐름 네비게이션 -->
|
||||
{#if doc.category === 'library'}
|
||||
<div class="lg:hidden fixed bottom-0 inset-x-0 z-30 bg-surface border-t border-default px-3 py-2 flex items-center gap-2 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)}
|
||||
disabled={!neighbors.prev}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="이전 자료"
|
||||
><ChevronLeft size={20} /></button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={readAndGoNext}
|
||||
disabled={!neighbors.next}
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
<Check size={16} />
|
||||
{#if neighbors.next}
|
||||
1회독 완료 + 다음
|
||||
{:else}
|
||||
1회독 완료 (마지막 자료)
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)}
|
||||
disabled={!neighbors.next}
|
||||
class="px-2 py-2 rounded text-dim disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="다음 자료 (회독 카운트 안 함)"
|
||||
><ChevronRight size={20} /></button>
|
||||
<button type="button" onclick={() => neighbors.prev && goto(`/documents/${neighbors.prev.id}`)} disabled={!neighbors.prev} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="이전">‹</button>
|
||||
<button type="button" onclick={readAndGoNext} disabled={!neighbors.next} class="flex-1 px-3 py-2.5 rounded-lg bg-accent text-white text-sm font-medium disabled:opacity-50">{#if neighbors.next}1회독 완료 + 다음{:else}1회독 완료 (마지막){/if}</button>
|
||||
<button type="button" onclick={() => neighbors.next && goto(`/documents/${neighbors.next.id}`)} disabled={!neighbors.next} class="px-3 py-2 rounded text-dim disabled:opacity-30" aria-label="다음">›</button>
|
||||
</div>
|
||||
<!-- 본문이 sticky 바 뒤에 가리지 않도록 패딩 -->
|
||||
<div class="lg:hidden h-20"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.d3node:hover { background: #ecf0e8; }
|
||||
.d3active:hover { background: #e3ebdf; }
|
||||
.d3child { position: relative; }
|
||||
.d3child::before { content: ""; position: absolute; left: 2px; top: -3px; bottom: 50%; width: 1px; background: #cdd6c4; }
|
||||
.d3child::after { content: ""; position: absolute; left: 2px; top: 50%; width: 7px; height: 1px; background: #cdd6c4; }
|
||||
.m-secbody[open] .m-chev { transform: rotate(90deg); }
|
||||
.d3warn { animation: d3pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes d3pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(181, 132, 10, .35); } 50% { box-shadow: 0 0 0 3px rgba(181, 132, 10, 0); } }
|
||||
</style>
|
||||
|
||||
@@ -103,6 +103,32 @@ def test_summarize_pool_split_attribution():
|
||||
assert macbook["pending"] == 0 # 풀 pending 은 macmini 만
|
||||
|
||||
|
||||
def test_summarize_by_machine_projection():
|
||||
"""build_summarize_by_machine = split 의 done_1h/done_today 를 머신별로 투영
|
||||
(done_15m 은 제외 — 내부 state 판정 전용)."""
|
||||
from services.queue_overview import build_summarize_by_machine
|
||||
split = _split(
|
||||
macbook={"done_1h": 226, "done_today": 312, "done_15m": 60},
|
||||
macmini={"done_1h": 37, "done_today": 94, "done_15m": 9},
|
||||
)
|
||||
sbm = build_summarize_by_machine(split)
|
||||
assert sbm == {
|
||||
"macmini": {"done_1h": 37, "done_today": 94},
|
||||
"macbook": {"done_1h": 226, "done_today": 312},
|
||||
}
|
||||
assert "done_15m" not in sbm["macbook"]
|
||||
|
||||
|
||||
def test_compose_overview_includes_summarize_by_machine():
|
||||
"""compose_overview 응답 계약에 summarize_by_machine 포함 (FE 레인 분담 재료)."""
|
||||
now_kst = datetime(2026, 6, 13, 13, 0, tzinfo=KST)
|
||||
stats = {"summarize": _stage(pending=1317, done_1h=264)}
|
||||
split = _split(macbook={"done_1h": 226, "done_today": 312}, macmini={"done_1h": 37, "done_today": 94})
|
||||
ov = compose_overview(stats, split, {}, {}, [], deep_enabled=True, now_kst=now_kst)
|
||||
assert ov["summarize_by_machine"]["macbook"]["done_1h"] == 226
|
||||
assert ov["summarize_by_machine"]["macmini"]["done_today"] == 94
|
||||
|
||||
|
||||
def test_deep_disabled_deep_summary_counts_to_macmini():
|
||||
stats = {"deep_summary": _stage(pending=2, processing=1, done_1h=3, done_today=4)}
|
||||
machines = build_machines(stats, _split(), [], deep_enabled=False)
|
||||
|
||||
Reference in New Issue
Block a user