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:
+32
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 타임스탬프
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user