Files
jjx-web/uniCloud-tcb/cloudfunctions/jjxReviewTimer/local-test.js
T
ngele 95024bb2a6 feat: AI审查报告支持下载Word + 我的报告支持重新审查
- 云函数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文件
2026-10-23 19:07:05 +08:00

337 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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('════════════════════════════════════════');
})();