Files
hyungi_document_server/frontend/src/routes/inbox/+page.svelte
Hyungi Ahn cdcbb07561 fix(inbox): page_size=200 → 422 해결, review_status 서버 필터 추가
Inbox 페이지가 /documents/?page_size=200 를 호출하는데 백엔드 Query 가
le=100 이라 422 발생 — Phase 2 첫 commit(2026-04-02)부터 dormant 버그.
inbox 코드 안에 'TODO(backend): review_status filter 지원 시 page_size 축소'
주석이 있던 상태.

backend:
- list_documents 에 review_status: str | None Query 파라미터 추가
- WHERE 절에 review_status 매칭 분기 추가

frontend:
- /documents/?review_status=pending&page_size=100 으로 변경
- 클라이언트 필터링 코드 제거 (서버 필터로 대체)

100 미만 안전. pending 이 100 넘으면 다음 페이지 로직 추가 필요 (별도 작업).
2026-04-09 08:31:51 +09:00

493 lines
17 KiB
Svelte

<script>
// Phase F — Inbox 분류 UX 전면 재작성.
// - F.1: review_status: 'approved' PATCH body 누락 버그 수정 + 미분류 가드
// - F.2: runes mode + ui/* 프리미티브
// - F.3: source/format 필터 row (confidence는 백엔드 대기)
// - F.4: 처리 단계 타임스탬프 Badge 3개 (stop-gap)
// - F.5: 행별 도메인 override + tag 인라인 (로컬 state, 승인 시 반영)
// - F.6: 배치 override modal + (재시도 stub는 queue retry 백엔드 대기)
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { ui } from '$lib/stores/uiState.svelte';
import { pLimit } from '$lib/utils/pLimit';
import { AlertTriangle, CheckCircle2, RotateCcw, Inbox as InboxIcon } from 'lucide-svelte';
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Select from '$lib/components/ui/Select.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import FormatIcon from '$lib/components/FormatIcon.svelte';
import TagPill from '$lib/components/TagPill.svelte';
const DOMAIN_OPTIONS = [
{ value: 'Knowledge/Philosophy', label: 'Knowledge / Philosophy' },
{ value: 'Knowledge/Language', label: 'Knowledge / Language' },
{ value: 'Knowledge/Engineering', label: 'Knowledge / Engineering' },
{ value: 'Knowledge/Industrial_Safety', label: 'Knowledge / Industrial Safety' },
{ value: 'Knowledge/Programming', label: 'Knowledge / Programming' },
{ value: 'Knowledge/General', label: 'Knowledge / General' },
{ value: 'Reference', label: 'Reference' },
];
const MAX_SELECTION = 50;
let documents = $state([]);
let loading = $state(true);
let busy = $state(false);
// 선택
let selected = $state(new Set());
// F.3 필터
let filterSource = $state('');
let filterFormat = $state('');
// F.5 행별 override Map<id, { domain?, tags? }>
let overrides = $state(new Map());
// F.6 batch override 입력
let batchDomainValue = $state('');
let batchTagValue = $state('');
onMount(loadInbox);
async function loadInbox() {
loading = true;
try {
// 서버 필터 review_status=pending 적용 — page_size 100 이내 안전
const data = await api('/documents/?review_status=pending&page_size=100');
documents = data.items || [];
} catch (err) {
addToast('error', 'Inbox 로딩 실패');
} finally {
loading = false;
}
}
// Derived
let filteredDocs = $derived(
documents.filter((d) => {
if (filterSource && d.source_channel !== filterSource) return false;
if (filterFormat && d.file_format !== filterFormat) return false;
return true;
})
);
let sourceOptions = $derived.by(() => {
const set = new Set(documents.map((d) => d.source_channel).filter(Boolean));
return [
{ value: '', label: '모든 소스' },
...[...set].map((s) => ({ value: s, label: s })),
];
});
let formatOptions = $derived.by(() => {
const set = new Set(documents.map((d) => d.file_format).filter(Boolean));
return [
{ value: '', label: '모든 형식' },
...[...set].map((f) => ({ value: f, label: f.toUpperCase() })),
];
});
// Helper: override 고려한 effective domain/tags
function effectiveDomain(doc) {
const ov = overrides.get(doc.id);
if (ov && ov.domain !== undefined) return ov.domain;
return doc.ai_domain ?? '';
}
function effectiveTags(doc) {
const ov = overrides.get(doc.id);
if (ov && ov.tags !== undefined) return ov.tags;
return doc.ai_tags ?? [];
}
function isApprovable(doc) {
return !!effectiveDomain(doc);
}
// F.5 override setter
function setDomainOverride(id, value) {
const next = new Map(overrides);
const existing = next.get(id) ?? {};
next.set(id, { ...existing, domain: value });
overrides = next;
}
function clearOverride(id) {
const next = new Map(overrides);
next.delete(id);
overrides = next;
// 미분류로 돌아가면 선택에서도 제거
const doc = documents.find((d) => d.id === id);
if (doc && !isApprovable(doc) && selected.has(id)) {
const nextSel = new Set(selected);
nextSel.delete(id);
selected = nextSel;
}
}
// Selection
function toggleSelect(id) {
const doc = documents.find((d) => d.id === id);
if (!doc || !isApprovable(doc)) return;
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
}
function toggleAll() {
const approvable = filteredDocs.filter(isApprovable);
const allSelected = approvable.length > 0 && approvable.every((d) => selected.has(d.id));
if (allSelected) {
selected = new Set();
} else {
selected = new Set(approvable.map((d) => d.id));
}
}
let selectionCount = $derived(selected.size);
let selectionOverLimit = $derived(selectionCount > MAX_SELECTION);
let approvableCount = $derived(filteredDocs.filter(isApprovable).length);
let unclassifiedCount = $derived(documents.filter((d) => !isApprovable(d)).length);
// F.1 fix + 일괄 승인 (pLimit 5)
async function confirmApprove() {
const ids = [...selected];
if (ids.length === 0 || ids.length > MAX_SELECTION) return;
busy = true;
const limit = pLimit(5);
const results = await Promise.allSettled(
ids.map((id) => limit(() => approveOne(id)))
);
busy = false;
const success = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.length - success;
if (success > 0) addToast('success', `${success}건 승인 완료`);
if (failed > 0) addToast('error', `${failed}건 실패`);
selected = new Set();
overrides = new Map();
loadInbox();
}
async function approveOne(id) {
const doc = documents.find((d) => d.id === id);
if (!doc) return;
const ov = overrides.get(id) ?? {};
const body = {
review_status: 'approved', // F.1: 누락 버그 수정 (핵심)
ai_domain: ov.domain ?? doc.ai_domain,
};
if (ov.tags !== undefined) body.ai_tags = ov.tags;
return api(`/documents/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
// F.6 배치 override — 로컬 상태만 변경, 실제 PATCH는 승인 시점에
function applyBatchDomain() {
if (!batchDomainValue) return;
const next = new Map(overrides);
for (const id of selected) {
const existing = next.get(id) ?? {};
next.set(id, { ...existing, domain: batchDomainValue });
}
overrides = next;
addToast('success', `${selected.size}건에 도메인 override`);
batchDomainValue = '';
ui.closeModal('batch-override-domain');
}
function applyBatchTag() {
const tag = batchTagValue.trim();
if (!tag) return;
const next = new Map(overrides);
for (const id of selected) {
const doc = documents.find((d) => d.id === id);
const existing = next.get(id) ?? {};
const current = existing.tags ?? doc?.ai_tags ?? [];
if (!current.includes(tag)) {
next.set(id, { ...existing, tags: [...current, tag] });
}
}
overrides = next;
addToast('success', `${selected.size}건에 태그 추가`);
batchTagValue = '';
ui.closeModal('batch-override-tag');
}
// F.6 재시도 stub — 백엔드 POST /queue/retry 대기
function retryStub() {
addToast('warning', '재시도 엔드포인트 구현 대기 중 (TODO backend)');
}
</script>
<div class="p-4 lg:p-6 max-w-5xl mx-auto">
<!-- 헤더 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-text">Inbox</h2>
<Badge tone="warning" size="sm">{documents.length}</Badge>
{#if unclassifiedCount > 0}
<Badge tone="warning" size="sm">
<AlertTriangle size={10} />
미분류 {unclassifiedCount}
</Badge>
{/if}
</div>
<div class="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onclick={toggleAll}
disabled={approvableCount === 0}
>
{selected.size === approvableCount && approvableCount > 0 ? '전체 해제' : '전체 선택'}
</Button>
<Button
variant="secondary"
size="sm"
icon={RotateCcw}
onclick={retryStub}
disabled
title="백엔드 구현 대기"
>
재시도
</Button>
<Button
variant="primary"
size="sm"
onclick={() => ui.openModal('approve-confirm')}
disabled={busy || selectionCount === 0 || selectionOverLimit}
loading={busy}
>
선택 승인 ({selectionCount})
</Button>
</div>
</div>
<!-- F.3 필터 row -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<div class="w-44">
<Select bind:value={filterSource} options={sourceOptions} />
</div>
<div class="w-36">
<Select bind:value={filterFormat} options={formatOptions} />
</div>
<!-- confidence: TODO(backend) — ai_confidence 필드 추가 시 복원 -->
</div>
<!-- F.6 선택 toolbar (batch override) -->
{#if selectionCount > 0}
<div
class="sticky top-0 z-dropdown flex flex-wrap items-center gap-2 px-3 py-2 mb-4 rounded-md bg-accent/10 border border-accent/30 backdrop-blur-sm"
>
<span class="text-xs font-medium {selectionOverLimit ? 'text-error' : 'text-accent'}">
{selectionCount}건 선택
{#if selectionOverLimit}
<span class="ml-1">(최대 {MAX_SELECTION}건 초과)</span>
{/if}
</span>
<div class="flex-1"></div>
<Button
variant="secondary"
size="sm"
onclick={() => ui.openModal('batch-override-domain')}
disabled={selectionOverLimit}
>
일괄 도메인
</Button>
<Button
variant="secondary"
size="sm"
onclick={() => ui.openModal('batch-override-tag')}
disabled={selectionOverLimit}
>
일괄 태그
</Button>
</div>
{/if}
<!-- 목록 -->
{#if loading}
<div class="space-y-3">
{#each Array(3) as _}
<Skeleton h="h-28" rounded="card" />
{/each}
</div>
{:else if filteredDocs.length === 0}
<EmptyState
icon={InboxIcon}
title={documents.length === 0 ? 'Inbox가 비어 있습니다' : '필터 결과 없음'}
description={documents.length === 0
? '새 파일이 들어오면 자동으로 표시됩니다.'
: '필터를 조정해 보세요.'}
/>
{:else}
<div class="space-y-3">
{#each filteredDocs as doc (doc.id)}
{@const approvable = isApprovable(doc)}
{@const isChecked = selected.has(doc.id)}
{@const ov = overrides.get(doc.id) ?? {}}
{@const displayDomain = effectiveDomain(doc)}
{@const displayTags = effectiveTags(doc)}
{@const hasOverride = ov.domain !== undefined || ov.tags !== undefined}
<Card class={approvable ? '' : 'border-warning/50'}>
<div class="flex items-start gap-3">
<input
type="checkbox"
checked={isChecked}
disabled={!approvable}
onchange={() => toggleSelect(doc.id)}
class="mt-1 h-4 w-4 accent-accent disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="{doc.title || '문서'} 선택"
/>
<div class="flex-1 min-w-0">
<!-- 제목 + 미분류 경고 -->
<div class="flex items-center gap-2 mb-1 flex-wrap">
<span class="text-faint"><FormatIcon format={doc.file_format} size={14} /></span>
<a
href="/documents/{doc.id}"
class="text-sm font-medium text-text hover:text-accent truncate"
>
{doc.title || '제목 없음'}
</a>
{#if !approvable}
<Badge tone="warning" size="sm">
<AlertTriangle size={10} />
도메인 선택 필요
</Badge>
{/if}
{#if hasOverride}
<Badge tone="accent" size="sm">override</Badge>
{/if}
</div>
{#if doc.ai_summary}
<p class="text-xs text-dim truncate mb-2">{doc.ai_summary.slice(0, 140)}</p>
{/if}
<!-- F.4 처리 단계 타임스탬프 -->
<div class="flex items-center gap-1.5 mb-2 flex-wrap">
<Badge tone={doc.extracted_at ? 'success' : 'neutral'} size="sm">
{#if doc.extracted_at}<CheckCircle2 size={10} />{/if}
추출
</Badge>
<Badge tone={doc.ai_processed_at ? 'success' : 'neutral'} size="sm">
{#if doc.ai_processed_at}<CheckCircle2 size={10} />{/if}
분류
</Badge>
<Badge tone={doc.embedded_at ? 'success' : 'neutral'} size="sm">
{#if doc.embedded_at}<CheckCircle2 size={10} />{/if}
임베딩
</Badge>
<span class="text-[10px] text-faint">
· {doc.source_channel || '-'}
</span>
</div>
<!-- F.5 행별 override -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-[10px] text-dim shrink-0">도메인</span>
<select
aria-label="도메인 선택"
value={displayDomain}
onchange={(e) => setDomainOverride(doc.id, e.currentTarget.value)}
class="text-xs px-2 py-1 rounded bg-bg border border-default text-text focus:border-accent outline-none"
>
<option value="">미분류</option>
{#each DOMAIN_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if hasOverride}
<button
type="button"
onclick={() => clearOverride(doc.id)}
class="inline-flex items-center gap-1 text-[10px] text-dim hover:text-accent"
title="override 초기화"
>
<RotateCcw size={10} />
원복
</button>
{/if}
{#if displayTags.length > 0}
<div class="flex gap-1 ml-2 flex-wrap">
{#each displayTags.slice(0, 6) as tag}
<TagPill {tag} clickable={false} />
{/each}
</div>
{/if}
</div>
</div>
</div>
</Card>
{/each}
</div>
{/if}
</div>
<!-- 승인 확인 -->
<ConfirmDialog
id="approve-confirm"
title="{selectionCount}건 승인"
message="선택한 문서를 승인합니다. 도메인/태그 override가 있으면 함께 저장됩니다."
confirmLabel="승인"
tone="primary"
loading={busy}
onconfirm={confirmApprove}
/>
<!-- 배치 도메인 override -->
<Modal id="batch-override-domain" title="일괄 도메인 override" size="sm">
<p class="text-xs text-dim mb-3">
선택한 {selectionCount}건에 로컬 override 적용. 아직 저장되지 않음 — 승인 시 반영됨.
</p>
<Select
bind:value={batchDomainValue}
options={DOMAIN_OPTIONS}
placeholder="도메인 선택"
label="도메인"
/>
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('batch-override-domain')}>
취소
</Button>
<Button
variant="primary"
size="sm"
disabled={!batchDomainValue}
onclick={applyBatchDomain}
>
적용
</Button>
{/snippet}
</Modal>
<!-- 배치 태그 override -->
<Modal id="batch-override-tag" title="일괄 태그 추가" size="sm">
<p class="text-xs text-dim mb-3">
선택한 {selectionCount}건에 태그 override. 이미 같은 태그는 skip.
</p>
<TextInput bind:value={batchTagValue} placeholder="태그 입력" label="태그" />
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => ui.closeModal('batch-override-tag')}>
취소
</Button>
<Button
variant="primary"
size="sm"
disabled={!batchTagValue.trim()}
onclick={applyBatchTag}
>
적용
</Button>
{/snippet}
</Modal>