#!/usr/bin/env node /** * 本地自测脚本:智能体返回的 HTML 审查报告 → Word 文档 * 纯本地运行,不依赖 uniCloud,不联网,不动数据库。 * * 用法(在本目录下执行): * node local-test.js # 用内置样例跑一遍,验证环境 * node local-test.js report.html # 转换指定 HTML(输出到同目录) * node local-test.js report.html out.docx # 指定输出路径 * node local-test.js dir/ # 批量转换目录下所有 .html/.txt * node local-test.js report.html --json # 只打印解析结果,不生成 Word * node local-test.js report.html --open # 生成后自动用 Word 打开 */ const fs = require('fs'); const path = require('path'); const os = require('os'); const { exec } = require('child_process'); const { buildDocx, parseHtmlReport, extractDocxLink, isDocxConvertible } = require('./buildDocx.js'); // ==================== 内置样例(覆盖全部区块,用于环境自检) ==================== const SAMPLE_HTML = ` 样例合同审查报告

技术开发与服务合同审查报告

合同名称:金久信钢城平台AI功能接入开发及运维服务合同

审核日期:2026年10月23日

0
合规
C
部分合规
2
高风险
1
中风险

审核结论

本合同核心架构完整,但存在多处核心条款空白,整体风险等级为中高风险,需补充完善后方可签署。

需处理问题

🔴 高风险问题

H条款2.3 交付清单、全部附件空白
合同第2.3条交付清单表格完全空白,未明确交付物名称与交付时间,极易引发履约争议。
修复建议:详细填写交付清单,明确每项交付物的名称、数量、交付时间。
H合同首部多项核心字段空白
合同名称、签订地点、合同金额等核心字段均为空白,属于典型的空白合同。
修复建议:将所有空白字段填写完整,无需填写的标注"无"。

🟠 中风险问题

M条款9 违约责任约定过于笼统
违约责任仅约定逾期违约金,未约定交付不合格、侵犯知识产权等情形。
修复建议:补充交付不合格、侵权、擅自转包的违约责任。

优化建议汇总表

优先级条款编号问题描述建议方案
H2.3交付清单空白详细填写交付清单
H合同首部核心字段空白填写所有空白字段
M9违约责任笼统补充违约情形

补充条款模板

转包限制条款

未经甲方书面同意,乙方不得将本合同项下的任何工作转包或分包给第三方,否则视为乙方根本违约。

法律依据

相关司法案例

