feat: implement Phase 1 data pipeline and migration
- Implement kordoc /parse endpoint (HWP/HWPX/PDF via kordoc lib, text files direct read, images flagged for OCR) - Add queue consumer with APScheduler (1min interval, stage chaining extract→classify→embed, stale item recovery, retry logic) - Add extract worker (kordoc HTTP call + direct text read) - Add classify worker (Qwen3.5 AI classification with think-tag stripping and robust JSON extraction from AI responses) - Add embed worker (GPU server nomic-embed-text, graceful failure) - Add DEVONthink migration script with folder mapping for 16 DBs, dry-run mode, batch commits, and idempotent file_path UNIQUE - Enhance ai/client.py with strip_thinking() and parse_json_response() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,39 @@
|
|||||||
"""AI 추상화 레이어 — 통합 클라이언트. 기본값은 항상 Qwen3.5."""
|
"""AI 추상화 레이어 — 통합 클라이언트. 기본값은 항상 Qwen3.5."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def strip_thinking(text: str) -> str:
|
||||||
|
"""Qwen3.5의 <think>...</think> 블록 제거"""
|
||||||
|
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_response(raw: str) -> dict | None:
|
||||||
|
"""AI 응답에서 JSON 객체 추출 (think 태그, 코드블록 등 제거)"""
|
||||||
|
cleaned = strip_thinking(raw)
|
||||||
|
# 코드블록 내부 JSON 추출
|
||||||
|
code_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", cleaned, re.DOTALL)
|
||||||
|
if code_match:
|
||||||
|
cleaned = code_match.group(1)
|
||||||
|
# 마지막 유효 JSON 객체 찾기
|
||||||
|
matches = list(re.finditer(r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}", cleaned, re.DOTALL))
|
||||||
|
for m in reversed(matches):
|
||||||
|
try:
|
||||||
|
return json.loads(m.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
# 최후 시도: 전체 텍스트를 JSON으로
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
# 프롬프트 로딩
|
# 프롬프트 로딩
|
||||||
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||||
|
|
||||||
|
|||||||
14
app/main.py
14
app/main.py
@@ -16,11 +16,21 @@ from models.user import User
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
|
"""앱 시작/종료 시 실행되는 lifespan 핸들러"""
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from workers.queue_consumer import consume_queue
|
||||||
|
|
||||||
# 시작: DB 연결 확인
|
# 시작: DB 연결 확인
|
||||||
await init_db()
|
await init_db()
|
||||||
# TODO: APScheduler 시작 (Phase 3)
|
|
||||||
|
# APScheduler: 큐 소비자 1분 간격 실행
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
scheduler.add_job(consume_queue, "interval", minutes=1, id="queue_consumer")
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# 종료: DB 엔진 정리
|
|
||||||
|
# 종료: 스케줄러 → DB 순서로 정리
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
76
app/workers/classify_worker.py
Normal file
76
app/workers/classify_worker.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""AI 분류 워커 — Qwen3.5로 도메인/태그/요약 생성"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ai.client import AIClient, parse_json_response
|
||||||
|
from core.utils import setup_logger
|
||||||
|
from models.document import Document
|
||||||
|
|
||||||
|
logger = setup_logger("classify_worker")
|
||||||
|
|
||||||
|
# 분류용 텍스트 최대 길이 (Qwen3.5 컨텍스트 관리)
|
||||||
|
MAX_CLASSIFY_TEXT = 8000
|
||||||
|
|
||||||
|
# 유효한 도메인 목록
|
||||||
|
VALID_DOMAINS = {
|
||||||
|
"Knowledge/Philosophy",
|
||||||
|
"Knowledge/Language",
|
||||||
|
"Knowledge/Engineering",
|
||||||
|
"Knowledge/Industrial_Safety",
|
||||||
|
"Knowledge/Programming",
|
||||||
|
"Knowledge/General",
|
||||||
|
"Reference",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def process(document_id: int, session: AsyncSession) -> None:
|
||||||
|
"""문서 AI 분류 + 요약"""
|
||||||
|
doc = await session.get(Document, document_id)
|
||||||
|
if not doc:
|
||||||
|
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||||
|
|
||||||
|
if not doc.extracted_text:
|
||||||
|
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||||
|
|
||||||
|
client = AIClient()
|
||||||
|
try:
|
||||||
|
# ─── 분류 ───
|
||||||
|
truncated = doc.extracted_text[:MAX_CLASSIFY_TEXT]
|
||||||
|
raw_response = await client.classify(truncated)
|
||||||
|
parsed = parse_json_response(raw_response)
|
||||||
|
|
||||||
|
if not parsed:
|
||||||
|
raise ValueError(f"AI 응답에서 JSON 추출 실패: {raw_response[:200]}")
|
||||||
|
|
||||||
|
# 유효성 검증 + DB 업데이트
|
||||||
|
domain = parsed.get("domain", "")
|
||||||
|
if domain not in VALID_DOMAINS:
|
||||||
|
logger.warning(f"[분류] document_id={document_id}: 알 수 없는 도메인 '{domain}', Knowledge/General로 대체")
|
||||||
|
domain = "Knowledge/General"
|
||||||
|
|
||||||
|
doc.ai_domain = domain
|
||||||
|
doc.ai_sub_group = parsed.get("sub_group", "")
|
||||||
|
doc.ai_tags = parsed.get("tags", [])
|
||||||
|
|
||||||
|
if parsed.get("sourceChannel") and not doc.source_channel:
|
||||||
|
doc.source_channel = parsed["sourceChannel"]
|
||||||
|
if parsed.get("dataOrigin") and not doc.data_origin:
|
||||||
|
doc.data_origin = parsed["dataOrigin"]
|
||||||
|
|
||||||
|
# ─── 요약 ───
|
||||||
|
summary = await client.summarize(doc.extracted_text[:15000])
|
||||||
|
doc.ai_summary = summary
|
||||||
|
|
||||||
|
# ─── 메타데이터 ───
|
||||||
|
doc.ai_model_version = "qwen3.5-35b-a3b"
|
||||||
|
doc.ai_processed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[분류] document_id={document_id}: "
|
||||||
|
f"domain={domain}, tags={doc.ai_tags}, summary={len(summary)}자"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
44
app/workers/embed_worker.py
Normal file
44
app/workers/embed_worker.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""벡터 임베딩 워커 — GPU 서버 nomic-embed-text 호출"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ai.client import AIClient
|
||||||
|
from core.utils import setup_logger
|
||||||
|
from models.document import Document
|
||||||
|
|
||||||
|
logger = setup_logger("embed_worker")
|
||||||
|
|
||||||
|
# 임베딩용 텍스트 최대 길이 (nomic-embed-text: 8192 토큰)
|
||||||
|
MAX_EMBED_TEXT = 6000
|
||||||
|
EMBED_MODEL_VERSION = "nomic-embed-text-v1.5"
|
||||||
|
|
||||||
|
|
||||||
|
async def process(document_id: int, session: AsyncSession) -> None:
|
||||||
|
"""문서 벡터 임베딩 생성"""
|
||||||
|
doc = await session.get(Document, document_id)
|
||||||
|
if not doc:
|
||||||
|
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||||
|
|
||||||
|
if not doc.extracted_text:
|
||||||
|
raise ValueError(f"문서 ID {document_id}: extracted_text가 비어있음")
|
||||||
|
|
||||||
|
# title + 본문 앞부분을 결합하여 임베딩 입력 생성
|
||||||
|
title_part = doc.title or ""
|
||||||
|
text_part = doc.extracted_text[:MAX_EMBED_TEXT]
|
||||||
|
embed_input = f"{title_part}\n\n{text_part}".strip()
|
||||||
|
|
||||||
|
if not embed_input:
|
||||||
|
logger.warning(f"[임베딩] document_id={document_id}: 빈 텍스트, 스킵")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = AIClient()
|
||||||
|
try:
|
||||||
|
vector = await client.embed(embed_input)
|
||||||
|
doc.embedding = vector
|
||||||
|
doc.embed_model_version = EMBED_MODEL_VERSION
|
||||||
|
doc.embedded_at = datetime.now(timezone.utc)
|
||||||
|
logger.info(f"[임베딩] document_id={document_id}: {len(vector)}차원 벡터 생성")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
80
app/workers/extract_worker.py
Normal file
80
app/workers/extract_worker.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""텍스트 추출 워커 — kordoc 호출 또는 직접 파일 읽기"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
from core.utils import setup_logger
|
||||||
|
from models.document import Document
|
||||||
|
|
||||||
|
logger = setup_logger("extract_worker")
|
||||||
|
|
||||||
|
# kordoc으로 파싱 가능한 포맷
|
||||||
|
KORDOC_FORMATS = {"hwp", "hwpx", "pdf"}
|
||||||
|
# 직접 읽기 가능한 텍스트 포맷
|
||||||
|
TEXT_FORMATS = {"md", "txt", "csv", "json", "xml", "html"}
|
||||||
|
# OCR 필요 이미지 포맷 (Phase 2)
|
||||||
|
IMAGE_FORMATS = {"jpg", "jpeg", "png", "tiff", "tif", "bmp", "gif"}
|
||||||
|
|
||||||
|
EXTRACTOR_VERSION = "kordoc@1.7"
|
||||||
|
|
||||||
|
|
||||||
|
async def process(document_id: int, session: AsyncSession) -> None:
|
||||||
|
"""문서 텍스트 추출"""
|
||||||
|
doc = await session.get(Document, document_id)
|
||||||
|
if not doc:
|
||||||
|
raise ValueError(f"문서 ID {document_id}를 찾을 수 없음")
|
||||||
|
|
||||||
|
fmt = doc.file_format.lower()
|
||||||
|
full_path = Path(settings.nas_mount_path) / doc.file_path
|
||||||
|
|
||||||
|
# 텍스트 파일 — 직접 읽기
|
||||||
|
if fmt in TEXT_FORMATS:
|
||||||
|
if not full_path.exists():
|
||||||
|
raise FileNotFoundError(f"파일 없음: {full_path}")
|
||||||
|
text = full_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
doc.extracted_text = text
|
||||||
|
doc.extracted_at = datetime.now(timezone.utc)
|
||||||
|
doc.extractor_version = "direct_read"
|
||||||
|
logger.info(f"[텍스트] {doc.file_path} ({len(text)}자)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 이미지 — 스킵 (Phase 2 OCR)
|
||||||
|
if fmt in IMAGE_FORMATS:
|
||||||
|
doc.extracted_text = ""
|
||||||
|
doc.extracted_at = datetime.now(timezone.utc)
|
||||||
|
doc.extractor_version = "skip_image"
|
||||||
|
logger.info(f"[이미지] {doc.file_path} — OCR 미구현, 스킵")
|
||||||
|
return
|
||||||
|
|
||||||
|
# kordoc 파싱 (HWP/HWPX/PDF)
|
||||||
|
if fmt in KORDOC_FORMATS:
|
||||||
|
# 컨테이너 내부 경로: /documents/{file_path}
|
||||||
|
container_path = f"/documents/{doc.file_path}"
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{settings.kordoc_endpoint}/parse",
|
||||||
|
json={"filePath": container_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise FileNotFoundError(f"kordoc: 파일 없음 — {container_path}")
|
||||||
|
if resp.status_code == 422:
|
||||||
|
raise ValueError(f"kordoc: 파싱 실패 — {resp.json().get('error', 'unknown')}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
doc.extracted_text = data.get("markdown", "")
|
||||||
|
doc.extracted_at = datetime.now(timezone.utc)
|
||||||
|
doc.extractor_version = EXTRACTOR_VERSION
|
||||||
|
logger.info(f"[kordoc] {doc.file_path} ({len(doc.extracted_text)}자)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 미지원 포맷
|
||||||
|
doc.extracted_text = ""
|
||||||
|
doc.extracted_at = datetime.now(timezone.utc)
|
||||||
|
doc.extractor_version = f"unsupported_{fmt}"
|
||||||
|
logger.warning(f"[미지원] {doc.file_path} (format={fmt})")
|
||||||
117
app/workers/queue_consumer.py
Normal file
117
app/workers/queue_consumer.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""처리 큐 소비자 — APScheduler에서 1분 간격으로 호출"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from core.database import async_session
|
||||||
|
from core.utils import setup_logger
|
||||||
|
from models.queue import ProcessingQueue
|
||||||
|
|
||||||
|
logger = setup_logger("queue_consumer")
|
||||||
|
|
||||||
|
# stage별 배치 크기
|
||||||
|
BATCH_SIZE = {"extract": 5, "classify": 3, "embed": 1}
|
||||||
|
STALE_THRESHOLD_MINUTES = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_stale_items():
|
||||||
|
"""processing 상태로 10분 이상 방치된 항목 복구"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=STALE_THRESHOLD_MINUTES)
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
update(ProcessingQueue)
|
||||||
|
.where(
|
||||||
|
ProcessingQueue.status == "processing",
|
||||||
|
ProcessingQueue.started_at < cutoff,
|
||||||
|
)
|
||||||
|
.values(status="pending", started_at=None)
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
await session.commit()
|
||||||
|
logger.warning(f"stale 항목 {result.rowcount}건 복구")
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue_next_stage(document_id: int, current_stage: str, session):
|
||||||
|
"""현재 stage 완료 후 다음 stage를 pending으로 등록"""
|
||||||
|
next_stages = {"extract": "classify", "classify": "embed"}
|
||||||
|
next_stage = next_stages.get(current_stage)
|
||||||
|
if not next_stage:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 이미 존재하는지 확인 (중복 방지)
|
||||||
|
existing = await session.execute(
|
||||||
|
select(ProcessingQueue).where(
|
||||||
|
ProcessingQueue.document_id == document_id,
|
||||||
|
ProcessingQueue.stage == next_stage,
|
||||||
|
ProcessingQueue.status.in_(["pending", "processing"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
return
|
||||||
|
|
||||||
|
session.add(ProcessingQueue(
|
||||||
|
document_id=document_id,
|
||||||
|
stage=next_stage,
|
||||||
|
status="pending",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def consume_queue():
|
||||||
|
"""큐에서 pending 항목을 가져와 stage별 워커 실행"""
|
||||||
|
# 지연 임포트 (순환 참조 방지)
|
||||||
|
from workers.extract_worker import process as extract_process
|
||||||
|
from workers.classify_worker import process as classify_process
|
||||||
|
from workers.embed_worker import process as embed_process
|
||||||
|
|
||||||
|
workers = {
|
||||||
|
"extract": extract_process,
|
||||||
|
"classify": classify_process,
|
||||||
|
"embed": embed_process,
|
||||||
|
}
|
||||||
|
|
||||||
|
# stale 항목 복구
|
||||||
|
await reset_stale_items()
|
||||||
|
|
||||||
|
for stage, worker_fn in workers.items():
|
||||||
|
batch_size = BATCH_SIZE.get(stage, 3)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(ProcessingQueue)
|
||||||
|
.where(
|
||||||
|
ProcessingQueue.stage == stage,
|
||||||
|
ProcessingQueue.status == "pending",
|
||||||
|
)
|
||||||
|
.order_by(ProcessingQueue.created_at)
|
||||||
|
.limit(batch_size)
|
||||||
|
)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item.status = "processing"
|
||||||
|
item.started_at = datetime.now(timezone.utc)
|
||||||
|
item.attempts += 1
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await worker_fn(item.document_id, session)
|
||||||
|
item.status = "completed"
|
||||||
|
item.completed_at = datetime.now(timezone.utc)
|
||||||
|
await enqueue_next_stage(item.document_id, stage, session)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[{stage}] document_id={item.document_id} 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
# 세션에서 item 다시 로드
|
||||||
|
item = await session.get(ProcessingQueue, item.id)
|
||||||
|
item.error_message = str(e)[:500]
|
||||||
|
if item.attempts >= item.max_attempts:
|
||||||
|
item.status = "failed"
|
||||||
|
logger.error(f"[{stage}] document_id={item.document_id} 영구 실패: {e}")
|
||||||
|
else:
|
||||||
|
item.status = "pending"
|
||||||
|
item.started_at = None
|
||||||
|
logger.warning(f"[{stage}] document_id={item.document_id} 재시도 예정 ({item.attempts}/{item.max_attempts}): {e}")
|
||||||
|
await session.commit()
|
||||||
231
scripts/migrate_from_devonthink.py
Normal file
231
scripts/migrate_from_devonthink.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""DEVONthink → NAS PKM 마이그레이션 스크립트
|
||||||
|
|
||||||
|
DEVONthink에서 "파일 및 폴더" 내보내기 한 디렉토리를 스캔하여
|
||||||
|
NAS PKM 폴더 구조로 복사하고 DB에 등록합니다.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
# Dry-run (실제 복사/DB 등록 없이 시뮬레이션)
|
||||||
|
python scripts/migrate_from_devonthink.py --source-dir /path/to/export --dry-run
|
||||||
|
|
||||||
|
# 실제 실행
|
||||||
|
python scripts/migrate_from_devonthink.py \
|
||||||
|
--source-dir /path/to/export \
|
||||||
|
--target-dir /documents/PKM \
|
||||||
|
--database-url postgresql+asyncpg://pkm:PASSWORD@localhost:15432/pkm
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from core.utils import file_hash, setup_logger
|
||||||
|
|
||||||
|
logger = setup_logger("migrate")
|
||||||
|
|
||||||
|
# ─── DEVONthink DB → NAS PKM 폴더 매핑 ───
|
||||||
|
FOLDER_MAPPING = {
|
||||||
|
"00_Inbox DB": "PKM/Inbox",
|
||||||
|
"Inbox": "PKM/Inbox",
|
||||||
|
"00_Note_BOX": "PKM/Knowledge",
|
||||||
|
"01_Philosophie": "PKM/Knowledge/Philosophy",
|
||||||
|
"02_Language": "PKM/Knowledge/Language",
|
||||||
|
"03_Engineering": "PKM/Knowledge/Engineering",
|
||||||
|
"04_Industrial safety": "PKM/Knowledge/Industrial_Safety",
|
||||||
|
"05_Programming": "PKM/Knowledge/Programming",
|
||||||
|
"07_General Book": "PKM/Knowledge/General",
|
||||||
|
"97_Production drawing": "PKM/References",
|
||||||
|
"99_Reference Data": "PKM/References",
|
||||||
|
"99_Home File": "PKM/References",
|
||||||
|
"Archive": "PKM/Archive",
|
||||||
|
"Projects": "PKM/Knowledge",
|
||||||
|
# 아래는 별도 처리 또는 스킵
|
||||||
|
"99_Technicalkorea": "Technicalkorea",
|
||||||
|
"98_명일방주 엔드필드": None, # 스킵
|
||||||
|
}
|
||||||
|
|
||||||
|
# 무시할 파일/디렉토리 패턴
|
||||||
|
SKIP_NAMES = {".DS_Store", "._*", "Thumbs.db", "Icon\r", "Icon"}
|
||||||
|
SKIP_EXTENSIONS = {".dtMeta", ".dtBase2", ".sparseimage"}
|
||||||
|
|
||||||
|
|
||||||
|
def should_skip(path: Path) -> bool:
|
||||||
|
"""스킵해야 할 파일인지 확인"""
|
||||||
|
if path.name in SKIP_NAMES or path.name.startswith("._"):
|
||||||
|
return True
|
||||||
|
if path.suffix.lower() in SKIP_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_target(source_file: Path, source_root: Path) -> str | None:
|
||||||
|
"""소스 파일의 NAS 대상 경로 결정 (NAS 루트 기준 상대 경로)"""
|
||||||
|
relative = source_file.relative_to(source_root)
|
||||||
|
parts = relative.parts
|
||||||
|
|
||||||
|
# 첫 번째 디렉토리가 DEVONthink DB 이름
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
db_name = parts[0]
|
||||||
|
|
||||||
|
target_prefix = FOLDER_MAPPING.get(db_name)
|
||||||
|
if target_prefix is None:
|
||||||
|
return None # 스킵 대상
|
||||||
|
|
||||||
|
# 나머지 경로 조합
|
||||||
|
sub_path = Path(*parts[1:]) if len(parts) > 1 else Path(source_file.name)
|
||||||
|
return str(Path(target_prefix) / sub_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate(
|
||||||
|
source_dir: str,
|
||||||
|
target_dir: str,
|
||||||
|
database_url: str,
|
||||||
|
dry_run: bool = False,
|
||||||
|
batch_size: int = 100,
|
||||||
|
):
|
||||||
|
"""마이그레이션 실행"""
|
||||||
|
source = Path(source_dir)
|
||||||
|
target = Path(target_dir)
|
||||||
|
|
||||||
|
if not source.exists():
|
||||||
|
logger.error(f"소스 디렉토리 없음: {source}")
|
||||||
|
return
|
||||||
|
|
||||||
|
engine = create_async_engine(database_url)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
stats = {"total": 0, "copied": 0, "skipped": 0, "duplicates": 0, "errors": 0}
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
# 모든 파일 수집
|
||||||
|
files = [f for f in source.rglob("*") if f.is_file() and not should_skip(f)]
|
||||||
|
logger.info(f"스캔 완료: {len(files)}개 파일 발견")
|
||||||
|
|
||||||
|
for source_file in files:
|
||||||
|
stats["total"] += 1
|
||||||
|
target_rel = resolve_target(source_file, source)
|
||||||
|
|
||||||
|
if target_rel is None:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_path = target / target_rel
|
||||||
|
ext = source_file.suffix.lstrip(".").lower() or "unknown"
|
||||||
|
fhash = file_hash(source_file)
|
||||||
|
fsize = source_file.stat().st_size
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
logger.info(f"[DRY-RUN] {source_file.name} → {target_rel}")
|
||||||
|
stats["copied"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 파일 복사
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not target_path.exists():
|
||||||
|
shutil.copy2(source_file, target_path)
|
||||||
|
|
||||||
|
# DB 등록 배치에 추가
|
||||||
|
batch.append({
|
||||||
|
"file_path": target_rel,
|
||||||
|
"file_hash": fhash,
|
||||||
|
"file_format": ext,
|
||||||
|
"file_size": fsize,
|
||||||
|
"file_type": "immutable",
|
||||||
|
"import_source": f"devonthink:{source_file.relative_to(source).parts[0]}",
|
||||||
|
"title": source_file.stem,
|
||||||
|
"source_channel": "manual",
|
||||||
|
})
|
||||||
|
stats["copied"] += 1
|
||||||
|
|
||||||
|
# 배치 커밋
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
dups = await _insert_batch(async_session, batch)
|
||||||
|
stats["duplicates"] += dups
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
# 남은 배치 처리
|
||||||
|
if batch and not dry_run:
|
||||||
|
dups = await _insert_batch(async_session, batch)
|
||||||
|
stats["duplicates"] += dups
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
# 결과 출력
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(f"마이그레이션 {'시뮬레이션' if dry_run else '완료'}")
|
||||||
|
logger.info(f" 전체 파일: {stats['total']}")
|
||||||
|
logger.info(f" 복사/등록: {stats['copied']}")
|
||||||
|
logger.info(f" 스킵: {stats['skipped']}")
|
||||||
|
logger.info(f" 중복: {stats['duplicates']}")
|
||||||
|
logger.info(f" 오류: {stats['errors']}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_batch(async_session_factory, batch: list[dict]) -> int:
|
||||||
|
"""배치 단위로 documents + processing_queue 삽입, 중복 수 반환"""
|
||||||
|
duplicates = 0
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
for item in batch:
|
||||||
|
try:
|
||||||
|
# documents 삽입
|
||||||
|
result = await session.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO documents (file_path, file_hash, file_format, file_size,
|
||||||
|
file_type, import_source, title, source_channel)
|
||||||
|
VALUES (:file_path, :file_hash, :file_format, :file_size,
|
||||||
|
:file_type, :import_source, :title, :source_channel)
|
||||||
|
ON CONFLICT (file_path) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
"""),
|
||||||
|
item,
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if row is None:
|
||||||
|
duplicates += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc_id = row[0]
|
||||||
|
|
||||||
|
# processing_queue에 extract 등록
|
||||||
|
await session.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO processing_queue (document_id, stage, status)
|
||||||
|
VALUES (:doc_id, 'extract', 'pending')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""),
|
||||||
|
{"doc_id": doc_id},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"등록 실패: {item['file_path']}: {e}")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return duplicates
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="DEVONthink → NAS PKM 마이그레이션")
|
||||||
|
parser.add_argument("--source-dir", required=True, help="DEVONthink 내보내기 디렉토리")
|
||||||
|
parser.add_argument("--target-dir", default="/documents/PKM", help="NAS PKM 루트 경로")
|
||||||
|
parser.add_argument(
|
||||||
|
"--database-url",
|
||||||
|
default="postgresql+asyncpg://pkm:pkm@localhost:15432/pkm",
|
||||||
|
help="PostgreSQL 연결 URL",
|
||||||
|
)
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="시뮬레이션만 실행")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=100, help="배치 커밋 크기")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
asyncio.run(migrate(
|
||||||
|
source_dir=args.source_dir,
|
||||||
|
target_dir=args.target_dir,
|
||||||
|
database_url=args.database_url,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
batch_size=args.batch_size,
|
||||||
|
))
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.0",
|
"express": "^4.18.0",
|
||||||
"kordoc": "^1.7.0"
|
"kordoc": "^1.7.0",
|
||||||
|
"pdfjs-dist": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* kordoc 마이크로서비스 — HWP/HWPX/PDF → Markdown 변환 API
|
* kordoc 마이크로서비스 — HWP/HWPX/PDF/텍스트 → Markdown 변환 API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { parse, detectFormat } = require('kordoc');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3100;
|
const PORT = 3100;
|
||||||
|
const PARSE_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
app.use(express.json({ limit: '500mb' }));
|
app.use(express.json({ limit: '500mb' }));
|
||||||
|
|
||||||
@@ -13,26 +18,103 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', service: 'kordoc' });
|
res.json({ status: 'ok', service: 'kordoc' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 문서 파싱
|
// 지원 포맷 목록
|
||||||
|
const TEXT_FORMATS = new Set(['.md', '.txt', '.csv', '.json', '.xml', '.html']);
|
||||||
|
const PARSEABLE_FORMATS = new Set(['.hwp', '.hwpx', '.pdf']);
|
||||||
|
const IMAGE_FORMATS = new Set(['.jpg', '.jpeg', '.png', '.tiff', '.tif', '.bmp', '.gif']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문서 파싱 — 파일 경로를 받아 Markdown으로 변환
|
||||||
|
*/
|
||||||
app.post('/parse', async (req, res) => {
|
app.post('/parse', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { filePath } = req.body;
|
const { filePath } = req.body;
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return res.status(400).json({ error: 'filePath is required' });
|
return res.status(400).json({ error: 'filePath is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: kordoc 라이브러리 연동 (Phase 1에서 구현)
|
// 파일 존재 확인
|
||||||
// const kordoc = require('kordoc');
|
if (!fs.existsSync(filePath)) {
|
||||||
// const result = await kordoc.parse(filePath);
|
return res.status(404).json({ error: `파일을 찾을 수 없습니다: ${filePath}` });
|
||||||
// return res.json(result);
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
// 100MB 초과 파일 거부
|
||||||
|
if (stat.size > 100 * 1024 * 1024) {
|
||||||
|
return res.status(413).json({ error: '파일 크기 100MB 초과' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 파일 — 직접 읽기
|
||||||
|
if (TEXT_FORMATS.has(ext)) {
|
||||||
|
const text = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
markdown: text,
|
||||||
|
metadata: { format: ext.slice(1), fileSize: stat.size },
|
||||||
|
format: ext.slice(1),
|
||||||
|
requires_ocr: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 파일 — OCR 필요 플래그
|
||||||
|
if (IMAGE_FORMATS.has(ext)) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
markdown: '',
|
||||||
|
metadata: { format: ext.slice(1), fileSize: stat.size },
|
||||||
|
format: ext.slice(1),
|
||||||
|
requires_ocr: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HWP/HWPX/PDF — kordoc 파싱
|
||||||
|
if (PARSEABLE_FORMATS.has(ext)) {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
|
||||||
|
// 타임아웃 처리
|
||||||
|
const result = await Promise.race([
|
||||||
|
parse(buffer),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('파싱 타임아웃 (30초)')), PARSE_TIMEOUT_MS)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(422).json({
|
||||||
|
error: '문서 파싱 실패',
|
||||||
|
warnings: result.warnings || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
markdown: result.markdown || '',
|
||||||
|
metadata: {
|
||||||
|
...(result.metadata || {}),
|
||||||
|
format: ext.slice(1),
|
||||||
|
fileSize: stat.size,
|
||||||
|
parseTime: Date.now() - startTime,
|
||||||
|
},
|
||||||
|
format: ext.slice(1),
|
||||||
|
requires_ocr: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미지원 포맷
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
markdown: '',
|
markdown: '',
|
||||||
metadata: {},
|
metadata: { format: ext.slice(1), fileSize: stat.size },
|
||||||
format: 'unknown',
|
format: ext.slice(1),
|
||||||
message: 'kordoc 파싱은 Phase 1에서 구현 예정'
|
requires_ocr: false,
|
||||||
|
unsupported: true,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(`[ERROR] /parse: ${err.message}`);
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -45,7 +127,7 @@ app.post('/compare', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'filePathA and filePathB are required' });
|
return res.status(400).json({ error: 'filePathA and filePathB are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: kordoc compare 구현 (Phase 2)
|
// TODO: Phase 2에서 kordoc compare 구현
|
||||||
return res.json({ diffs: [], message: 'compare는 Phase 2에서 구현 예정' });
|
return res.json({ diffs: [], message: 'compare는 Phase 2에서 구현 예정' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
|
|||||||
Reference in New Issue
Block a user