Showing
2 changed files
with
702 additions
and
25 deletions
docs/tasks/plan/改进文档解析工具-添加审核流程.md
0 → 100644
| 1 | +# 改进文档解析工具-添加审核流程 | ||
| 2 | + | ||
| 3 | +**创建时间**: 2026-02-14 | ||
| 4 | +**负责人**: Claude Code | ||
| 5 | +**优先级**: 🔴 高 | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 背景分析 | ||
| 10 | + | ||
| 11 | +### 当前问题 | ||
| 12 | +使用 `pnpm parse:docs --file="计划书模版2.docx"` 解析文档时存在以下问题: | ||
| 13 | + | ||
| 14 | +1. **解析不准确**:mammoth解析.docx提取的内容与实际文档内容不符 | ||
| 15 | + - 缺少产品基本信息(name, type, currency) | ||
| 16 | + - 字段定义提取不完整(form_schema, submit_mapping) | ||
| 17 | + - 结构化表格数据提取困难 | ||
| 18 | + | ||
| 19 | +2. **缺少审核环节**: | ||
| 20 | + - 当前流程:解析 → 直接生成配置代码 | ||
| 21 | + - 问题:无法验证解析准确性,直接写入配置文件风险高 | ||
| 22 | + | ||
| 23 | +3. **用户需求**: | ||
| 24 | + - 需要"人工辅助"的半自动化方式 | ||
| 25 | + - 在自动解析和直接生成配置之间增加审核环节 | ||
| 26 | + | ||
| 27 | +--- | ||
| 28 | + | ||
| 29 | +## 解决方案 | ||
| 30 | + | ||
| 31 | +### 方案设计 | ||
| 32 | +采用 **"解析 → 审核 → 生成"** 三步流程,支持多种解析方式: | ||
| 33 | + | ||
| 34 | +``` | ||
| 35 | +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ | ||
| 36 | +│ 选择解析方式 │ → │ 生成待审核文件 │ → │ 人工审核后移动 │ | ||
| 37 | +│ mammoth/MCP │ │ (markdown) │ │ 到正式配置 │ | ||
| 38 | +└─────────────────┘ └─────────────────┘ └─────────────────┘ | ||
| 39 | +``` | ||
| 40 | + | ||
| 41 | +#### 解析方式对比 | ||
| 42 | + | ||
| 43 | +| 特性 | mammoth | MCP文档解析 | | ||
| 44 | +|------|---------|-------------| | ||
| 45 | +| **准确性** | 基础(纯文本提取) | 高(AI理解结构) | | ||
| 46 | +| **结构化** | 弱(需手动处理) | 强(自动识别字段) | | ||
| 47 | +| **表格解析** | 一般(markdown表格) | 好(保留结构) | | ||
| 48 | +| **速度** | 快(本地解析) | 慢(网络请求) | | ||
| 49 | +| **成本** | 免费 | 可能需要API Key | | ||
| 50 | + | ||
| 51 | +#### 使用场景 | ||
| 52 | +- **mammoth**: 快速预览、简单文档、离线使用 | ||
| 53 | +- **MCP**: 复杂文档、准确度要求高、有网络连接 | ||
| 54 | + | ||
| 55 | +### 技术实现 | ||
| 56 | + | ||
| 57 | +#### 1. 改进extractProductBasicInfo | ||
| 58 | +尝试从多个位置提取产品基本信息: | ||
| 59 | + | ||
| 60 | +```javascript | ||
| 61 | +// 尝试从文档标题、表格、特定文本模式提取 | ||
| 62 | +async function extractProductBasicInfo(content, fileName) { | ||
| 63 | + const info = { | ||
| 64 | + name: '', | ||
| 65 | + type: 'savings', // 默认储蓄型 | ||
| 66 | + currency: 'USD', | ||
| 67 | + form_sn: generateFormSn(fileName) | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + // 策略1: 从文档标题提取 | ||
| 71 | + const titleMatch = content.match(/^#\s+(.+)$/m) | ||
| 72 | + if (titleMatch) { | ||
| 73 | + info.name = cleanProductName(titleMatch[1].trim()) | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + // 策略2: 从表格中提取"币种"信息 | ||
| 77 | + const currencyMatch = content.match(/币种[::]\s*([A-Z]{3})/i) | ||
| 78 | + if (currencyMatch) { | ||
| 79 | + info.currency = currencyMatch[1] | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + // 策略3: 从表格中提取"产品类型"信息 | ||
| 83 | + if (content.includes('重疾') || content.includes('危疾')) { | ||
| 84 | + info.type = 'critical-illness' | ||
| 85 | + } else if (content.includes('人寿')) { | ||
| 86 | + info.type = 'life-insurance' | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + return info | ||
| 90 | +} | ||
| 91 | +``` | ||
| 92 | + | ||
| 93 | +#### 2. 实现generateAuditFile | ||
| 94 | +生成结构化的待审核markdown文件: | ||
| 95 | + | ||
| 96 | +```javascript | ||
| 97 | +async function generateAuditFile(fileName, config, code) { | ||
| 98 | + const auditDir = 'docs/parse-audit/pending/' | ||
| 99 | + const dateStr = new Date().toISOString().split('T')[0].replace(/:/g, '-') | ||
| 100 | + const auditFileName = `${dateStr}-${fileName.replace(/\.[^/.]+$/, '')}.md` | ||
| 101 | + const auditFilePath = path.join(auditDir, auditFileName) | ||
| 102 | + | ||
| 103 | + const content = `# 产品配置审核 - ${fileName} | ||
| 104 | + | ||
| 105 | +**解析时间**: ${new Date().toLocaleString('zh-CN')} | ||
| 106 | + | ||
| 107 | +--- | ||
| 108 | + | ||
| 109 | +## 📋 产品基本信息 | ||
| 110 | + | ||
| 111 | +| 字段 | 提取值 | 需要确认 | | ||
| 112 | +|------|--------|---------| | ||
| 113 | +| 产品名称 | ${config.name || '未提取'} | ✅ 请核对产品名称 | | ||
| 114 | +| 产品类型 | ${config.type || '未提取'} | ✅ 请确认产品类型 | | ||
| 115 | +| 币种 | ${config.currency || 'USD'} | ✅ 请确认币种 | | ||
| 116 | +| form_sn | \`${config.form_sn || '未生成'}` | ✅ 请确认form_sn唯一性 | | ||
| 117 | + | ||
| 118 | +--- | ||
| 119 | + | ||
| 120 | +## 📝 表单字段 (form_schema) | ||
| 121 | + | ||
| 122 | +\`\`\`javascript | ||
| 123 | +${code.form_schema || '// 请手动补充'} | ||
| 124 | +\`\`\` | ||
| 125 | + | ||
| 126 | +--- | ||
| 127 | + | ||
| 128 | +## 🔄 提交字段映射 (submit_mapping) | ||
| 129 | + | ||
| 130 | +\`\`\`javascript | ||
| 131 | +${code.submit_mapping || '// 请手动补充'} | ||
| 132 | +\`\`\` | ||
| 133 | + | ||
| 134 | +--- | ||
| 135 | + | ||
| 136 | +## ✅ 审核检查清单 | ||
| 137 | + | ||
| 138 | +- [ ] 产品名称正确 | ||
| 139 | +- [ ] 产品类型正确(savings/critical-illness/life-insurance) | ||
| 140 | +- [ ] 币种正确(USD/CNY/HKD/EUR) | ||
| 141 | +- [ ] form_sn 唯一且符合命名规范 | ||
| 142 | +- [ ] 缴费年期选项完整 | ||
| 143 | +- [ ] 年龄范围合理 | ||
| 144 | +- [ ] 提取计划配置(如适用) | ||
| 145 | +- [ ] 表单字段定义完整 | ||
| 146 | +- [ ] 提交字段映射正确 | ||
| 147 | + | ||
| 148 | +--- | ||
| 149 | + | ||
| 150 | +## 📋 审核后操作 | ||
| 151 | + | ||
| 152 | +### 确认无误 | ||
| 153 | +\`\`\`bash | ||
| 154 | +# 1. 移动配置到正式文件 | ||
| 155 | +mv docs/parse-audit/pending/${auditFileName} \\ | ||
| 156 | + src/config/plan-templates.backup.js | ||
| 157 | + | ||
| 158 | +# 2. 合并到正式配置 | ||
| 159 | +# 手动复制或使用工具合并 | ||
| 160 | + | ||
| 161 | +# 3. 删除待审核文件 | ||
| 162 | +rm docs/parse-audit/pending/${auditFileName} | ||
| 163 | +\`\`\` | ||
| 164 | + | ||
| 165 | +### 需要修改 | ||
| 166 | +1. 编辑本文件修正内容 | ||
| 167 | +2. 重新提交审核 | ||
| 168 | + | ||
| 169 | +### 放弃本次解析 | ||
| 170 | +\`\`\`bash | ||
| 171 | +rm docs/parse-audit/pending/${auditFileName} | ||
| 172 | +\`\`\` | ||
| 173 | +` | ||
| 174 | + | ||
| 175 | +--- | ||
| 176 | + | ||
| 177 | +**生成工具**: Claude Code - parse-docs.js | ||
| 178 | +` | ||
| 179 | + | ||
| 180 | + return fs.writeFileSync(auditFilePath, content, 'utf-8') | ||
| 181 | +} | ||
| 182 | +``` | ||
| 183 | + | ||
| 184 | +--- | ||
| 185 | + | ||
| 186 | +## 实施计划 | ||
| 187 | + | ||
| 188 | +### 阶段1: 改进解析逻辑 (30分钟) | ||
| 189 | +- [ ] 改进extractProductBasicInfo函数 | ||
| 190 | + - [ ] 添加文档标题提取 | ||
| 191 | + - [ ] 添加币种信息提取 | ||
| 192 | + - [ ] 添加产品类型推断 | ||
| 193 | + - [ ] 测试验证提取效果 | ||
| 194 | + | ||
| 195 | +### 阶段2: 实现审核文件生成 (20分钟) | ||
| 196 | +- [ ] 实现generateAuditFile函数 | ||
| 197 | + - [ ] 创建待审核目录结构 | ||
| 198 | + - [ ] 测试生成markdown格式 | ||
| 199 | + - [ ] 添加文件路径返回 | ||
| 200 | + | ||
| 201 | +### 阶段3: 集成到主流程 (10分钟) | ||
| 202 | +- [ ] 更新parse-docs.js主函数 | ||
| 203 | + - [ ] 添加成功提示和审核引导 | ||
| 204 | + - [ ] 错误处理和日志输出 | ||
| 205 | + | ||
| 206 | +### 阶段4: 测试验证 (10分钟) | ||
| 207 | +- [ ] 使用实际文档测试 | ||
| 208 | +- [ ] 验证生成的审核文件格式 | ||
| 209 | + - [ ] 确认目录结构正确 | ||
| 210 | + | ||
| 211 | +--- | ||
| 212 | + | ||
| 213 | +## 预期成果 | ||
| 214 | + | ||
| 215 | +1. **更准确的信息提取** | ||
| 216 | + - 产品基本信息提取率提升 | ||
| 217 | + - 减少人工补充工作 | ||
| 218 | + | ||
| 219 | +2. **结构化审核流程** | ||
| 220 | + - 清晰的待审核markdown格式 | ||
| 221 | + - 明确的审核检查清单 | ||
| 222 | + - 简单的审核后操作指引 | ||
| 223 | + | ||
| 224 | +3. **更好的用户体验** | ||
| 225 | + - 成功提示包含下一步操作 | ||
| 226 | + - 降低配置错误风险 | ||
| 227 | + | ||
| 228 | +--- | ||
| 229 | + | ||
| 230 | +## 风险评估 | ||
| 231 | + | ||
| 232 | +| 风险 | 影响 | 应对措施 | | ||
| 233 | +|------|------|---------| | ||
| 234 | +| 提取仍不准确 | 需要大量人工补充 | 提供清晰的标记和默认值 | | ||
| 235 | +| 审核文件过多 | 难以管理 | 定期清理已审核文件 | | ||
| 236 | +| 目录权限问题 | 无法写入文件 | 提前创建目录并检查权限 | | ||
| 237 | + | ||
| 238 | +--- | ||
| 239 | + | ||
| 240 | +## 后续优化 | ||
| 241 | + | ||
| 242 | +1. **交互式审核** | ||
| 243 | + - 提供命令行工具逐步引导填写缺失信息 | ||
| 244 | + - 支持编辑现有审核文件 | ||
| 245 | + | ||
| 246 | +2. **智能推断** | ||
| 247 | + - 基于历史配置推断产品类型 | ||
| 248 | + - 从表格结构自动推断字段定义 | ||
| 249 | + | ||
| 250 | +3. **版本对比** | ||
| 251 | + - 检测配置变更并生成差异报告 | ||
| 252 | + - 支持配置回滚 | ||
| 253 | + | ||
| 254 | +--- | ||
| 255 | + | ||
| 256 | +## 相关文档 | ||
| 257 | +- [mamoth使用文档](https://github.com/mwilliamtohman/mammoth) | ||
| 258 | +- [计划书模板配置规范](../../src/config/CLAUDE.md) | ||
| 259 | +- [代码注释规范](~/.claude/rules/code-commenting.md) |
| ... | @@ -22,6 +22,14 @@ import path from 'path' | ... | @@ -22,6 +22,14 @@ import path from 'path' |
| 22 | import { PDFParse } from 'pdf-parse' | 22 | import { PDFParse } from 'pdf-parse' |
| 23 | import mammoth from 'mammoth' | 23 | import mammoth from 'mammoth' |
| 24 | import Ajv from 'ajv' | 24 | import Ajv from 'ajv' |
| 25 | +import { spawn } from 'child_process' | ||
| 26 | +import { | ||
| 27 | + checkMarkitdownAvailable, | ||
| 28 | + checkAIServiceConfigured, | ||
| 29 | + printConfigStatus, | ||
| 30 | + MARKITDOWN_CONFIG, | ||
| 31 | + AI_SERVICE_CONFIG | ||
| 32 | +} from './parse-config.js' | ||
| 25 | 33 | ||
| 26 | // ========== 配置区 ========== | 34 | // ========== 配置区 ========== |
| 27 | 35 | ||
| ... | @@ -32,9 +40,6 @@ const BACKUP_DIR = path.resolve(process.cwd(), 'docs/parsed-backup') | ... | @@ -32,9 +40,6 @@ const BACKUP_DIR = path.resolve(process.cwd(), 'docs/parsed-backup') |
| 32 | // 支持的文档格式 | 40 | // 支持的文档格式 |
| 33 | const SUPPORTED_EXTENSIONS = ['.pdf', '.doc', '.docx', '.txt', '.md'] | 41 | const SUPPORTED_EXTENSIONS = ['.pdf', '.doc', '.docx', '.txt', '.md'] |
| 34 | 42 | ||
| 35 | -// AI 解析服务选择(通过 skill 调用) | ||
| 36 | -const AI_SERVICE = 'openai' // 'openai' | 'anthropic' | 'openrouter' | ||
| 37 | - | ||
| 38 | const ajv = new Ajv({ allErrors: true, strict: false }) | 43 | const ajv = new Ajv({ allErrors: true, strict: false }) |
| 39 | const parseConfigSchema = { | 44 | const parseConfigSchema = { |
| 40 | type: 'object', | 45 | type: 'object', |
| ... | @@ -322,36 +327,181 @@ function formatSize(size) { | ... | @@ -322,36 +327,181 @@ function formatSize(size) { |
| 322 | } | 327 | } |
| 323 | 328 | ||
| 324 | /** | 329 | /** |
| 330 | + * 调用 markitdown 服务解析文档 | ||
| 331 | + * | ||
| 332 | + * @description 使用 markitdown CLI 将 PDF/DOCX 转换为 Markdown/文本 | ||
| 333 | + * @param {string} docPath - 文档路径 | ||
| 334 | + * @returns {Promise<{text: string, warnings: string[]}>} 解析结果 | ||
| 335 | + */ | ||
| 336 | +async function parseDocumentWithMarkitdown(docPath) { | ||
| 337 | + const ext = path.extname(docPath).toLowerCase() | ||
| 338 | + | ||
| 339 | + // MD 和 TXT 文件直接读取,不需要 markitdown | ||
| 340 | + if (ext === '.md' || ext === '.txt') { | ||
| 341 | + console.log(`📄 直接读取文本文件: ${path.basename(docPath)}`) | ||
| 342 | + return buildExtractResult(docPath, fs.readFileSync(docPath, 'utf-8'), []) | ||
| 343 | + } | ||
| 344 | + | ||
| 345 | + console.log(`\n📄 使用 markitdown 解析: ${path.basename(docPath)}`) | ||
| 346 | + | ||
| 347 | + try { | ||
| 348 | + if (MARKITDOWN_CONFIG.type === 'cli') { | ||
| 349 | + // .docx 文件使用 mammoth 库(markitdown 兼容性问题) | ||
| 350 | + if (ext === '.docx') { | ||
| 351 | + console.log('⚠️ .docx 文件使用 mammoth 库解析(避免 markitdown 兼容性问题)') | ||
| 352 | + return await extractTextFromDocx(docPath) | ||
| 353 | + } | ||
| 354 | + // 只对 PDF 使用 markitdown | ||
| 355 | + if (ext === '.pdf') { | ||
| 356 | + return await parseWithMarkitdownCLI(docPath) | ||
| 357 | + } else { | ||
| 358 | + console.log(`⚠️ 文件类型 ${ext} 不支持 markitdown,使用本地库解析`) | ||
| 359 | + return await extractDocumentText(docPath) | ||
| 360 | + } | ||
| 361 | + } | ||
| 362 | + | ||
| 363 | + // 其他类型暂未实现,fallback 到本地库 | ||
| 364 | + console.log('⚚️ markitdown 未启用,使用本地库解析') | ||
| 365 | + return await extractDocumentText(docPath) | ||
| 366 | + } catch (error) { | ||
| 367 | + console.error(`❌ markitdown 解析失败 (${docPath}):`, error.message) | ||
| 368 | + // fallback 到本地库 | ||
| 369 | + console.log('🔄 回退到本地库解析...') | ||
| 370 | + return await extractDocumentText(docPath) | ||
| 371 | + } | ||
| 372 | +} | ||
| 373 | + | ||
| 374 | +/** | ||
| 375 | + * 使用 markitdown CLI 解析文档 | ||
| 376 | + * | ||
| 377 | + * @description 使用 spawn 调用 markitdown CLI 工具(从 stdin 读取) | ||
| 378 | + * @param {string} docPath - 文档路径 | ||
| 379 | + * @returns {Promise<{text: string, warnings: string[]}>} 解析结果 | ||
| 380 | + */ | ||
| 381 | +async function parseWithMarkitdownCLI(docPath) { | ||
| 382 | + const tmpDir = path.resolve(process.cwd(), 'docs/tmp') | ||
| 383 | + ensureDir(tmpDir) | ||
| 384 | + | ||
| 385 | + const outputPath = path.join(tmpDir, path.basename(docPath, path.extname(docPath)) + '.md') | ||
| 386 | + const timeout = MARKITDOWN_CONFIG.cli.timeout || 30000 | ||
| 387 | + | ||
| 388 | + console.log(` 命令: cat "${docPath}" | markitdown > "${outputPath}"`) | ||
| 389 | + | ||
| 390 | + return new Promise((resolve, reject) => { | ||
| 391 | + const timer = setTimeout(() => { | ||
| 392 | + spawn.kill(0, 'SIGTERM') // 尝试优雅终止 | ||
| 393 | + reject(new Error('markitdown 执行超时')) | ||
| 394 | + }, timeout) | ||
| 395 | + | ||
| 396 | + // 使用 cat 读取文件并通过管道传递给 markitdown | ||
| 397 | + const cat = spawn('cat', [docPath]) | ||
| 398 | + const markitdown = spawn('markitdown', [], { | ||
| 399 | + stdio: ['ignore', 'pipe', 'pipe'] | ||
| 400 | + }) | ||
| 401 | + | ||
| 402 | + let stdout = '' | ||
| 403 | + let stderr = '' | ||
| 404 | + | ||
| 405 | + markitdown.stdout.on('data', (data) => { stdout += data }) | ||
| 406 | + markitdown.stderr.on('data', (data) => { stderr += data }) | ||
| 407 | + | ||
| 408 | + markitdown.on('close', (code) => { | ||
| 409 | + clearTimeout(timer) | ||
| 410 | + | ||
| 411 | + if (code !== 0) { | ||
| 412 | + reject(new Error(`markitdown 退出码: ${code}\n${stderr}`)) | ||
| 413 | + return | ||
| 414 | + } | ||
| 415 | + | ||
| 416 | + // 写入输出文件 | ||
| 417 | + try { | ||
| 418 | + fs.writeFileSync(outputPath, stdout, 'utf-8') | ||
| 419 | + | ||
| 420 | + console.log(`✅ markitdown 解析成功,提取 ${stdout.length} 字符`) | ||
| 421 | + resolve({ text: stdout, warnings: [] }) | ||
| 422 | + } catch (writeError) { | ||
| 423 | + reject(writeError) | ||
| 424 | + } | ||
| 425 | + }) | ||
| 426 | + | ||
| 427 | + markitdown.on('error', (error) => { | ||
| 428 | + clearTimeout(timer) | ||
| 429 | + reject(error) | ||
| 430 | + }) | ||
| 431 | + | ||
| 432 | + cat.on('error', (error) => { | ||
| 433 | + clearTimeout(timer) | ||
| 434 | + reject(error) | ||
| 435 | + }) | ||
| 436 | + | ||
| 437 | + // 将 cat 的输出连接到 markitdown 的输入 | ||
| 438 | + cat.stdout.pipe(markitdown.stdin) | ||
| 439 | + }) | ||
| 440 | +} | ||
| 441 | + | ||
| 442 | +/** | ||
| 443 | + * AI 解析提示词模板 | ||
| 444 | + * | ||
| 445 | + * @description 用于指导 AI 从文档内容中提取产品配置 | ||
| 446 | + */ | ||
| 447 | +const AI_PARSE_PROMPT = `你是一个保险产品配置专家。请从以下文档内容中提取产品配置信息。 | ||
| 448 | + | ||
| 449 | +请按以下 JSON 格式返回配置: | ||
| 450 | +{ | ||
| 451 | + "product_name": "产品名称", | ||
| 452 | + "product_type": "产品类型 (savings/life-insurance/critical-illness)", | ||
| 453 | + "currency": "币种 (USD/CNY/HKD)", | ||
| 454 | + "payment_periods": ["缴费年期1", "缴费年期2"], | ||
| 455 | + "age_range": { "min": 最小年龄, "max": 最大年龄 }, | ||
| 456 | + "insurance_period": "保险期间", | ||
| 457 | + "is_savings": true/false (是否为储蓄型产品), | ||
| 458 | + "withdrawal_modes": ["提取模式1", "提取模式2"], | ||
| 459 | + "withdrawal_periods": ["提取期1", "提取期2"] | ||
| 460 | +} | ||
| 461 | + | ||
| 462 | +文档内容: | ||
| 463 | +{CONTENT} | ||
| 464 | + | ||
| 465 | +请只返回 JSON,不要包含其他内容。` | ||
| 466 | + | ||
| 467 | +/** | ||
| 325 | * 调用 AI 服务解析文档 | 468 | * 调用 AI 服务解析文档 |
| 326 | * | 469 | * |
| 327 | - * 这里使用 skill 工具调用实际的 AI 解析服务 | 470 | + * @description 使用 markitdown + AI 智能解析文档并提取配置 |
| 328 | - * 可以是:file-url-to-pdf + openai/anthropic skill | 471 | + * @param {string} docPath - 文档路径 |
| 472 | + * @returns {Promise<Object>} 解析后的配置对象 | ||
| 329 | */ | 473 | */ |
| 330 | async function parseDocumentWithAI(docPath) { | 474 | async function parseDocumentWithAI(docPath) { |
| 331 | - console.log(`\n🤖 正在解析: ${path.basename(docPath)}`) | 475 | + console.log(`\n🤖 正在智能解析: ${path.basename(docPath)}`) |
| 332 | 476 | ||
| 333 | try { | 477 | try { |
| 334 | - const extract_result = await extractDocumentText(docPath) | 478 | + // 步骤 1: 使用 markitdown 将文档转换为 Markdown/文本 |
| 479 | + const parse_result = await parseDocumentWithMarkitdown(docPath) | ||
| 335 | 480 | ||
| 336 | - if (extract_result.warnings.length > 0) { | 481 | + if (parse_result.warnings.length > 0) { |
| 337 | - extract_result.warnings.forEach(message => { | 482 | + parse_result.warnings.forEach(message => { |
| 338 | - console.log(`⚠️ 抽取警告: ${message}`) | 483 | + console.log(`⚠️ 解析警告: ${message}`) |
| 339 | }) | 484 | }) |
| 340 | } | 485 | } |
| 341 | 486 | ||
| 342 | - if (!extract_result.text || !extract_result.text.trim()) { | 487 | + if (!parse_result.text || !parse_result.text.trim()) { |
| 343 | - console.error(`❌ 文本抽取失败 (${docPath})`) | 488 | + console.error(`❌ 文档解析失败,文本为空 (${docPath})`) |
| 344 | return null | 489 | return null |
| 345 | } | 490 | } |
| 346 | 491 | ||
| 347 | - const content = extract_result.text | 492 | + const content = parse_result.text |
| 348 | 493 | ||
| 349 | - // 模拟解析:从文档内容中提取配置 | 494 | + // 步骤 2: 调用 AI 服务从文档内容中提取配置 |
| 350 | - // 实际使用时可以调用 AI 服务 | 495 | + // TODO(human): 集成 AI 服务(OpenAI/Anthropic) |
| 351 | - const mockConfig = { | 496 | + // 需要配置 API Key 和服务端点 |
| 352 | - product_name: path.basename(docPath, path.extname(docPath)), | 497 | + console.log('📝 AI 配置提取: 待集成 AI 服务') |
| 353 | - product_type: 'savings', | 498 | + |
| 354 | - currency: 'USD', | 499 | + // 临时方案:生成基础配置(基于文件名和内容启发式推断) |
| 500 | + const fileName = path.basename(docPath, path.extname(docPath)) | ||
| 501 | + const config = { | ||
| 502 | + product_name: fileName, | ||
| 503 | + product_type: inferProductType(fileName, content), | ||
| 504 | + currency: inferCurrency(content), | ||
| 355 | payment_periods: ['整付', '3年', '5年'], | 505 | payment_periods: ['整付', '3年', '5年'], |
| 356 | age_range: { min: 0, max: 75 }, | 506 | age_range: { min: 0, max: 75 }, |
| 357 | insurance_period: '终身', | 507 | insurance_period: '终身', |
| ... | @@ -362,8 +512,10 @@ async function parseDocumentWithAI(docPath) { | ... | @@ -362,8 +512,10 @@ async function parseDocumentWithAI(docPath) { |
| 362 | submit_mapping: {} | 512 | submit_mapping: {} |
| 363 | } | 513 | } |
| 364 | 514 | ||
| 365 | - console.log('✅ 解析成功') | 515 | + console.log('✅ 解析成功 (启发式推断)') |
| 366 | - return mockConfig | 516 | + console.log(` 产品类型: ${config.product_type}`) |
| 517 | + console.log(` 币种: ${config.currency}`) | ||
| 518 | + return config | ||
| 367 | } catch (error) { | 519 | } catch (error) { |
| 368 | console.error(`❌ 解析失败 (${docPath}):`, error.message) | 520 | console.error(`❌ 解析失败 (${docPath}):`, error.message) |
| 369 | return null | 521 | return null |
| ... | @@ -371,6 +523,64 @@ async function parseDocumentWithAI(docPath) { | ... | @@ -371,6 +523,64 @@ async function parseDocumentWithAI(docPath) { |
| 371 | } | 523 | } |
| 372 | 524 | ||
| 373 | /** | 525 | /** |
| 526 | + * 启发式推断产品类型 | ||
| 527 | + * | ||
| 528 | + * @description 从文件名和内容推断产品类型 | ||
| 529 | + * @param {string} fileName - 文件名 | ||
| 530 | + * @param {string} content - 文档内容 | ||
| 531 | + * @returns {string} 产品类型 | ||
| 532 | + */ | ||
| 533 | +function inferProductType(fileName, content) { | ||
| 534 | + const lowerName = fileName.toLowerCase() | ||
| 535 | + | ||
| 536 | + if (lowerName.includes('储蓄') || lowerName.includes('saving') || lowerName.includes('传承') || lowerName.includes('家传')) { | ||
| 537 | + return 'savings' | ||
| 538 | + } | ||
| 539 | + if (lowerName.includes('重疾') || lowerName.includes('critical') || lowerName.includes('守护')) { | ||
| 540 | + return 'critical-illness' | ||
| 541 | + } | ||
| 542 | + if (lowerName.includes('人寿') || lowerName.includes('life') || lowerName.includes('创富')) { | ||
| 543 | + return 'life-insurance' | ||
| 544 | + } | ||
| 545 | + | ||
| 546 | + // 从内容中推断 | ||
| 547 | + const contentLower = content.toLowerCase() | ||
| 548 | + if (contentLower.includes('储蓄') || contentLower.includes('红利') || contentLower.includes('提取')) { | ||
| 549 | + return 'savings' | ||
| 550 | + } | ||
| 551 | + if (contentLower.includes('重疾') || contentLower.includes('早期严重疾病')) { | ||
| 552 | + return 'critical-illness' | ||
| 553 | + } | ||
| 554 | + if (contentLower.includes('寿险') || contentLower.includes('身故保障')) { | ||
| 555 | + return 'life-insurance' | ||
| 556 | + } | ||
| 557 | + | ||
| 558 | + // 默认为储蓄型 | ||
| 559 | + return 'savings' | ||
| 560 | +} | ||
| 561 | + | ||
| 562 | +/** | ||
| 563 | + * 启发式推断币种 | ||
| 564 | + * | ||
| 565 | + * @description 从文档内容推断币种 | ||
| 566 | + * @param {string} content - 文档内容 | ||
| 567 | + * @returns {string} 币种代码 | ||
| 568 | + */ | ||
| 569 | +function inferCurrency(content) { | ||
| 570 | + // 统计各种币种符号的出现次数 | ||
| 571 | + const usdCount = (content.match(/\$/g) || []).length | ||
| 572 | + const cnyCount = (content.match(/¥|人民币/g) || []).length | ||
| 573 | + const hkdCount = (content.match(/HK\$/g) || []).length | ||
| 574 | + | ||
| 575 | + if (usdCount > cnyCount && usdCount > hkdCount) return 'USD' | ||
| 576 | + if (hkdCount > usdCount && hkdCount > cnyCount) return 'HKD' | ||
| 577 | + if (cnyCount > usdCount && cnyCount > hkdCount) return 'CNY' | ||
| 578 | + | ||
| 579 | + // 默认美元 | ||
| 580 | + return 'USD' | ||
| 581 | +} | ||
| 582 | + | ||
| 583 | +/** | ||
| 374 | * 解析单个文档 | 584 | * 解析单个文档 |
| 375 | */ | 585 | */ |
| 376 | async function parseSingleFile(filePath) { | 586 | async function parseSingleFile(filePath) { |
| ... | @@ -405,7 +615,196 @@ async function parseSingleFile(filePath) { | ... | @@ -405,7 +615,196 @@ async function parseSingleFile(filePath) { |
| 405 | console.log("\n📝 生成 form_sn: " + formSn) | 615 | console.log("\n📝 生成 form_sn: " + formSn) |
| 406 | console.log("📋 生成配置代码:\n" + code) | 616 | console.log("📋 生成配置代码:\n" + code) |
| 407 | 617 | ||
| 408 | - return { success: true, formSn, code, file: fileName, config } | 618 | + // ✨ 新增:生成待审核文件(不直接写入正式配置) |
| 619 | + const auditFile = await generateAuditFile(fileName, config, code) | ||
| 620 | + if (auditFile) { | ||
| 621 | + console.log("\n✅ 已生成待审核文件: " + auditFile) | ||
| 622 | + console.log("📋 请审核后手动移动到 src/config/plan-templates.js") | ||
| 623 | + return { success: true, formSn, code, file: fileName, config, auditFile } | ||
| 624 | + } | ||
| 625 | + | ||
| 626 | + return { success: true, formSn, code, file: fileName, config, auditFile } | ||
| 627 | +} | ||
| 628 | + | ||
| 629 | +/** | ||
| 630 | + * 生成待审核文件 | ||
| 631 | + * | ||
| 632 | + * @description 生成人类可读的 markdown 审核文件 | ||
| 633 | + * @param {string} fileName - 原始文件名 | ||
| 634 | + * @param {Object} config - 解析的配置对象 | ||
| 635 | + * @param {string} code - 生成的配置代码 | ||
| 636 | + * @returns {Promise<string|null>} 审核文件路径 | ||
| 637 | + */ | ||
| 638 | +async function generateAuditFile(fileName, config, code) { | ||
| 639 | + const AUDIT_DIR = path.resolve(process.cwd(), 'docs/parse-audit/pending') | ||
| 640 | + ensureDir(AUDIT_DIR) | ||
| 641 | + | ||
| 642 | + const date = new Date().toISOString().split('T')[0] | ||
| 643 | + const auditFileName = `${date}-${fileName.replace(/\.[^/.]+$/, '')}.md` | ||
| 644 | + const auditFilePath = path.join(AUDIT_DIR, auditFileName) | ||
| 645 | + | ||
| 646 | + const content = `# ${config.product_name || fileName} | ||
| 647 | + | ||
| 648 | +## 解析信息 | ||
| 649 | + | ||
| 650 | +- **原始文件**: ${fileName} | ||
| 651 | +- **解析时间**: ${new Date().toLocaleString('zh-CN')} | ||
| 652 | +- **数据来源**: docs/to-parse/${fileName} | ||
| 653 | + | ||
| 654 | +--- | ||
| 655 | + | ||
| 656 | +## 配置预览 | ||
| 657 | + | ||
| 658 | +\`\`\`javascript | ||
| 659 | +const config = \\${JSON.stringify(config, null, 2)} | ||
| 660 | +\`\` | ||
| 661 | + | ||
| 662 | +--- | ||
| 663 | + | ||
| 664 | +## 审核检查清单 | ||
| 665 | + | ||
| 666 | +- [ ] 产品名称是否正确 | ||
| 667 | +- [ ] 产品类型是否正确(${config.product_type || '未知'}) | ||
| 668 | +- [ ] 币种是否正确(${config.currency || '未知'}) | ||
| 669 | +- [ ] age_range 是否合理(${config.age_range?.min || 0} - ${config.age_range?.max || 75}岁) | ||
| 670 | +- [ ] form_schema 字段是否完整 | ||
| 671 | +- [ ] submit_mapping 逻辑是否正确 | ||
| 672 | + | ||
| 673 | +--- | ||
| 674 | + | ||
| 675 | +## 下一步 | ||
| 676 | + | ||
| 677 | +1. 审核以上配置是否正确 | ||
| 678 | +2. 确核通过后,执行以下操作: | ||
| 679 | + | ||
| 680 | +\`\`\`bash | ||
| 681 | +# 1. 移动到 approved 目录 | ||
| 682 | +mv docs/parse-audit/pending/${auditFileName} docs/parse-audit/approved/ | ||
| 683 | + | ||
| 684 | +# 2. 手动添加配置到 src/config/plan-templates.js | ||
| 685 | +# 3. 运行 pnpm lint 检 | ||
| 686 | +\`\` | ||
| 687 | + | ||
| 688 | +--- | ||
| 689 | + | ||
| 690 | +## 审核状态 | ||
| 691 | + | ||
| 692 | +- [ ] 待审核 | ||
| 693 | +- [ ] 已通过 | ||
| 694 | +- [ ] 已拒绝 | ||
| 695 | + | ||
| 696 | +## 审核意见 | ||
| 697 | + | ||
| 698 | +<!-- 审核时请填写 --> | ||
| 699 | + | ||
| 700 | +**生成时间**: ${new Date().toLocaleString('zh-CN')} | ||
| 701 | +` | ||
| 702 | + | ||
| 703 | +<!-- 审核通过后,请执行以下步骤:--> | ||
| 704 | +1. 将此文件移动到 \`docs/parse-audit/approved/\` | ||
| 705 | +2. 手动将配置添加到 \`src/config/plan-templates.js\` | ||
| 706 | +3. 运行 \`pnpm lint\` �查 | ||
| 707 | +` | ||
| 708 | + | ||
| 709 | +--- | ||
| 710 | + | ||
| 711 | +**注意**: | ||
| 712 | +- 请仔细核对配置的每个字段 | ||
| 713 | +- 确认产品类型和币种是否符合业务需求 | ||
| 714 | +- �查 form_schema 中的字段定义是否完整 | ||
| 715 | +` | ||
| 716 | + | ||
| 717 | +` | ||
| 718 | + | ||
| 719 | + try { | ||
| 720 | + fs.writeFileSync(auditFilePath, content, 'utf-8') | ||
| 721 | + return auditFilePath | ||
| 722 | + } catch (error) { | ||
| 723 | + console.error(\`❌ 写入审核文件失败: \${error.message}`) | ||
| 724 | + return null | ||
| 725 | + } | ||
| 726 | +} | ||
| 727 | + | ||
| 728 | +/** | ||
| 729 | + * 生成待审核文件 | ||
| 730 | + * | ||
| 731 | + * @description 生成人类可读的 markdown 审核文件,保存到 docs/parse-audit/pending/ | ||
| 732 | + * @param {string} fileName - 原始文件名 | ||
| 733 | + * @param {Object} config - 解析的配置对象 | ||
| 734 | + * @param {string} code - 生成的配置代码 | ||
| 735 | + * @returns {Promise<string|null>} 审核文件路径 | ||
| 736 | + */ | ||
| 737 | +async function generateAuditFile(fileName, config, code) { | ||
| 738 | + const AUDIT_DIR = path.resolve(process.cwd(), 'docs/parse-audit/pending') | ||
| 739 | + ensureDir(AUDIT_DIR) | ||
| 740 | + | ||
| 741 | + const date = new Date().toISOString().split('T')[0] | ||
| 742 | + const auditFileName = `${date}-${fileName.replace(/\.[^/.]+$/, '')}.md` | ||
| 743 | + const auditFilePath = path.join(AUDIT_DIR, auditFileName) | ||
| 744 | + | ||
| 745 | + const content = `# ${config.product_name || fileName} | ||
| 746 | + | ||
| 747 | +## 解析信息 | ||
| 748 | + | ||
| 749 | +- **原始文件**: ${fileName} | ||
| 750 | +- **解析时间**: ${new Date().toLocaleString('zh-CN')} | ||
| 751 | +- **数据来源**: docs/to-parse/${fileName} | ||
| 752 | + | ||
| 753 | +--- | ||
| 754 | + | ||
| 755 | +## 配置预览 | ||
| 756 | + | ||
| 757 | +\`\`\`javascript | ||
| 758 | +{ | ||
| 759 | + "product_name": "${config.product_name || ''}", | ||
| 760 | + "product_type": "${config.product_type || ''}", | ||
| 761 | + "currency": "${config.currency || ''}", | ||
| 762 | +${Object.entries(config).filter(([k]) => | ||
| 763 | +!['source_file', 'form_schema', 'submit_mapping'].includes(k) | ||
| 764 | +).map(([k, v]) => | ||
| 765 | + ` "${k}": ${JSON.stringify(v, null, 2)}` | ||
| 766 | +).join(',\n ')} | ||
| 767 | +\`\`\` | ||
| 768 | + | ||
| 769 | +--- | ||
| 770 | + | ||
| 771 | +## 审核检查清单 | ||
| 772 | + | ||
| 773 | +- [ ] 产品名称是否正确 | ||
| 774 | +- [ ] 产品类型是否正确 | ||
| 775 | +- [ ] 币种是否正确 | ||
| 776 | +- [ ] age_range 是否合理 | ||
| 777 | +- [ ] payment_periods / withdrawal_periods 是否完整 | ||
| 778 | +- [ ] form_schema 字段是否完整 | ||
| 779 | +- [ ] 提取逻辑是否符合需求 | ||
| 780 | + | ||
| 781 | +--- | ||
| 782 | + | ||
| 783 | +## 审核状态 | ||
| 784 | + | ||
| 785 | +- [ ] 待审核 | ||
| 786 | +- [ ] 已通过 | ||
| 787 | +- [ ] 已拒绝 | ||
| 788 | + | ||
| 789 | +## 审核意见 | ||
| 790 | + | ||
| 791 | +\`\`\` | ||
| 792 | + | ||
| 793 | +<!-- 审核通过后,请执行以下步骤: | ||
| 794 | + | ||
| 795 | +1. 将此文件移动到 docs/parse-audit/approved/ | ||
| 796 | +2. 手动将配置添加到 src/config/plan-templates.js | ||
| 797 | +3. 提交变更 | ||
| 798 | + | ||
| 799 | +--> | ||
| 800 | + | ||
| 801 | + try { | ||
| 802 | + fs.writeFileSync(auditFilePath, content, 'utf-8') | ||
| 803 | + return auditFilePath | ||
| 804 | + } catch (error) { | ||
| 805 | + console.error(`❌ 写入审核文件失败: ${error.message}`) | ||
| 806 | + return null | ||
| 807 | + } | ||
| 409 | } | 808 | } |
| 410 | 809 | ||
| 411 | /** | 810 | /** |
| ... | @@ -877,11 +1276,24 @@ async function main() { | ... | @@ -877,11 +1276,24 @@ async function main() { |
| 877 | const fileMode = args.find(arg => arg.startsWith('--file=')) | 1276 | const fileMode = args.find(arg => arg.startsWith('--file=')) |
| 878 | const dryRunMode = args.includes('--dry-run') | 1277 | const dryRunMode = args.includes('--dry-run') |
| 879 | const rollbackMode = args.find(arg => arg.startsWith('--rollback=')) | 1278 | const rollbackMode = args.find(arg => arg.startsWith('--rollback=')) |
| 1279 | + const statusMode = args.includes('--status') | ||
| 880 | 1280 | ||
| 881 | - console.log('\n🚀 文档解析工具') | 1281 | + // 检查解析器选择 |
| 1282 | + const parserModeArg = args.find(arg => arg.startsWith('--parser=')) | ||
| 1283 | + const parserMode = parserModeArg ? parserModeArg.split('=')[1].toLowerCase() : 'mammoth' | ||
| 1284 | + | ||
| 1285 | + console.log('\n🚀 文档解析工具 v2.0') | ||
| 882 | console.log(" 文档目录: " + DOCS_DIR) | 1286 | console.log(" 文档目录: " + DOCS_DIR) |
| 883 | console.log(" 配置文件: " + CONFIG_FILE) | 1287 | console.log(" 配置文件: " + CONFIG_FILE) |
| 884 | 1288 | ||
| 1289 | + // 显示配置状态 | ||
| 1290 | + printConfigStatus() | ||
| 1291 | + | ||
| 1292 | + if (statusMode) { | ||
| 1293 | + // 只显示状态,不执行解析 | ||
| 1294 | + return | ||
| 1295 | + } | ||
| 1296 | + | ||
| 885 | if (rollbackMode) { | 1297 | if (rollbackMode) { |
| 886 | const backupFile = rollbackMode.split('=')[1] | 1298 | const backupFile = rollbackMode.split('=')[1] |
| 887 | rollbackConfigFile(backupFile) | 1299 | rollbackConfigFile(backupFile) |
| ... | @@ -899,11 +1311,17 @@ async function main() { | ... | @@ -899,11 +1311,17 @@ async function main() { |
| 899 | } else if (fileMode) { | 1311 | } else if (fileMode) { |
| 900 | // 单文件模式 | 1312 | // 单文件模式 |
| 901 | const fileName = fileMode.split('=')[1] | 1313 | const fileName = fileMode.split('=')[1] |
| 902 | - const targetDoc = docs.find(d => d.name === fileName || d.name.includes(fileName)) | 1314 | + // 更宽松的匹配:支持模糊匹配(移除特殊字符后比较) |
| 1315 | + const normalize = (str) => str.toLowerCase().replace(/[\s\-_版]/g, '') | ||
| 1316 | + const normalizedFileName = normalize(fileName) | ||
| 1317 | + const targetDoc = docs.find(d => { | ||
| 1318 | + const normalizedName = normalize(d.name) | ||
| 1319 | + return normalizedName === normalizedFileName || normalizedName.includes(normalizedFileName) | ||
| 1320 | + }) | ||
| 903 | 1321 | ||
| 904 | if (targetDoc) { | 1322 | if (targetDoc) { |
| 905 | const start_time = Date.now() | 1323 | const start_time = Date.now() |
| 906 | - const result = await parseSingleFile(targetDoc.fullPath) | 1324 | + const result = await parseSingleFile(targetDoc.fullPath, parserMode) |
| 907 | const summary = buildParseSummary([result], Date.now() - start_time) | 1325 | const summary = buildParseSummary([result], Date.now() - start_time) |
| 908 | console.log("\n📊 解析结果汇总") | 1326 | console.log("\n📊 解析结果汇总") |
| 909 | console.log("总计: " + summary.total + " 个文档") | 1327 | console.log("总计: " + summary.total + " 个文档") | ... | ... |
-
Please register or login to post a comment