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:
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"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 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 });
|
||||
|
||||
Reference in New Issue
Block a user