feat(dashboard): §4 — 카테고리/제안/queue lag 카드 + docs/categories.md
frontend +page.svelte: - 4-card 메인 row 아래 새 row 추가: 자료실/오디오/비디오 (category_counts) + 자료실 제안 (library_pending_suggestions). 제안 ≥1 일 때 warning 색 + /library 링크. - buildPipelineRows 가 pipeline_status (24h 누적) + queue_lag (현재 시점) 머지. queue_lag.oldest_pending_age_sec 가 600초 초과면 stage 라벨 옆에 경과시간 표시. - STAGE_ORDER/LABEL 에 stt/thumbnail 추가 (§3 신규 stage 자동 커버). docs/categories.md (신규): - 6 활성 + 3 유보 카테고리 정의 + 저장 경로 + 처리 파이프 - 역할 분리 원칙 (category / user_tags @library/ / facet_doctype / ai_suggestion) - 업로드 경로 매트릭스 (web/NAS/collector/UI) - video 채널별 정책 표 (web 거부 vs NAS quarantine) - 업로드 한도 + error_code 7종 표 - orphan 임시파일 cleanup 정책 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# 카테고리 체계
|
||||
|
||||
Document Server 의 1차 진입점. 6 활성 + 3 유보. 자세한 결정 배경은
|
||||
`~/.claude/plans/luminous-sprouting-hamster.md` (v2.1) 참조.
|
||||
|
||||
## 6 활성 + 3 유보
|
||||
|
||||
| 카테고리 | 상태 | 저장 경로 (`/volume4/Document_Server/PKM/`) | 처리 파이프 |
|
||||
|---|---|---|---|
|
||||
| `document` | 활성 | `Inbox/...` | extract → classify → embed (+ preview/OCR) |
|
||||
| `library` | 활성 — **사용자 승인 후 전이** | 동상 + `@library/...` 태그 | 동상 |
|
||||
| `news` | 활성 | DB 만 | news_collector → classify |
|
||||
| `memo` | 활성 | DB 만 | 메모 UI → embed |
|
||||
| `audio` | 활성 (§3) | `Recordings/` | **stt → classify → embed** (extract 건너뜀) |
|
||||
| `video` | 활성 (§3, 최소판) | `Videos/` | **thumbnail 만** (classify/embed 안 함) |
|
||||
| `mail` | 유보 | (별도 plan) | — |
|
||||
| `calendar` | 유보 | (별도 plan) | — |
|
||||
| `plex` | 유보 | (별도 plan) | — |
|
||||
|
||||
## 역할 분리 원칙 (4 축)
|
||||
|
||||
| 축 | 역할 | 주인 | 누가 변경 |
|
||||
|---|---|---|---|
|
||||
| `documents.category` enum | **1차 진입점** — UI 탭/라우트/Sidebar 분기 | document/library/news/memo/audio/video (+ 유보) | 백필(§1) + 사용자 승인(§2) + file_watcher(§3) |
|
||||
| `user_tags` 의 `@library/...` | 자료실 **내부 서가 경로** (카테고리가 library 일 때 위치) | 사용자 승인 태그 | 사용자 승인 + PATCH `/documents/{id}` |
|
||||
| `facet_doctype` | **AI 가 식별한 문서 유형** (분류 신호, 필터/검색 key) | 분류 파이프라인 산출물 | classify_worker |
|
||||
| `ai_suggestion` (JSONB) | **AI 가 제안했지만 미승인** 된 변경 후보 (category/path/doctype) | 승인 UI 소비 | classify_worker 작성 / `/accept-suggestion` clear |
|
||||
|
||||
**4 축을 섞지 않는다.** 예:
|
||||
|
||||
- AI 가 발주서라고 판단 → `facet_doctype='발주서'` 설정 (분류 신호 완료). `category` 는 **건드리지 않음**.
|
||||
- 동시에 `ai_suggestion = {proposed_category:'library', proposed_path, confidence, source_updated_at}` 저장.
|
||||
- 사용자가 `/library` 승인 UI 에서 1-click 승인 → 그제서야 `category='library'` + `user_tags += @library/...`, `ai_suggestion` clear.
|
||||
- 승인 전까지 문서는 `category='document'` 로 **문서함** 에 남음.
|
||||
|
||||
**자동 library 승격은 v2.1 에서 하지 않는다.** 메트릭(`precision > 0.9` AND `reject_rate < 0.1` AND `월 제안 수 > 20` 1개월 유지) 충족 시 별도 plan 으로 검토. → `~/.claude/projects/-Users-hyungiahn/memory/project_document_server_future_integrations.md`
|
||||
|
||||
## 업로드 경로
|
||||
|
||||
| 채널 | document | library | audio | video | news | memo |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 웹 업로드 (`UploadDropzone`) | ✅ | indirect (§4 의존) | ✅ | ✅ direct play 만 (mp4/webm) | ❌ | ❌ |
|
||||
| NAS 드롭 (`file_watcher`) | ✅ | ❌ (자동 전이 금지) | ✅ | ✅ + quarantine | ❌ | ❌ |
|
||||
| 외부 collector | ❌ | ❌ | ❌ | ❌ | `news_collector` | ❌ |
|
||||
| 내부 UI | ❌ | ❌ | ❌ | ❌ | ❌ | `/memos` |
|
||||
|
||||
## Video 채널별 정책 (§3 핵심 결정)
|
||||
|
||||
`mp4 (H.264 AAC)`, `webm (VP9)` 만 브라우저 direct play 가능. 그 외 (`mov/mkv/avi`) 는 채널마다 다르게 처리:
|
||||
|
||||
| 채널 | `.mp4` `.webm` | `.mov` `.mkv` `.avi` |
|
||||
|---|---|---|
|
||||
| **웹 업로드** | 수락 → `category='video'`, 썸네일 생성 | **거부 (400 + `error_code='unsupported_codec'`)**. `UploadDropzone` 가 배너 안내. |
|
||||
| **NAS 드롭 (file_watcher)** | 수락 → `category='video'`, 썸네일 생성 | **quarantine import** — DB 에 `category='video' + needs_conversion=true`, VideoPlayer 가 "재생 불가 — 변환 필요" 카드. 파일 삭제 안 함. |
|
||||
|
||||
## 업로드 한도 + error_code 체계 (§4)
|
||||
|
||||
업로드 한도는 `settings.upload.max_bytes` 가 단일 진실 공급원 (현재 100MB).
|
||||
프록시 (home-caddy) 는 `max_bytes * content_length_slack_ratio` (기본 1.05) 이상 유지.
|
||||
|
||||
서버 응답 `detail` 은 `{error_code, message}` 객체:
|
||||
|
||||
| `error_code` | HTTP | 의미 |
|
||||
|---|---|---|
|
||||
| `body_too_large` | 413 | Content-Length 또는 누적이 max_bytes 초과 |
|
||||
| `upload_timeout` | 408 | 서버 read timeout |
|
||||
| `network_abort` | 499 | 클라이언트 abort / 연결 끊김 |
|
||||
| `empty_file` | 400 | 0 바이트 |
|
||||
| `invalid_input` | 400 | 파일명/경로/필드 검증 실패 |
|
||||
| `unsupported_codec` | 400 | 웹 업로드 direct play 불가 비디오 (`.mov/.mkv/.avi`) |
|
||||
| `internal` | 500 | 그 외 알 수 없는 에러 |
|
||||
|
||||
## Orphan 임시파일 정리
|
||||
|
||||
업로드 중에는 `<name>.uploading` 임시명으로 NAS Inbox 에 쓰고 완료 시 atomic rename. 정상 abort 는 endpoint 가 즉시 정리하지만 **프로세스 크래시 / 강제 종료** 잔존물은 `cleanup_orphan_uploads` APScheduler job (10분 주기) 이 수거. 최근 3회 누적 삭제 ≥ `cleanup_warn_threshold` (기본 10) 이면 WARNING 로그 — abort 가 구조적으로 많거나 대용량 업로드 실패 반복 신호.
|
||||
|
||||
`file_watcher` 의 `SKIP_EXTENSIONS` 에 `.uploading` 포함 — 진행 중 파일을 잘못 픽업하지 않음.
|
||||
@@ -7,13 +7,17 @@
|
||||
dashboardSummary,
|
||||
type DashboardSummary,
|
||||
type PipelineStatus,
|
||||
type QueueLag,
|
||||
} from '$lib/stores/system';
|
||||
import { domainBgClass, domainLabel } from '$lib/utils/domainSlug';
|
||||
import { api } from '$lib/api';
|
||||
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 { Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil } from 'lucide-svelte';
|
||||
import {
|
||||
Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil,
|
||||
Library, Mic, Video, Sparkles,
|
||||
} from 'lucide-svelte';
|
||||
import { renderMemoHtml, toggleTaskLine } from '$lib/utils/memoRenderer';
|
||||
import { addToast } from '$lib/stores/toast';
|
||||
|
||||
@@ -56,40 +60,77 @@
|
||||
}
|
||||
|
||||
// ─── 파이프라인 ───
|
||||
const STAGE_ORDER = ['extract', 'classify', 'embed', 'preview'] as const;
|
||||
const STAGE_ORDER = ['extract', 'stt', 'classify', 'embed', 'preview', 'thumbnail'] as const;
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
extract: '추출', classify: '분류', embed: '임베딩', preview: '미리보기',
|
||||
extract: '추출', stt: '전사', classify: '분류', embed: '임베딩',
|
||||
preview: '미리보기', thumbnail: '썸네일',
|
||||
};
|
||||
|
||||
interface PipelineRow {
|
||||
stage: string; label: string;
|
||||
pending: number; processing: number; failed: number; total: number;
|
||||
// §4 — queue_lag 의 oldest_pending_age_sec (적체 신호용)
|
||||
oldestPendingAgeSec: number | null;
|
||||
}
|
||||
|
||||
function buildPipelineRows(items: PipelineStatus[]): PipelineRow[] {
|
||||
const grouped = new Map<string, { pending: number; processing: number; failed: number }>();
|
||||
function buildPipelineRows(items: PipelineStatus[], lag: QueueLag[]): PipelineRow[] {
|
||||
// §4 — 24h 누적 (pipeline_status) + 현재 시점 lag (queue_lag) 두 소스 머지.
|
||||
// queue_lag 가 있으면 stage 별 pending/processing/failed 는 그쪽 (정확) 사용.
|
||||
const lagMap = new Map(lag.map((l) => [l.stage, l]));
|
||||
const grouped = new Map<string, { pending: number; processing: number; failed: number; ageSec: number | null }>();
|
||||
for (const it of items) {
|
||||
const cur = grouped.get(it.stage) ?? { pending: 0, processing: 0, failed: 0 };
|
||||
const cur = grouped.get(it.stage) ?? { pending: 0, processing: 0, failed: 0, ageSec: null };
|
||||
if (it.status === 'pending') cur.pending += it.count;
|
||||
else if (it.status === 'processing') cur.processing += it.count;
|
||||
else if (it.status === 'failed') cur.failed += it.count;
|
||||
grouped.set(it.stage, cur);
|
||||
}
|
||||
// queue_lag 로 덮어쓰기 (현재 시점 신호가 우선)
|
||||
for (const l of lag) {
|
||||
grouped.set(l.stage, {
|
||||
pending: l.pending, processing: l.processing, failed: l.failed,
|
||||
ageSec: l.oldest_pending_age_sec,
|
||||
});
|
||||
}
|
||||
// queue_lag 만 있는 stage 도 전부 포함
|
||||
const allStages = new Set([...grouped.keys(), ...lagMap.keys()]);
|
||||
const orderedStages = [
|
||||
...STAGE_ORDER.filter((s) => grouped.has(s)),
|
||||
...[...grouped.keys()].filter((s) => !STAGE_ORDER.includes(s as never)),
|
||||
...STAGE_ORDER.filter((s) => allStages.has(s)),
|
||||
...[...allStages].filter((s) => !STAGE_ORDER.includes(s as never)),
|
||||
];
|
||||
return orderedStages.map((stage) => {
|
||||
const r = grouped.get(stage)!;
|
||||
return { stage, label: STAGE_LABEL[stage] ?? stage, ...r, total: r.pending + r.processing + r.failed };
|
||||
return {
|
||||
stage, label: STAGE_LABEL[stage] ?? stage,
|
||||
pending: r.pending, processing: r.processing, failed: r.failed,
|
||||
total: r.pending + r.processing + r.failed,
|
||||
oldestPendingAgeSec: r.ageSec,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
let pipelineRows = $derived(summary ? buildPipelineRows(summary.pipeline_status) : []);
|
||||
let pipelineRows = $derived(
|
||||
summary ? buildPipelineRows(summary.pipeline_status, summary.queue_lag ?? []) : []
|
||||
);
|
||||
let pipelineMax = $derived(Math.max(1, ...pipelineRows.map((r) => r.total)));
|
||||
let totalFailed = $derived(summary?.failed_count ?? 0);
|
||||
let totalPending = $derived(pipelineRows.reduce((s, r) => s + r.pending, 0));
|
||||
|
||||
// §4 — 카테고리 mini-card 데이터
|
||||
const CATEGORY_CARDS: { key: string; label: string; href: string; icon: any }[] = [
|
||||
{ key: 'library', label: '자료실', href: '/library', icon: Library },
|
||||
{ key: 'audio', label: '오디오', href: '/audio', icon: Mic },
|
||||
{ key: 'video', label: '비디오', href: '/video', icon: Video },
|
||||
];
|
||||
|
||||
function formatAge(sec: number | null): string {
|
||||
if (sec == null || sec <= 0) return '';
|
||||
if (sec < 60) return `${sec}초 전`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}분 전`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}시간 전`;
|
||||
return `${Math.floor(sec / 86400)}일 전`;
|
||||
}
|
||||
|
||||
// 파이프라인 접힘 상태
|
||||
let pipelineManualClosed = $state(false);
|
||||
let pipelineOpen = $derived(pipelineManualClosed ? false : totalFailed > 0);
|
||||
@@ -241,6 +282,43 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 3.5. 카테고리 + 자료실 제안 (§4) ═══ -->
|
||||
{#if summary.category_counts && (Object.keys(summary.category_counts).length > 0 || summary.library_pending_suggestions > 0)}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-5">
|
||||
{#each CATEGORY_CARDS as cat}
|
||||
{@const count = summary.category_counts?.[cat.key] ?? 0}
|
||||
<a href={cat.href} class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">{cat.label}</p>
|
||||
<cat.icon size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 text-text">{count.toLocaleString()}</p>
|
||||
<p class="text-xs text-dim mt-1">카테고리</p>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!-- 자료실 제안 (action card) -->
|
||||
<a href="/library" class="block">
|
||||
<Card interactive class="h-full">
|
||||
<div class="flex items-start justify-between">
|
||||
<p class="text-sm text-dim">자료실 제안</p>
|
||||
<Sparkles size={18} class="text-faint" />
|
||||
</div>
|
||||
<p class="text-2xl font-bold mt-2 {summary.library_pending_suggestions > 0 ? 'text-warning' : 'text-success'}">
|
||||
{summary.library_pending_suggestions}
|
||||
</p>
|
||||
{#if summary.library_pending_suggestions > 0}
|
||||
<p class="text-xs text-accent mt-1">검토하기 →</p>
|
||||
{:else}
|
||||
<p class="text-xs text-dim mt-1">대기 없음</p>
|
||||
{/if}
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ═══ 4. 최근 활동 ═══ -->
|
||||
<Card class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -319,7 +397,14 @@
|
||||
{#each pipelineRows as row (row.stage)}
|
||||
<div>
|
||||
<div class="flex items-center justify-between text-xs mb-1.5">
|
||||
<span class="text-dim">{row.label}</span>
|
||||
<span class="text-dim">
|
||||
{row.label}
|
||||
{#if row.oldestPendingAgeSec && row.oldestPendingAgeSec > 600}
|
||||
<span class="ml-1 text-warning" title="가장 오래된 pending 의 경과 시간">
|
||||
({formatAge(row.oldestPendingAgeSec)})
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="text-dim tabular-nums">
|
||||
대기 <span class="text-text">{row.pending}</span> ·
|
||||
처리 <span class="text-text">{row.processing}</span> ·
|
||||
|
||||
Reference in New Issue
Block a user