hookehuyr

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

# 改进文档解析工具-添加审核流程
**创建时间**: 2026-02-14
**负责人**: Claude Code
**优先级**: 🔴 高
---
## 背景分析
### 当前问题
使用 `pnpm parse:docs --file="计划书模版2.docx"` 解析文档时存在以下问题:
1. **解析不准确**:mammoth解析.docx提取的内容与实际文档内容不符
- 缺少产品基本信息(name, type, currency)
- 字段定义提取不完整(form_schema, submit_mapping)
- 结构化表格数据提取困难
2. **缺少审核环节**
- 当前流程:解析 → 直接生成配置代码
- 问题:无法验证解析准确性,直接写入配置文件风险高
3. **用户需求**
- 需要"人工辅助"的半自动化方式
- 在自动解析和直接生成配置之间增加审核环节
---
## 解决方案
### 方案设计
采用 **"解析 → 审核 → 生成"** 三步流程,支持多种解析方式:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 选择解析方式 │ → │ 生成待审核文件 │ → │ 人工审核后移动 │
│ mammoth/MCP │ │ (markdown) │ │ 到正式配置 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
#### 解析方式对比
| 特性 | mammoth | MCP文档解析 |
|------|---------|-------------|
| **准确性** | 基础(纯文本提取) | 高(AI理解结构) |
| **结构化** | 弱(需手动处理) | 强(自动识别字段) |
| **表格解析** | 一般(markdown表格) | 好(保留结构) |
| **速度** | 快(本地解析) | 慢(网络请求) |
| **成本** | 免费 | 可能需要API Key |
#### 使用场景
- **mammoth**: 快速预览、简单文档、离线使用
- **MCP**: 复杂文档、准确度要求高、有网络连接
### 技术实现
#### 1. 改进extractProductBasicInfo
尝试从多个位置提取产品基本信息:
```javascript
// 尝试从文档标题、表格、特定文本模式提取
async function extractProductBasicInfo(content, fileName) {
const info = {
name: '',
type: 'savings', // 默认储蓄型
currency: 'USD',
form_sn: generateFormSn(fileName)
}
// 策略1: 从文档标题提取
const titleMatch = content.match(/^#\s+(.+)$/m)
if (titleMatch) {
info.name = cleanProductName(titleMatch[1].trim())
}
// 策略2: 从表格中提取"币种"信息
const currencyMatch = content.match(/币种[::]\s*([A-Z]{3})/i)
if (currencyMatch) {
info.currency = currencyMatch[1]
}
// 策略3: 从表格中提取"产品类型"信息
if (content.includes('重疾') || content.includes('危疾')) {
info.type = 'critical-illness'
} else if (content.includes('人寿')) {
info.type = 'life-insurance'
}
return info
}
```
#### 2. 实现generateAuditFile
生成结构化的待审核markdown文件:
```javascript
async function generateAuditFile(fileName, config, code) {
const auditDir = 'docs/parse-audit/pending/'
const dateStr = new Date().toISOString().split('T')[0].replace(/:/g, '-')
const auditFileName = `${dateStr}-${fileName.replace(/\.[^/.]+$/, '')}.md`
const auditFilePath = path.join(auditDir, auditFileName)
const content = `# 产品配置审核 - ${fileName}
**解析时间**: ${new Date().toLocaleString('zh-CN')}
---
## 📋 产品基本信息
| 字段 | 提取值 | 需要确认 |
|------|--------|---------|
| 产品名称 | ${config.name || '未提取'} | ✅ 请核对产品名称 |
| 产品类型 | ${config.type || '未提取'} | ✅ 请确认产品类型 |
| 币种 | ${config.currency || 'USD'} | ✅ 请确认币种 |
| form_sn | \`${config.form_sn || '未生成'}` | 请确认form_sn唯一性 |
---
## 📝 表单字段 (form_schema)
\`\`\`javascript
${code.form_schema || '// 请手动补充'}
\`\`\`
---
## 🔄 提交字段映射 (submit_mapping)
\`\`\`javascript
${code.submit_mapping || '// 请手动补充'}
\`\`\`
---
## ✅ 审核检查清单
- [ ] 产品名称正确
- [ ] 产品类型正确(savings/critical-illness/life-insurance)
- [ ] 币种正确(USD/CNY/HKD/EUR)
- [ ] form_sn 唯一且符合命名规范
- [ ] 缴费年期选项完整
- [ ] 年龄范围合理
- [ ] 提取计划配置(如适用)
- [ ] 表单字段定义完整
- [ ] 提交字段映射正确
---
## 📋 审核后操作
### 确认无误
\`\`\`bash
# 1. 移动配置到正式文件
mv docs/parse-audit/pending/${auditFileName} \\
src/config/plan-templates.backup.js
# 2. 合并到正式配置
# 手动复制或使用工具合并
# 3. 删除待审核文件
rm docs/parse-audit/pending/${auditFileName}
\`\`\`
### 需要修改
1. 编辑本文件修正内容
2. 重新提交审核
### 放弃本次解析
\`\`\`bash
rm docs/parse-audit/pending/${auditFileName}
\`\`\`
`
---
**生成工具**: Claude Code - parse-docs.js
`
return fs.writeFileSync(auditFilePath, content, 'utf-8')
}
```
---
## 实施计划
### 阶段1: 改进解析逻辑 (30分钟)
- [ ] 改进extractProductBasicInfo函数
- [ ] 添加文档标题提取
- [ ] 添加币种信息提取
- [ ] 添加产品类型推断
- [ ] 测试验证提取效果
### 阶段2: 实现审核文件生成 (20分钟)
- [ ] 实现generateAuditFile函数
- [ ] 创建待审核目录结构
- [ ] 测试生成markdown格式
- [ ] 添加文件路径返回
### 阶段3: 集成到主流程 (10分钟)
- [ ] 更新parse-docs.js主函数
- [ ] 添加成功提示和审核引导
- [ ] 错误处理和日志输出
### 阶段4: 测试验证 (10分钟)
- [ ] 使用实际文档测试
- [ ] 验证生成的审核文件格式
- [ ] 确认目录结构正确
---
## 预期成果
1. **更准确的信息提取**
- 产品基本信息提取率提升
- 减少人工补充工作
2. **结构化审核流程**
- 清晰的待审核markdown格式
- 明确的审核检查清单
- 简单的审核后操作指引
3. **更好的用户体验**
- 成功提示包含下一步操作
- 降低配置错误风险
---
## 风险评估
| 风险 | 影响 | 应对措施 |
|------|------|---------|
| 提取仍不准确 | 需要大量人工补充 | 提供清晰的标记和默认值 |
| 审核文件过多 | 难以管理 | 定期清理已审核文件 |
| 目录权限问题 | 无法写入文件 | 提前创建目录并检查权限 |
---
## 后续优化
1. **交互式审核**
- 提供命令行工具逐步引导填写缺失信息
- 支持编辑现有审核文件
2. **智能推断**
- 基于历史配置推断产品类型
- 从表格结构自动推断字段定义
3. **版本对比**
- 检测配置变更并生成差异报告
- 支持配置回滚
---
## 相关文档
- [mamoth使用文档](https://github.com/mwilliamtohman/mammoth)
- [计划书模板配置规范](../../src/config/CLAUDE.md)
- [代码注释规范](~/.claude/rules/code-commenting.md)
......@@ -22,6 +22,14 @@ import path from 'path'
import { PDFParse } from 'pdf-parse'
import mammoth from 'mammoth'
import Ajv from 'ajv'
import { spawn } from 'child_process'
import {
checkMarkitdownAvailable,
checkAIServiceConfigured,
printConfigStatus,
MARKITDOWN_CONFIG,
AI_SERVICE_CONFIG
} from './parse-config.js'
// ========== 配置区 ==========
......@@ -32,9 +40,6 @@ const BACKUP_DIR = path.resolve(process.cwd(), 'docs/parsed-backup')
// 支持的文档格式
const SUPPORTED_EXTENSIONS = ['.pdf', '.doc', '.docx', '.txt', '.md']
// AI 解析服务选择(通过 skill 调用)
const AI_SERVICE = 'openai' // 'openai' | 'anthropic' | 'openrouter'
const ajv = new Ajv({ allErrors: true, strict: false })
const parseConfigSchema = {
type: 'object',
......@@ -322,36 +327,181 @@ function formatSize(size) {
}
/**
* 调用 markitdown 服务解析文档
*
* @description 使用 markitdown CLI 将 PDF/DOCX 转换为 Markdown/文本
* @param {string} docPath - 文档路径
* @returns {Promise<{text: string, warnings: string[]}>} 解析结果
*/
async function parseDocumentWithMarkitdown(docPath) {
const ext = path.extname(docPath).toLowerCase()
// MD 和 TXT 文件直接读取,不需要 markitdown
if (ext === '.md' || ext === '.txt') {
console.log(`📄 直接读取文本文件: ${path.basename(docPath)}`)
return buildExtractResult(docPath, fs.readFileSync(docPath, 'utf-8'), [])
}
console.log(`\n📄 使用 markitdown 解析: ${path.basename(docPath)}`)
try {
if (MARKITDOWN_CONFIG.type === 'cli') {
// .docx 文件使用 mammoth 库(markitdown 兼容性问题)
if (ext === '.docx') {
console.log('⚠️ .docx 文件使用 mammoth 库解析(避免 markitdown 兼容性问题)')
return await extractTextFromDocx(docPath)
}
// 只对 PDF 使用 markitdown
if (ext === '.pdf') {
return await parseWithMarkitdownCLI(docPath)
} else {
console.log(`⚠️ 文件类型 ${ext} 不支持 markitdown,使用本地库解析`)
return await extractDocumentText(docPath)
}
}
// 其他类型暂未实现,fallback 到本地库
console.log('⚚️ markitdown 未启用,使用本地库解析')
return await extractDocumentText(docPath)
} catch (error) {
console.error(`❌ markitdown 解析失败 (${docPath}):`, error.message)
// fallback 到本地库
console.log('🔄 回退到本地库解析...')
return await extractDocumentText(docPath)
}
}
/**
* 使用 markitdown CLI 解析文档
*
* @description 使用 spawn 调用 markitdown CLI 工具(从 stdin 读取)
* @param {string} docPath - 文档路径
* @returns {Promise<{text: string, warnings: string[]}>} 解析结果
*/
async function parseWithMarkitdownCLI(docPath) {
const tmpDir = path.resolve(process.cwd(), 'docs/tmp')
ensureDir(tmpDir)
const outputPath = path.join(tmpDir, path.basename(docPath, path.extname(docPath)) + '.md')
const timeout = MARKITDOWN_CONFIG.cli.timeout || 30000
console.log(` 命令: cat "${docPath}" | markitdown > "${outputPath}"`)
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
spawn.kill(0, 'SIGTERM') // 尝试优雅终止
reject(new Error('markitdown 执行超时'))
}, timeout)
// 使用 cat 读取文件并通过管道传递给 markitdown
const cat = spawn('cat', [docPath])
const markitdown = spawn('markitdown', [], {
stdio: ['ignore', 'pipe', 'pipe']
})
let stdout = ''
let stderr = ''
markitdown.stdout.on('data', (data) => { stdout += data })
markitdown.stderr.on('data', (data) => { stderr += data })
markitdown.on('close', (code) => {
clearTimeout(timer)
if (code !== 0) {
reject(new Error(`markitdown 退出码: ${code}\n${stderr}`))
return
}
// 写入输出文件
try {
fs.writeFileSync(outputPath, stdout, 'utf-8')
console.log(`✅ markitdown 解析成功,提取 ${stdout.length} 字符`)
resolve({ text: stdout, warnings: [] })
} catch (writeError) {
reject(writeError)
}
})
markitdown.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
cat.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
// 将 cat 的输出连接到 markitdown 的输入
cat.stdout.pipe(markitdown.stdin)
})
}
/**
* AI 解析提示词模板
*
* @description 用于指导 AI 从文档内容中提取产品配置
*/
const AI_PARSE_PROMPT = `你是一个保险产品配置专家。请从以下文档内容中提取产品配置信息。
请按以下 JSON 格式返回配置:
{
"product_name": "产品名称",
"product_type": "产品类型 (savings/life-insurance/critical-illness)",
"currency": "币种 (USD/CNY/HKD)",
"payment_periods": ["缴费年期1", "缴费年期2"],
"age_range": { "min": 最小年龄, "max": 最大年龄 },
"insurance_period": "保险期间",
"is_savings": true/false (是否为储蓄型产品),
"withdrawal_modes": ["提取模式1", "提取模式2"],
"withdrawal_periods": ["提取期1", "提取期2"]
}
文档内容:
{CONTENT}
请只返回 JSON,不要包含其他内容。`
/**
* 调用 AI 服务解析文档
*
* 这里使用 skill 工具调用实际的 AI 解析服务
* 可以是:file-url-to-pdf + openai/anthropic skill
* @description 使用 markitdown + AI 智能解析文档并提取配置
* @param {string} docPath - 文档路径
* @returns {Promise<Object>} 解析后的配置对象
*/
async function parseDocumentWithAI(docPath) {
console.log(`\n🤖 正在解析: ${path.basename(docPath)}`)
console.log(`\n🤖 正在智能解析: ${path.basename(docPath)}`)
try {
const extract_result = await extractDocumentText(docPath)
// 步骤 1: 使用 markitdown 将文档转换为 Markdown/文本
const parse_result = await parseDocumentWithMarkitdown(docPath)
if (extract_result.warnings.length > 0) {
extract_result.warnings.forEach(message => {
console.log(`⚠️ 抽取警告: ${message}`)
if (parse_result.warnings.length > 0) {
parse_result.warnings.forEach(message => {
console.log(`⚠️ 解析警告: ${message}`)
})
}
if (!extract_result.text || !extract_result.text.trim()) {
console.error(`❌ 文本抽取失败 (${docPath})`)
if (!parse_result.text || !parse_result.text.trim()) {
console.error(`❌ 文档解析失败,文本为空 (${docPath})`)
return null
}
const content = extract_result.text
const content = parse_result.text
// 模拟解析:从文档内容中提取配置
// 实际使用时可以调用 AI 服务
const mockConfig = {
product_name: path.basename(docPath, path.extname(docPath)),
product_type: 'savings',
currency: 'USD',
// 步骤 2: 调用 AI 服务从文档内容中提取配置
// TODO(human): 集成 AI 服务(OpenAI/Anthropic)
// 需要配置 API Key 和服务端点
console.log('📝 AI 配置提取: 待集成 AI 服务')
// 临时方案:生成基础配置(基于文件名和内容启发式推断)
const fileName = path.basename(docPath, path.extname(docPath))
const config = {
product_name: fileName,
product_type: inferProductType(fileName, content),
currency: inferCurrency(content),
payment_periods: ['整付', '3年', '5年'],
age_range: { min: 0, max: 75 },
insurance_period: '终身',
......@@ -362,8 +512,10 @@ async function parseDocumentWithAI(docPath) {
submit_mapping: {}
}
console.log('✅ 解析成功')
return mockConfig
console.log('✅ 解析成功 (启发式推断)')
console.log(` 产品类型: ${config.product_type}`)
console.log(` 币种: ${config.currency}`)
return config
} catch (error) {
console.error(`❌ 解析失败 (${docPath}):`, error.message)
return null
......@@ -371,6 +523,64 @@ async function parseDocumentWithAI(docPath) {
}
/**
* 启发式推断产品类型
*
* @description 从文件名和内容推断产品类型
* @param {string} fileName - 文件名
* @param {string} content - 文档内容
* @returns {string} 产品类型
*/
function inferProductType(fileName, content) {
const lowerName = fileName.toLowerCase()
if (lowerName.includes('储蓄') || lowerName.includes('saving') || lowerName.includes('传承') || lowerName.includes('家传')) {
return 'savings'
}
if (lowerName.includes('重疾') || lowerName.includes('critical') || lowerName.includes('守护')) {
return 'critical-illness'
}
if (lowerName.includes('人寿') || lowerName.includes('life') || lowerName.includes('创富')) {
return 'life-insurance'
}
// 从内容中推断
const contentLower = content.toLowerCase()
if (contentLower.includes('储蓄') || contentLower.includes('红利') || contentLower.includes('提取')) {
return 'savings'
}
if (contentLower.includes('重疾') || contentLower.includes('早期严重疾病')) {
return 'critical-illness'
}
if (contentLower.includes('寿险') || contentLower.includes('身故保障')) {
return 'life-insurance'
}
// 默认为储蓄型
return 'savings'
}
/**
* 启发式推断币种
*
* @description 从文档内容推断币种
* @param {string} content - 文档内容
* @returns {string} 币种代码
*/
function inferCurrency(content) {
// 统计各种币种符号的出现次数
const usdCount = (content.match(/\$/g) || []).length
const cnyCount = (content.match(/¥|人民币/g) || []).length
const hkdCount = (content.match(/HK\$/g) || []).length
if (usdCount > cnyCount && usdCount > hkdCount) return 'USD'
if (hkdCount > usdCount && hkdCount > cnyCount) return 'HKD'
if (cnyCount > usdCount && cnyCount > hkdCount) return 'CNY'
// 默认美元
return 'USD'
}
/**
* 解析单个文档
*/
async function parseSingleFile(filePath) {
......@@ -405,7 +615,196 @@ async function parseSingleFile(filePath) {
console.log("\n📝 生成 form_sn: " + formSn)
console.log("📋 生成配置代码:\n" + code)
return { success: true, formSn, code, file: fileName, config }
// ✨ 新增:生成待审核文件(不直接写入正式配置)
const auditFile = await generateAuditFile(fileName, config, code)
if (auditFile) {
console.log("\n✅ 已生成待审核文件: " + auditFile)
console.log("📋 请审核后手动移动到 src/config/plan-templates.js")
return { success: true, formSn, code, file: fileName, config, auditFile }
}
return { success: true, formSn, code, file: fileName, config, auditFile }
}
/**
* 生成待审核文件
*
* @description 生成人类可读的 markdown 审核文件
* @param {string} fileName - 原始文件名
* @param {Object} config - 解析的配置对象
* @param {string} code - 生成的配置代码
* @returns {Promise<string|null>} 审核文件路径
*/
async function generateAuditFile(fileName, config, code) {
const AUDIT_DIR = path.resolve(process.cwd(), 'docs/parse-audit/pending')
ensureDir(AUDIT_DIR)
const date = new Date().toISOString().split('T')[0]
const auditFileName = `${date}-${fileName.replace(/\.[^/.]+$/, '')}.md`
const auditFilePath = path.join(AUDIT_DIR, auditFileName)
const content = `# ${config.product_name || fileName}
## 解析信息
- **原始文件**: ${fileName}
- **解析时间**: ${new Date().toLocaleString('zh-CN')}
- **数据来源**: docs/to-parse/${fileName}
---
## 配置预览
\`\`\`javascript
const config = \\${JSON.stringify(config, null, 2)}
\`\`
---
## 审核检查清单
- [ ] 产品名称是否正确
- [ ] 产品类型是否正确(${config.product_type || '未知'}
- [ ] 币种是否正确(${config.currency || '未知'}
- [ ] age_range 是否合理(${config.age_range?.min || 0} - ${config.age_range?.max || 75}岁)
- [ ] form_schema 字段是否完整
- [ ] submit_mapping 逻辑是否正确
---
## 下一步
1. 审核以上配置是否正确
2. 确核通过后,执行以下操作:
\`\`\`bash
# 1. 移动到 approved 目录
mv docs/parse-audit/pending/${auditFileName} docs/parse-audit/approved/
# 2. 手动添加配置到 src/config/plan-templates.js
# 3. 运行 pnpm lint 检
\`\`
---
## 审核状态
- [ ] 待审核
- [ ] 已通过
- [ ] 已拒绝
## 审核意见
<!-- 审核时请填写 -->
**生成时间**: ${new Date().toLocaleString('zh-CN')}
`
<!-- 审核通过后,请执行以下步骤:-->
1. 将此文件移动到 \`docs/parse-audit/approved/\`
2. 手动将配置添加到 \`src/config/plan-templates.js\`
3. 运行 \`pnpm lint\` �查
`
---
**注意**:
- 请仔细核对配置的每个字段
- 确认产品类型和币种是否符合业务需求
- �查 form_schema 中的字段定义是否完整
`
`
try {
fs.writeFileSync(auditFilePath, content, 'utf-8')
return auditFilePath
} catch (error) {
console.error(\`❌ 写入审核文件失败: \${error.message}`)
return null
}
}
/**
* 生成待审核文件
*
* @description 生成人类可读的 markdown 审核文件,保存到 docs/parse-audit/pending/
* @param {string} fileName - 原始文件名
* @param {Object} config - 解析的配置对象
* @param {string} code - 生成的配置代码
* @returns {Promise<string|null>} 审核文件路径
*/
async function generateAuditFile(fileName, config, code) {
const AUDIT_DIR = path.resolve(process.cwd(), 'docs/parse-audit/pending')
ensureDir(AUDIT_DIR)
const date = new Date().toISOString().split('T')[0]
const auditFileName = `${date}-${fileName.replace(/\.[^/.]+$/, '')}.md`
const auditFilePath = path.join(AUDIT_DIR, auditFileName)
const content = `# ${config.product_name || fileName}
## 解析信息
- **原始文件**: ${fileName}
- **解析时间**: ${new Date().toLocaleString('zh-CN')}
- **数据来源**: docs/to-parse/${fileName}
---
## 配置预览
\`\`\`javascript
{
"product_name": "${config.product_name || ''}",
"product_type": "${config.product_type || ''}",
"currency": "${config.currency || ''}",
${Object.entries(config).filter(([k]) =>
!['source_file', 'form_schema', 'submit_mapping'].includes(k)
).map(([k, v]) =>
` "${k}": ${JSON.stringify(v, null, 2)}`
).join(',\n ')}
\`\`\`
---
## 审核检查清单
- [ ] 产品名称是否正确
- [ ] 产品类型是否正确
- [ ] 币种是否正确
- [ ] age_range 是否合理
- [ ] payment_periods / withdrawal_periods 是否完整
- [ ] form_schema 字段是否完整
- [ ] 提取逻辑是否符合需求
---
## 审核状态
- [ ] 待审核
- [ ] 已通过
- [ ] 已拒绝
## 审核意见
\`\`\`
<!-- 审核通过后,请执行以下步骤:
1. 将此文件移动到 docs/parse-audit/approved/
2. 手动将配置添加到 src/config/plan-templates.js
3. 提交变更
-->
try {
fs.writeFileSync(auditFilePath, content, 'utf-8')
return auditFilePath
} catch (error) {
console.error(` 写入审核文件失败: ${error.message}`)
return null
}
}
/**
......@@ -877,11 +1276,24 @@ async function main() {
const fileMode = args.find(arg => arg.startsWith('--file='))
const dryRunMode = args.includes('--dry-run')
const rollbackMode = args.find(arg => arg.startsWith('--rollback='))
const statusMode = args.includes('--status')
console.log('\n🚀 文档解析工具')
// 检查解析器选择
const parserModeArg = args.find(arg => arg.startsWith('--parser='))
const parserMode = parserModeArg ? parserModeArg.split('=')[1].toLowerCase() : 'mammoth'
console.log('\n🚀 文档解析工具 v2.0')
console.log(" 文档目录: " + DOCS_DIR)
console.log(" 配置文件: " + CONFIG_FILE)
// 显示配置状态
printConfigStatus()
if (statusMode) {
// 只显示状态,不执行解析
return
}
if (rollbackMode) {
const backupFile = rollbackMode.split('=')[1]
rollbackConfigFile(backupFile)
......@@ -899,11 +1311,17 @@ async function main() {
} else if (fileMode) {
// 单文件模式
const fileName = fileMode.split('=')[1]
const targetDoc = docs.find(d => d.name === fileName || d.name.includes(fileName))
// 更宽松的匹配:支持模糊匹配(移除特殊字符后比较)
const normalize = (str) => str.toLowerCase().replace(/[\s\-_版]/g, '')
const normalizedFileName = normalize(fileName)
const targetDoc = docs.find(d => {
const normalizedName = normalize(d.name)
return normalizedName === normalizedFileName || normalizedName.includes(normalizedFileName)
})
if (targetDoc) {
const start_time = Date.now()
const result = await parseSingleFile(targetDoc.fullPath)
const result = await parseSingleFile(targetDoc.fullPath, parserMode)
const summary = buildParseSummary([result], Date.now() - start_time)
console.log("\n📊 解析结果汇总")
console.log("总计: " + summary.total + " 个文档")
......