95024bb2a6
- 云函数jjxReviewTimer完成审查后自动将HTML报告转换为Word文档并上传 - 新增buildDocx.js纯正则解析HTML报告生成A4公文版式docx(三级数据提取兼容结构漂移) - 兼容智能体插件直接返回docx链接(jjx-docx-link标记协议) - router/jjxDoReview同步支持Word生成(try降级不影响主流程) - 前端报告列表新增下载Word/重新审查按钮 - 修复上传/查询取真实uid(vk.getVuex而非页面本地data) - login.js清理调试日志、.gitignore忽略node_modules与lock文件
337 lines
15 KiB
JavaScript
337 lines
15 KiB
JavaScript
#!/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 = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="UTF-8"><title>样例合同审查报告</title></head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>技术开发与服务合同审查报告</h1>
|
||
<p>合同名称:金久信钢城平台AI功能接入开发及运维服务合同</p>
|
||
<p>审核日期:2026年10月23日</p>
|
||
</div>
|
||
<div class="card-wrap">
|
||
<div class="stat-card green"><div class="num">0</div><div class="label">合规</div></div>
|
||
<div class="stat-card orange"><div class="num">C</div><div class="label">部分合规</div></div>
|
||
<div class="stat-card red"><div class="num">2</div><div class="label">高风险</div></div>
|
||
<div class="stat-card yellow"><div class="num">1</div><div class="label">中风险</div></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>审核结论</h2>
|
||
<p>本合同核心架构完整,但存在多处核心条款空白,整体风险等级为中高风险,需补充完善后方可签署。</p>
|
||
</div>
|
||
<div class="section">
|
||
<h2>需处理问题</h2>
|
||
<h3>🔴 高风险问题</h3>
|
||
<div class="risk-item high">
|
||
<div class="risk-clause"><span class="risk-tag high">H</span>条款2.3 交付清单、全部附件空白</div>
|
||
<div class="risk-desc">合同第2.3条交付清单表格完全空白,未明确交付物名称与交付时间,极易引发履约争议。</div>
|
||
<div class="risk-fix">修复建议:详细填写交付清单,明确每项交付物的名称、数量、交付时间。</div>
|
||
</div>
|
||
<div class="risk-item high">
|
||
<div class="risk-clause"><span class="risk-tag high">H</span>合同首部多项核心字段空白</div>
|
||
<div class="risk-desc">合同名称、签订地点、合同金额等核心字段均为空白,属于典型的空白合同。</div>
|
||
<div class="risk-fix">修复建议:将所有空白字段填写完整,无需填写的标注"无"。</div>
|
||
</div>
|
||
<h3>🟠 中风险问题</h3>
|
||
<div class="risk-item medium">
|
||
<div class="risk-clause"><span class="risk-tag medium">M</span>条款9 违约责任约定过于笼统</div>
|
||
<div class="risk-desc">违约责任仅约定逾期违约金,未约定交付不合格、侵犯知识产权等情形。</div>
|
||
<div class="risk-fix">修复建议:补充交付不合格、侵权、擅自转包的违约责任。</div>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>优化建议汇总表</h2>
|
||
<table>
|
||
<thead><tr><th>优先级</th><th>条款编号</th><th>问题描述</th><th>建议方案</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>H</td><td>2.3</td><td>交付清单空白</td><td>详细填写交付清单</td></tr>
|
||
<tr><td>H</td><td>合同首部</td><td>核心字段空白</td><td>填写所有空白字段</td></tr>
|
||
<tr><td>M</td><td>9</td><td>违约责任笼统</td><td>补充违约情形</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="section">
|
||
<h2>补充条款模板</h2>
|
||
<div class="clause-template"><h3>转包限制条款</h3>
|
||
<pre>未经甲方书面同意,乙方不得将本合同项下的任何工作转包或分包给第三方,否则视为乙方根本违约。</pre></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>法律依据</h2>
|
||
<ul>
|
||
<li>《民法典》第510条:合同生效后,当事人就质量、价款或者报酬等内容没有约定或者约定不明确的,可以协议补充。</li>
|
||
<li>《民法典》第577条:当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担违约责任。</li>
|
||
</ul>
|
||
</div>
|
||
<div class="section">
|
||
<h2>相关司法案例</h2>
|
||
<table>
|
||
<thead><tr><th>序号</th><th>案件名称·案由</th><th>资料链接</th><th>法院·机构</th><th>裁判年份</th><th>判决要点</th><th>关联风险点</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>1</td><td>空白合同效力认定纠纷</td>
|
||
<td><a class="case-link" href="https://example.com/case-1" target="_blank">打开裁判页面 ↗</a></td>
|
||
<td>最高人民法院</td><td>2019年</td><td>交付空白合同视为概括授权</td><td>空白合同字段风险</td></tr>
|
||
<tr><td>2</td><td>技术开发合同交付约定不明纠纷</td>
|
||
<td><a class="case-link" href="https://example.com/case-2" target="_blank">打开裁判页面 ↗</a></td>
|
||
<td>某中级人民法院</td><td>2021年</td><td>约定不明按交易习惯确定</td><td>交付方式不明风险</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="disclaimer">本报告仅基于您提供的合同文本出具,不构成任何法律意见。</div>
|
||
<div class="footer">金久信签约宝 · AI合同审查服务 © 2026 | 生成时间:2026年10月23日</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
// ==================== 用例:智能体返回「文件读不了」的道歉文本 ====================
|
||
// 这种回复里没有报告,线上应当跳过 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(/<!DOCTYPE\s+html|<html[\s>]/i);
|
||
if (start > 0) s = s.slice(start);
|
||
const end = s.toLowerCase().lastIndexOf('</html>');
|
||
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 (!/<!DOCTYPE\s+html|<html[\s>]/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 被截断、缺 </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('════════════════════════════════════════');
|
||
})();
|