feat(library): doc_purpose 필드 + 자료실 업로드 기능

지식/업무 문서 1차 구분을 위한 doc_purpose(business|knowledge) 추가.
- 마이그레이션: document_purpose enum + 컬럼
- AI 분류: docPurpose 자동 추론 (빈 값만 채움)
- 업로드 API: doc_purpose + library_path Form 파라미터
- 자료실 업로드: business 기본값 + 선택 경로 자동 태깅
- FileInfoView: 용도 select (수동 변경, 실패 롤백)
- DocumentCard: 업무/참조 배지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-14 15:26:59 +09:00
parent 96ab2369a7
commit 5c58778a41
8 changed files with 142 additions and 3 deletions
+32 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
@@ -53,6 +53,7 @@ class DocumentResponse(BaseModel):
preview_status: str | None
source_channel: str | None
data_origin: str | None
doc_purpose: str | None
extracted_at: datetime | None
ai_processed_at: datetime | None
embedded_at: datetime | None
@@ -81,6 +82,7 @@ class DocumentUpdate(BaseModel):
edit_url: str | None = None
source_channel: str | None = None
data_origin: str | None = None
doc_purpose: str | None = None
pinned: bool | None = None
@@ -396,8 +398,29 @@ async def upload_document(
file: UploadFile,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
doc_purpose: str | None = Form(None, description="business | knowledge"),
library_path: str | None = Form(None, description="자료실 경로 (자동 @library/ 태깅)"),
):
"""파일 업로드 → Inbox 저장 + DB 등록 + 처리 큐 등록"""
from core.library import LIBRARY_PREFIX, normalize_library_path
# doc_purpose 검증
if doc_purpose is not None:
doc_purpose = doc_purpose.strip().lower()
if doc_purpose == "":
doc_purpose = None
elif doc_purpose not in ("business", "knowledge"):
raise HTTPException(status_code=400, detail="doc_purpose는 business 또는 knowledge만 가능")
# library_path 검증 + 정규화
library_tag = None
if library_path:
try:
normalized = normalize_library_path(library_path)
library_tag = f"{LIBRARY_PREFIX}{normalized}"
except ValueError as e:
raise HTTPException(status_code=400, detail=f"잘못된 자료실 경로: {e}")
if not file.filename:
raise HTTPException(status_code=400, detail="파일명이 필요합니다")
@@ -439,6 +462,8 @@ async def upload_document(
file_type="immutable",
title=target.stem,
source_channel="manual",
doc_purpose=doc_purpose,
user_tags=[library_tag] if library_tag else [],
)
session.add(doc)
await session.flush()
@@ -477,6 +502,12 @@ async def update_document(
except (TypeError, ValueError) as e:
raise HTTPException(status_code=400, detail=str(e))
# doc_purpose 검증
if "doc_purpose" in update_data:
val = update_data["doc_purpose"]
if val is not None and val not in ("business", "knowledge"):
raise HTTPException(status_code=400, detail="doc_purpose는 business 또는 knowledge만 가능")
for field, value in update_data.items():
setattr(doc, field, value)
doc.updated_at = datetime.now(timezone.utc)
+4
View File
@@ -93,6 +93,10 @@ class Document(Base):
data_origin: Mapped[str | None] = mapped_column(
Enum("work", "external", name="data_origin")
)
# 용도 구분 (우선순위: 수동 수정 > 업로드 명시값 > AI 추론)
doc_purpose: Mapped[str | None] = mapped_column(
Enum("business", "knowledge", name="document_purpose")
)
title: Mapped[str | None] = mapped_column(Text)
# 타임스탬프
+9 -1
View File
@@ -8,7 +8,8 @@ You are a document classification AI. Analyze the document below and respond ONL
"tags": ["tag1", "tag2"],
"importance": "medium",
"sourceChannel": "inbox_route",
"dataOrigin": "work or external"
"dataOrigin": "work or external",
"docPurpose": "business or knowledge"
}
## Domain Taxonomy (select the most specific leaf node)
@@ -89,5 +90,12 @@ Reference, Standard, Manual, Drawing, Template, Note, Academic_Paper, Law_Docume
- work: company-related (TK, Technicalkorea, factory, production)
- external: external reference (news, papers, laws, general info)
## docPurpose
- business: 업무 수행에 직접 사용 (양식, 보고서, 체크리스트, 제출물, 계획서)
- knowledge: 참조·학습·보관 목적 (법령, 논문, 기사, 레퍼런스, 기술 문서, 교육 자료)
- Template, Checklist, Report, Specification → business 가능성 높음
- Academic_Paper, Law_Document, Reference, Standard → knowledge 가능성 높음
- Meeting_Minutes, Memo → 문맥 판단 (실행 기록이면 business, 참조용이면 knowledge)
## Document to classify
{document_text}
+6
View File
@@ -107,6 +107,12 @@ async def process(document_id: int, session: AsyncSession) -> None:
if parsed.get("dataOrigin") and not doc.data_origin:
doc.data_origin = parsed["dataOrigin"]
# 용도 (AI는 빈 값만 채움 — 수동/업로드 명시값 우선)
if parsed.get("docPurpose") and not doc.doc_purpose:
purpose = parsed["docPurpose"]
if purpose in ("business", "knowledge"):
doc.doc_purpose = purpose
# ─── 요약 ───
summary = await client.summarize(doc.extracted_text[:15000])
doc.ai_summary = strip_thinking(summary)
@@ -142,7 +142,11 @@
{#if doc.score !== undefined}
<span class="text-accent font-medium">{(doc.score * 100).toFixed(0)}%</span>
{/if}
{#if doc.data_origin}
{#if doc.doc_purpose}
<span class="hidden sm:inline px-1.5 py-0.5 rounded {doc.doc_purpose === 'business' ? 'bg-blue-900/30 text-blue-400' : 'bg-emerald-900/30 text-emerald-400'}">
{doc.doc_purpose === 'business' ? '업무' : '참조'}
</span>
{:else if doc.data_origin}
<span class="hidden sm:inline px-1.5 py-0.5 rounded {doc.data_origin === 'work' ? 'bg-blue-900/30 text-blue-400' : 'bg-gray-800 text-gray-400'}">
{doc.data_origin}
</span>
@@ -1,5 +1,8 @@
<script>
// Phase E.1 — 파일 메타 정보 read-only 표시.
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
let { doc } = $props();
function formatDate(dateStr) {
@@ -17,6 +20,22 @@
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / 1048576).toFixed(1)}MB`;
}
async function updatePurpose(e) {
const val = e.target.value || null;
const prev = doc.doc_purpose;
doc.doc_purpose = val;
try {
await api(`/documents/${doc.id}`, {
method: 'PATCH',
body: JSON.stringify({ doc_purpose: val }),
});
addToast('success', '용도 변경됨');
} catch {
doc.doc_purpose = prev;
addToast('error', '용도 변경 실패');
}
}
</script>
<div>
@@ -44,6 +63,20 @@
<dd class="text-text">{doc.data_origin}</dd>
</div>
{/if}
<div class="flex justify-between items-center">
<dt class="text-dim">용도</dt>
<dd>
<select
value={doc.doc_purpose || ''}
onchange={updatePurpose}
class="bg-bg border border-default rounded px-1 py-0.5 text-xs text-text outline-none focus:border-accent"
>
<option value="">미분류</option>
<option value="business">업무용</option>
<option value="knowledge">참조용</option>
</select>
</dd>
</div>
<div class="flex justify-between">
<dt class="text-dim">등록일</dt>
<dd class="text-text">{formatDate(doc.created_at)}</dd>
+48
View File
@@ -12,6 +12,7 @@
ExternalLink,
Download,
FileText,
Upload,
} from 'lucide-svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import FormatIcon from '$lib/components/FormatIcon.svelte';
@@ -151,6 +152,41 @@
window.open(`/api/documents/${doc.id}/preview?token=${getAccessToken()}&download=true`);
}
// ─── 업로드 ───
let fileInput;
let uploadingCount = $state(0);
async function handleUpload(e) {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
uploadingCount = files.length;
let success = 0;
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
formData.append('doc_purpose', 'business');
if (activePath) formData.append('library_path', activePath);
try {
await api('/documents/', { method: 'POST', body: formData });
success++;
} catch {
addToast('error', `${file.name} 업로드 실패`);
}
}
uploadingCount = 0;
fileInput.value = '';
if (success > 0) {
addToast('success', `${success}건 업로드 완료`);
loadTree();
loadDocs();
}
}
// ─── 검색 debounce ───
let searchInput = $state(activeQ);
@@ -273,6 +309,18 @@
<main class="lg:col-span-7 xl:col-span-8">
<!-- 컨트롤 바 -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<!-- 업로드 -->
<input type="file" multiple bind:this={fileInput} onchange={handleUpload} class="hidden" />
<Button
variant="secondary"
size="sm"
icon={Upload}
onclick={() => fileInput.click()}
disabled={uploadingCount > 0}
>
{uploadingCount > 0 ? `업로드 중 (${uploadingCount})` : '업로드'}
</Button>
<!-- 정렬 -->
<select
value={activeSort}
+5
View File
@@ -0,0 +1,5 @@
-- 문서 용도 구분: business(업무용) | knowledge(참조용)
-- 기존 문서는 NULL → AI 재분류 시 점진 채움
-- 우선순위: 수동 수정 > 업로드 시 명시값 > AI 추론
CREATE TYPE document_purpose AS ENUM ('business', 'knowledge');
ALTER TABLE documents ADD COLUMN doc_purpose document_purpose;