diff --git a/app/ai/client.py b/app/ai/client.py index f64b9e4..eadf245 100644 --- a/app/ai/client.py +++ b/app/ai/client.py @@ -1,11 +1,39 @@ """AI 추상화 레이어 — 통합 클라이언트. 기본값은 항상 Qwen3.5.""" +import json +import re from pathlib import Path import httpx from core.config import settings + +def strip_thinking(text: str) -> str: + """Qwen3.5의 ... 블록 제거""" + return re.sub(r".*?", "", 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" diff --git a/app/main.py b/app/main.py index be36b71..8621ab8 100644 --- a/app/main.py +++ b/app/main.py @@ -16,11 +16,21 @@ from models.user import User @asynccontextmanager async def lifespan(app: FastAPI): """앱 시작/종료 시 실행되는 lifespan 핸들러""" + from apscheduler.schedulers.asyncio import AsyncIOScheduler + from workers.queue_consumer import consume_queue + # 시작: 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 - # 종료: DB 엔진 정리 + + # 종료: 스케줄러 → DB 순서로 정리 + scheduler.shutdown(wait=False) await engine.dispose() diff --git a/app/workers/classify_worker.py b/app/workers/classify_worker.py new file mode 100644 index 0000000..10f5140 --- /dev/null +++ b/app/workers/classify_worker.py @@ -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() diff --git a/app/workers/embed_worker.py b/app/workers/embed_worker.py new file mode 100644 index 0000000..74f8999 --- /dev/null +++ b/app/workers/embed_worker.py @@ -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() diff --git a/app/workers/extract_worker.py b/app/workers/extract_worker.py new file mode 100644 index 0000000..9bf38f6 --- /dev/null +++ b/app/workers/extract_worker.py @@ -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})") diff --git a/app/workers/queue_consumer.py b/app/workers/queue_consumer.py new file mode 100644 index 0000000..3856acf --- /dev/null +++ b/app/workers/queue_consumer.py @@ -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() diff --git a/scripts/migrate_from_devonthink.py b/scripts/migrate_from_devonthink.py new file mode 100644 index 0000000..567580a --- /dev/null +++ b/scripts/migrate_from_devonthink.py @@ -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, + )) diff --git a/services/kordoc/package.json b/services/kordoc/package.json index 5f37986..eb2e9f4 100644 --- a/services/kordoc/package.json +++ b/services/kordoc/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "express": "^4.18.0", - "kordoc": "^1.7.0" + "kordoc": "^1.7.0", + "pdfjs-dist": "^4.0.0" } } diff --git a/services/kordoc/server.js b/services/kordoc/server.js index 6841785..ca1aba1 100644 --- a/services/kordoc/server.js +++ b/services/kordoc/server.js @@ -1,10 +1,15 @@ /** - * kordoc 마이크로서비스 — HWP/HWPX/PDF → Markdown 변환 API + * kordoc 마이크로서비스 — HWP/HWPX/PDF/텍스트 → Markdown 변환 API */ const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const { parse, detectFormat } = require('kordoc'); + const app = express(); const PORT = 3100; +const PARSE_TIMEOUT_MS = 30000; app.use(express.json({ limit: '500mb' })); @@ -13,26 +18,103 @@ app.get('/health', (req, res) => { 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) => { + const startTime = Date.now(); + try { const { filePath } = req.body; if (!filePath) { return res.status(400).json({ error: 'filePath is required' }); } - // TODO: kordoc 라이브러리 연동 (Phase 1에서 구현) - // const kordoc = require('kordoc'); - // const result = await kordoc.parse(filePath); - // return res.json(result); + // 파일 존재 확인 + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: `파일을 찾을 수 없습니다: ${filePath}` }); + } + 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({ + 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: '', - metadata: {}, - format: 'unknown', - message: 'kordoc 파싱은 Phase 1에서 구현 예정' + metadata: { format: ext.slice(1), fileSize: stat.size }, + format: ext.slice(1), + requires_ocr: false, + unsupported: true, }); } catch (err) { + console.error(`[ERROR] /parse: ${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' }); } - // TODO: kordoc compare 구현 (Phase 2) + // TODO: Phase 2에서 kordoc compare 구현 return res.json({ diffs: [], message: 'compare는 Phase 2에서 구현 예정' }); } catch (err) { res.status(500).json({ error: err.message });