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文件
This commit is contained in:
2026-10-23 19:07:05 +08:00
parent d0fb8802e7
commit 95024bb2a6
9 changed files with 2341 additions and 4 deletions
@@ -0,0 +1,336 @@
#!/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('════════════════════════════════════════');
})();