序号案件名称·案由资料链接法院·机构裁判年份判决要点关联风险点
1空白合同效力认定纠纷 打开裁判页面 ↗ 最高人民法院2019年交付空白合同视为概括授权空白合同字段风险
2技术开发合同交付约定不明纠纷 打开裁判页面 ↗ 某中级人民法院2021年约定不明按交易习惯确定交付方式不明风险
本报告仅基于您提供的合同文本出具,不构成任何法律意见。
`; // ==================== 用例:智能体返回「文件读不了」的道歉文本 ==================== // 这种回复里没有报告,线上应当跳过 Word 生成,而不是产出一份空文档 const APOLOGY_TEXT = '很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败,暂时无法对该文件开展审查工作。建议您选择以下任一方式解决: 1. 将该DOC文件转换为DOCX、PDF、TXT等兼容性更好的格式后重新上传; 2. 直接粘贴合同的完整文本内容发送给我,我会立即为您生成专业的合同审查报告。'; // ==================== 工具函数 ==================== /** 清洗智能体回复:去掉 markdown 包裹、前后说明文字,只留 HTML 本体 */ function cleanAnswer(text) { let s = String(text || ''); const fence = s.match(/```(?:html)?\s*([\s\S]*?)```/i); if (fence && fence[1] && /<(!DOCTYPE|html)/i.test(fence[1])) s = fence[1]; const start = s.search(/]/i); if (start > 0) s = s.slice(start); const end = s.toLowerCase().lastIndexOf(''); if (end > 0) s = s.slice(0, end + 7); return s.trim(); } function pad(s, n) { const str = String(s); // 中文按 2 宽度算 let w = 0; for (const ch of str) w += /[\u4e00-\u9fa5\uff00-\uffef]/.test(ch) ? 2 : 1; return str + ' '.repeat(Math.max(0, n - w)); } function line(label, value) { console.log(' ' + pad(label, 14) + ': ' + value); } /** 打印解析结果摘要 */ function summarize(data) { if (!data) { console.log(' ✗ 解析失败:未提取到任何有效内容(结论/风险/建议全空)'); return false; } const s = data.summary || {}; line('报告标题', data.report_title || '(空)'); line('合同名称', data.contract_name || '(空)'); line('审核日期', data.review_date || '(空)'); line('统计卡片', `合规 ${s.compliant || 0} / 部分合规 ${s.partial || 0} / 高风险 ${s.high || 0} / 中风险 ${s.medium || 0}`); line('风险项', `高 ${(data.high_risks || []).length} / 中 ${(data.medium_risks || []).length} / 低 ${(data.low_risks || []).length}`); line('优化建议', (data.suggestions || []).length + ' 条'); line('补充条款', (data.clause_templates || []).length + ' 条'); line('法律依据', (data.legal_bases || []).length + ' 条'); line('案例', (data.cases || []).length + ' 个'); line('免责声明', data.disclaimer ? `已提取(${data.disclaimer.length} 字)` : '(空)'); const cases = data.cases || []; if (cases.length) { console.log(' --- 案例链接核对 ---'); cases.forEach((c, i) => { const url = c.url ? c.url : '(无链接)'; console.log(` ${i + 1}. ${c.name || '-'}`); console.log(` 法院/年份: ${c.court || '-'} / ${c.year || '-'}`); console.log(` 链接: ${url}`); }); } const risks = [...(data.high_risks || []), ...(data.medium_risks || []), ...(data.low_risks || [])]; if (risks.length) { console.log(' --- 风险条款号 ---'); risks.forEach((r, i) => { console.log(` ${i + 1}. [${r.level || '-'}] ${r.clause || '-'} — ${(r.issue || '').slice(0, 40)}`); }); } return true; } /** 转换单个 HTML 文件/字符串 */ async function convert(label, html, outPath) { console.log(''); console.log('════════════════════════════════════════'); console.log('▶ ' + label); console.log('════════════════════════════════════════'); const cleaned = cleanAnswer(html); if (!cleaned) { console.log(' ✗ 内容为空'); return 'fail'; } // 通道判断(与线上逻辑一致) const pluginLink = extractDocxLink(cleaned); if (pluginLink) { console.log(' ⚑ 检测到智能体插件返回的 Word 链接:'); console.log(' ' + pluginLink); console.log(' (线上会直接使用该链接,不本地生成)'); if (!/]/i.test(cleaned)) { console.log(' ⊘ 回复里没有 HTML 报告,线上直接复用插件链接,不做本地转换'); return 'skip'; } } console.log(' 原文长度: ' + cleaned.length + ' 字符'); // 关键拦截:智能体有时不返回报告,只回一段道歉/澄清文字 // (例如「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」) // 线上此时不生成 Word,这里保持一致,避免产出一份残缺文档。 if (!isDocxConvertible(cleaned)) { console.log(' ⊘ 这不是一份完整报告,线上会跳过 Word 生成(不产出空文档)'); console.log(' 开头内容:' + cleaned.slice(0, 70).replace(/\s+/g, ' ') + '…'); return 'skip'; } const data = parseHtmlReport(cleaned); const ok = summarize(data); if (!ok) return 'fail'; if (FLAGS.jsonOnly) { console.log(' (--json 模式,未生成 Word)'); return 'skip'; } const t0 = Date.now(); const buffer = await buildDocx({ data: data, fileName: data.contract_name || '合同审查报告' }); const ms = Date.now() - t0; fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, buffer); console.log(' ✓ Word 已生成'); line('输出文件', outPath); line('文件大小', (buffer.length / 1024).toFixed(1) + ' KB'); line('耗时', ms + ' ms'); if (FLAGS.open) { exec(`start "" "${outPath}"`, { shell: 'cmd.exe' }); console.log(' → 已调用系统默认程序打开'); } return 'done'; } // ==================== 主流程 ==================== const argv = process.argv.slice(2); const FLAGS = { jsonOnly: argv.includes('--json'), open: argv.includes('--open'), help: argv.includes('-h') || argv.includes('--help') }; const positional = argv.filter((a) => !a.startsWith('-')); (async function main() { if (FLAGS.help) { console.log(` 本地自测:HTML 审查报告 → Word node local-test.js 用内置样例自检 node local-test.js report.html 转换指定文件(输出到同目录) node local-test.js report.html out.docx 指定输出路径 node local-test.js someDir/ 批量转换目录下 .html / .txt node local-test.js report.html --json 只看解析结果 node local-test.js report.html --open 生成后自动打开 `); return; } let ok = 0; let skip = 0; let fail = 0; const tally = (r) => { if (r === 'done') ok++; else if (r === 'skip') skip++; else fail++; }; try { if (!positional.length) { // 三个内置用例,覆盖「能转」和「不该转」两类情况 tally(await convert( '用例1:正常 HTML 报告(应生成 Word)', SAMPLE_HTML, path.join(os.tmpdir(), 'jjx_本地自测_审查报告.docx') )); tally(await convert( '用例2:智能体只回「文件解析失败」道歉文本(应跳过)', APOLOGY_TEXT, path.join(os.tmpdir(), 'jjx_用例2_不应生成.docx') )); tally(await convert( '用例3:HTML 被截断、缺 (应跳过,不产出残缺文档)', SAMPLE_HTML.slice(0, 1800), path.join(os.tmpdir(), 'jjx_用例3_不应生成.docx') )); } else { const target = positional[0]; const stat = fs.statSync(target); if (stat.isDirectory()) { const files = fs.readdirSync(target) .filter((f) => /\.(html?|txt)$/i.test(f)) .map((f) => path.join(target, f)); if (!files.length) { console.log('目录下没有 .html / .txt 文件:' + target); return; } console.log(`批量转换:共 ${files.length} 个文件`); for (const f of files) { const out = path.join(target, path.basename(f).replace(/\.[^.]+$/, '') + '_审查报告.docx'); try { tally(await convert(path.basename(f), fs.readFileSync(f, 'utf8'), out)); } catch (e) { console.log(' ✗ 异常:' + e.message); fail++; } } } else { const out = positional[1] || path.join( path.dirname(target), path.basename(target).replace(/\.[^.]+$/, '') + '_审查报告.docx' ); tally(await convert(path.basename(target), fs.readFileSync(target, 'utf8'), out)); } } } catch (e) { console.log(''); console.log('✗ 运行失败:' + e.message); if (e.code === 'ENOENT') console.log(' 提示:文件路径不存在,注意用引号包裹含空格的路径'); if (e.code === 'MODULE_NOT_FOUND') console.log(' 提示:先执行 npm install 安装 docx 依赖'); process.exitCode = 1; return; } console.log(''); console.log('════════════════════════════════════════'); console.log(`完成:生成 ${ok} 个,跳过 ${skip} 个,失败 ${fail} 个`); if (fail > 0) console.log('提示:解析失败通常是 HTML 结构与预期不符,把该文件发我看看。'); console.log('════════════════════════════════════════'); })();