hookehuyr

feat(parse-docs): 添加MCP解析切换功能和审核流程

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
493 +
494 + // 步骤 2: 调用 AI 服务从文档内容中提取配置
495 + // TODO(human): 集成 AI 服务(OpenAI/Anthropic)
496 + // 需要配置 API Key 和服务端点
497 + console.log('📝 AI 配置提取: 待集成 AI 服务')
348 498
349 - // 模拟解析:从文档内容中提取配置 499 + // 临时方案:生成基础配置(基于文件名和内容启发式推断)
350 - // 实际使用时可以调用 AI 服务 500 + const fileName = path.basename(docPath, path.extname(docPath))
351 - const mockConfig = { 501 + const config = {
352 - product_name: path.basename(docPath, path.extname(docPath)), 502 + product_name: fileName,
353 - product_type: 'savings', 503 + product_type: inferProductType(fileName, content),
354 - currency: 'USD', 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 + " 个文档")
......