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
+2
View File
@@ -4,5 +4,7 @@
/uni_modules/ /uni_modules/
/.idea /.idea
/uniCloud-aliyun/cloudfunctions/router/node_modules/ /uniCloud-aliyun/cloudfunctions/router/node_modules/
/uniCloud-tcb/cloudfunctions/**/node_modules/
/uniCloud-tcb/cloudfunctions/**/package-lock.json
/pnpm-lock.yaml /pnpm-lock.yaml
/金久信源码/ /金久信源码/
+57 -2
View File
@@ -633,8 +633,10 @@
<el-tag v-else-if="r.status === 'completed'" size="mini" type="success">已完成</el-tag> <el-tag v-else-if="r.status === 'completed'" size="mini" type="success">已完成</el-tag>
<el-tag v-else-if="r.status === 'failed'" size="mini" type="danger">失败</el-tag> <el-tag v-else-if="r.status === 'failed'" size="mini" type="danger">失败</el-tag>
<el-button v-if="r.htmlUrl" size="mini" type="text" @click="openUrl(r.htmlUrl)">报告下载</el-button> <el-button v-if="r.htmlUrl" size="mini" type="text" @click="openUrl(r.htmlUrl)">报告下载</el-button>
<el-button v-if="r.docxUrl" size="mini" type="text" @click="openUrl(r.docxUrl)">下载 Word</el-button>
<el-button v-if="r.fileUrl" size="mini" type="text" @click="previewFile(r)">查看上传文件</el-button> <el-button v-if="r.fileUrl" size="mini" type="text" @click="previewFile(r)">查看上传文件</el-button>
<el-button v-if="r.status === 'completed' && r.result" size="mini" type="text" @click="viewReport(r)">在线查看报告</el-button> <el-button v-if="r.status === 'completed' && r.result" size="mini" type="text" @click="viewReport(r)">在线查看报告</el-button>
<el-button v-if="r.status === 'uploaded'" size="mini" type="primary" plain :loading="r._submitting" @click="reReview(r)">重新审查</el-button>
</view> </view>
</view> </view>
@@ -1652,14 +1654,16 @@ export default {
try { try {
const res = await vk.callFunction({ const res = await vk.callFunction({
url: 'client/ai/pub/getMyReports', url: 'client/ai/pub/getMyReports',
data: { pageIndex: 1, pageSize: 50 } data: { pageIndex: 1, pageSize: 50, uid: vk.getVuex('$user.userInfo._id') || '' }
}); });
if (res.code === 0 && res.data && res.data.rows) { if (res.code === 0 && res.data && res.data.rows) {
this.reportList = res.data.rows.map(r => ({ this.reportList = res.data.rows.map(r => ({
fileName: r.file_name, fileName: r.file_name,
fileSize: r.file_size, fileSize: r.file_size,
fileId: r.file_id || '',
fileUrl: r.file_url || '', fileUrl: r.file_url || '',
htmlUrl: r.html_url || '', htmlUrl: r.html_url || '',
docxUrl: r.docx_url || '',
time: dayjs(r._add_time && r._add_time['$date'] ? r._add_time['$date'] : r._add_time).format('YYYY-MM-DD HH:mm:ss'), time: dayjs(r._add_time && r._add_time['$date'] ? r._add_time['$date'] : r._add_time).format('YYYY-MM-DD HH:mm:ss'),
amount: r.points_used || 0, amount: r.points_used || 0,
result: r.result, result: r.result,
@@ -1678,6 +1682,56 @@ export default {
this.reviewResult = r.result || '暂无审查结果'; this.reviewResult = r.result || '暂无审查结果';
this.reviewFile = { name: r.fileName, size: r.fileSize || 0 }; this.reviewFile = { name: r.fileName, size: r.fileSize || 0 };
}, },
// 待审查记录 → 重新发起审查
async reReview(r) {
if (!r.fileId) {
this.$message.warning('该记录缺少文件信息,请重新上传合同');
return;
}
try {
await this.$confirm(
`确认对「${r.fileName}」发起审查?审查完成后将按文件大小扣除相应积分。`,
'重新审查',
{ confirmButtonText: '确认审查', cancelButtonText: '取消', type: 'warning' }
);
} catch (e) {
return;
}
// 积分校验
try {
const balanceRes = await vk.callFunction({ url: 'client/points/kh/getBalance' });
if (balanceRes.code !== 0 || !balanceRes.data || balanceRes.data.balance < 200) {
this.$message.warning('积分不足,请先充值');
return;
}
} catch (e) {
this.$message.error('积分查询失败,请稍后重试');
return;
}
this.$set(r, '_submitting', true);
try {
const res = await vk.callFunction({
url: 'client/ai/pub/review',
data: {
record_id: r.recordId,
file_id: r.fileId,
file_name: r.fileName,
file_size: r.fileSize || 0,
uid: vk.getVuex('$user.userInfo._id') || ''
}
});
if (res.code === 0) {
r.status = (res.data && res.data.status) || 'reviewing';
this.$message.success('已提交审查,AI 正在分析中,预计 3-5 分钟完成');
setTimeout(() => this.queryReports(), 1500);
} else {
this.$message.error(res.msg || '提交审查失败');
}
} catch (e) {
this.$message.error('提交审查异常');
}
this.$set(r, '_submitting', false);
},
previewFile(r) { previewFile(r) {
if (!r.fileUrl) { if (!r.fileUrl) {
this.$message.warning('文件链接不存在'); this.$message.warning('文件链接不存在');
@@ -1723,6 +1777,7 @@ export default {
file_base64: fileBase64, file_base64: fileBase64,
file_name: file.name, file_name: file.name,
file_size: file.size, file_size: file.size,
uid: vk.getVuex('$user.userInfo._id') || '',
upload_only: true upload_only: true
} }
}); });
@@ -1769,7 +1824,7 @@ export default {
file_id: this.reviewFileId, file_id: this.reviewFileId,
file_name: this.reviewFile.name, file_name: this.reviewFile.name,
file_size: this.reviewFile.size, file_size: this.reviewFile.size,
uid: this.userInfo.uid uid: vk.getVuex('$user.userInfo._id') || ''
} }
}); });
@@ -0,0 +1,922 @@
'use strict';
/**
* 合同审查报告 → Word(.docx) 生成器
*
* 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON
* <script type="application/json" id="jjx-report-data">{...}</script>
*
* 排版目标:正式法律文书风格,A4 纵向,可直接打印装订
*/
const {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
AlignmentType,
BorderStyle,
ShadingType,
TableLayoutType,
Footer,
Header,
PageNumber,
VerticalAlign,
ExternalHyperlink
} = require('docx');
const FONT = 'Microsoft YaHei';
const FONT_SONG = 'SimSun';
const C = {
primary: '1A5276',
danger: 'E74C3C',
success: '27AE60',
warning: 'F39C12',
text: '222222',
gray: '777777',
lightBg: 'F5F7F9',
pinkBg: 'FDF2F2',
border: 'D0D7DE'
};
const PAGE_BORDER = {
top: { style: BorderStyle.SINGLE, size: 4, color: C.border },
bottom: { style: BorderStyle.SINGLE, size: 4, color: C.border },
left: { style: BorderStyle.SINGLE, size: 4, color: C.border },
right: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: C.border }
};
// ---------- 基础元件 ----------
function run(text, o) {
o = o || {};
const opt = {
text: String(text === null || text === undefined ? '' : text),
font: o.font || FONT,
size: o.size || 21,
bold: !!o.bold,
italics: !!o.italics,
color: o.color || C.text
};
if (o.underline) opt.underline = {};
return new TextRun(opt);
}
function para(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
spacing: {
before: o.before === undefined ? 60 : o.before,
after: o.after === undefined ? 60 : o.after,
line: o.line || 320
}
};
if (o.alignment) opt.alignment = o.alignment;
if (o.indent) opt.indent = o.indent;
if (o.border) opt.border = o.border;
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
return new Paragraph(opt);
}
function cell(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
verticalAlign: VerticalAlign.CENTER,
margins: { top: 60, bottom: 60, left: 110, right: 110 }
};
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
if (o.width) opt.width = { size: o.width, type: WidthType.PERCENTAGE };
if (o.columnSpan) opt.columnSpan = o.columnSpan;
return new TableCell(opt);
}
function table(rows, widths) {
return new Table({
rows: rows,
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: widths,
layout: TableLayoutType.AUTOFIT,
borders: PAGE_BORDER
});
}
function sectionTitle(no, text) {
return para([run(no + '、' + text, { size: 28, bold: true, color: C.primary })], {
before: 320,
after: 140,
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: C.primary, space: 4 } }
});
}
function pick(obj, keys, dft) {
if (!obj) return dft;
for (let i = 0; i < keys.length; i++) {
const v = obj[keys[i]];
if (v !== undefined && v !== null && v !== '') return v;
}
return dft;
}
function asArray(v) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
// ---------- 风险条目 ----------
function riskBlock(item, color, levelText) {
const clause = pick(item, ['clause', 'no', '条款', 'clauseNo'], '');
const issue = pick(item, ['issue', 'risk', 'problem', '描述', '问题'], '');
const suggest = pick(item, ['suggestion', 'fix', 'advice', '建议', '修改建议'], '');
const basis = pick(item, ['legal_basis', 'legalBasis', 'basis', '法律依据'], '');
const blocks = [];
blocks.push(para([
run('[' + levelText + ']', { bold: true, color: color, size: 21 }),
run(clause ? ' 第 ' + clause + ' 条' : ' 合同条款', { bold: true, color: color, size: 21 })
], { before: 140, after: 40 }));
blocks.push(para([
run('问题:', { bold: true, size: 21 }),
run(issue, { size: 21 })
], { after: 40, indent: { left: 240 } }));
if (suggest) {
blocks.push(para([
run('建议:', { bold: true, size: 21 }),
run(suggest, { size: 21 })
], { after: 40, indent: { left: 240 } }));
}
if (basis) {
blocks.push(para([
run('依据:', { bold: true, size: 21 }),
run(basis, { size: 21, color: C.gray })
], { after: 80, indent: { left: 240 } }));
}
return blocks;
}
function riskSection(items, color, levelText, emptyText) {
const list = asArray(items);
if (!list.length) {
return [para([run(emptyText || '无', { size: 21, color: C.gray })], { after: 80 })];
}
let out = [];
list.forEach(function (it) {
out = out.concat(riskBlock(it, color, levelText));
});
return out;
}
// ---------- 主函数 ----------
async function buildDocx(input) {
const data = input.data || {};
const fileName = input.fileName || '合同审查报告';
const contractName = pick(data, ['contract_name', 'contractName', '协议名称', 'name'], fileName.replace(/\.[^.]+$/, ''));
const reportTitle = pick(data, ['report_title', 'reportTitle', '报告标题'], '合同审查报告');
const reviewDate = pick(data, ['review_date', 'reviewDate', '审核日期'], new Date().toLocaleDateString('zh-CN'));
const summary = pick(data, ['summary', '统计'], {}) || {};
const conclusion = pick(data, ['conclusion', '审核结论', 'summary_text'], '');
const highRisks = pick(data, ['high_risks', 'highRisks', '高风险'], []);
const mediumRisks = pick(data, ['medium_risks', 'mediumRisks', '中风险'], []);
const lowRisks = pick(data, ['low_risks', 'lowRisks', '低风险'], []);
const suggestions = pick(data, ['suggestions', '优化建议', 'advice_table'], []);
const templates = pick(data, ['clause_templates', 'clauseTemplates', '补充条款'], []);
const legalBases = pick(data, ['legal_bases', 'legalBases', '法律依据'], []);
const cases = pick(data, ['cases', '案例'], []);
const disclaimer = pick(data, ['disclaimer', '免责声明'], '');
const nCompliant = pick(summary, ['compliant', '合规'], 0);
const nPartial = pick(summary, ['partial', '部分合规'], 0);
const nHigh = pick(summary, ['high', '高风险'], asArray(highRisks).length);
const nMedium = pick(summary, ['medium', '中风险'], asArray(mediumRisks).length);
const children = [];
// ===== 抬头 =====
children.push(para([run(reportTitle, { size: 44, bold: true, color: C.primary })], {
alignment: AlignmentType.CENTER,
before: 200,
after: 120
}));
children.push(para([run(contractName, { size: 26, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 60
}));
children.push(para([run('审核日期:' + reviewDate + ' 出具机构:金久信签约宝', { size: 20, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 240
}));
// ===== 目录 =====
children.push(sectionTitle('一', '报告目录'));
const tocItems = [
'二、审核结论',
'三、需处理问题(高风险 / 中风险)',
'四、优化建议汇总表',
'五、补充条款模板',
'六、法律依据',
'七、相关案例',
'八、免责声明'
];
tocItems.forEach(function (item) {
children.push(para([run(item, { size: 21 })], { after: 30, indent: { left: 240 } }));
});
// ===== 审核结论 =====
children.push(sectionTitle('二', '审核结论'));
const statRow = new TableRow({
children: [
cell([para([run(String(nCompliant), { size: 40, bold: true, color: C.success })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('合规条款', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'F0F9EB', width: 25 }),
cell([para([run(String(nPartial), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('部分合规', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 }),
cell([para([run(String(nHigh), { size: 40, bold: true, color: C.danger })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('高风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FEF0F0', width: 25 }),
cell([para([run(String(nMedium), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('中风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 })
]
});
children.push(table([statRow], [2000, 2000, 2000, 2000]));
if (conclusion) {
children.push(para([run('审核结论', { size: 21, bold: true, color: C.primary })], { before: 200, after: 80 }));
String(conclusion).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 21 })], { after: 60, indent: { left: 240 } }));
}
});
}
// ===== 需处理问题 =====
children.push(sectionTitle('三', '需处理问题'));
children.push(para([run('(一)高风险问题', { size: 24, bold: true, color: C.danger })], { before: 140, after: 80 }));
riskSection(highRisks, C.danger, '高风险', '本次审查未发现高风险问题。').forEach(function (p) { children.push(p); });
children.push(para([run('(二)中风险问题', { size: 24, bold: true, color: C.warning })], { before: 200, after: 80 }));
riskSection(mediumRisks, C.warning, '中风险', '本次审查未发现中风险问题。').forEach(function (p) { children.push(p); });
if (asArray(lowRisks).length) {
children.push(para([run('(三)低风险问题', { size: 24, bold: true, color: C.gray })], { before: 200, after: 80 }));
riskSection(lowRisks, C.gray, '低风险', '无').forEach(function (p) { children.push(p); });
}
// ===== 优化建议汇总表 =====
children.push(sectionTitle('四', '优化建议汇总表'));
const sugList = asArray(suggestions);
if (!sugList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
const headRow = new TableRow({
tableHeader: true,
children: [
cell([para([run('优先级', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 12 }),
cell([para([run('条款编号', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 15 }),
cell([para([run('问题描述', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 38 }),
cell([para([run('建议方案', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 35 })
]
});
const rows = [headRow];
sugList.forEach(function (s, i) {
const lv = String(pick(s, ['priority', 'level', '优先级'], 'M')).toUpperCase();
const lvBg = lv === 'H' ? C.danger : (lv === 'L' ? '999999' : C.warning);
rows.push(new TableRow({
children: [
cell([para([run(lv, { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: lvBg, width: 12 }),
cell([para([run(pick(s, ['clause', 'no', '条款编号'], '-'), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 15 }),
cell([para([run(pick(s, ['issue', 'problem', '问题描述', '描述'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 38 }),
cell([para([run(pick(s, ['solution', 'suggestion', '建议方案', '建议'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 35 })
]
}));
});
children.push(table(rows, [1200, 1500, 3800, 3500]));
}
// ===== 补充条款模板 =====
children.push(sectionTitle('五', '补充条款模板'));
const tplList = asArray(templates);
if (!tplList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
tplList.forEach(function (t, i) {
const title = pick(t, ['title', 'name', '条款名称'], '补充条款 ' + (i + 1));
const content = pick(t, ['content', 'text', '条文', '内容'], '');
children.push(para([run(String(i + 1) + '. ' + title, { size: 21, bold: true, color: C.primary })], { before: 140, after: 60 }));
String(content).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 20, font: FONT_SONG })], {
after: 50,
indent: { left: 240 },
shading: C.lightBg,
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.primary, space: 6 } }
}));
}
});
});
}
// ===== 法律依据 =====
children.push(sectionTitle('六', '法律依据'));
const lawList = asArray(legalBases);
if (!lawList.length) {
children.push(para([run('本报告未引用具体法条,相关意见为实务建议,具体法律适用需律师人工核实。', { size: 20, color: C.gray })]));
} else {
lawList.forEach(function (l, i) {
const law = pick(l, ['law', 'name', '法律名称'], '');
const article = pick(l, ['article', 'no', '条款编号'], '');
const content = pick(l, ['content', 'text', '引用内容', '内容'], '');
children.push(para([
run(String(i + 1) + '. ', { size: 20, bold: true }),
run(law + (article ? ' ' + article : ''), { size: 20, bold: true, color: C.primary }),
run(content ? ':' + content : '', { size: 20 })
], { after: 60, indent: { left: 120 } }));
});
}
// ===== 相关案例 =====
children.push(sectionTitle('七', '相关案例'));
const caseList = asArray(cases);
if (!caseList.length) {
children.push(para([run('暂未检索到公开案例,建议人工核实。', { size: 20, color: C.gray })]));
} else {
const caseHead = new TableRow({
tableHeader: true,
children: [
cell([para([run('序号', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('案件名称·案由', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 26 }),
cell([para([run('法院·机构', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 14 }),
cell([para([run('年份', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('判决要点', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 28 }),
cell([para([run('与风险点关联', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 16 })
]
});
const caseRows = [caseHead];
caseList.forEach(function (c, i) {
const name = pick(c, ['name', 'title', '案件名称'], '-');
const url = pick(c, ['url', 'link', '链接'], '');
const court = pick(c, ['court', 'org', '法院'], '-');
const year = pick(c, ['year', '年份'], '-');
const result = pick(c, ['result', 'point', '判决要点', '要点'], '-');
const relation = pick(c, ['relation', '关联', '与本次风险点关联'], '-');
const nameCellChildren = [para([run(name, { size: 19, bold: true })], { after: url ? 20 : 0 })];
if (url) {
const host = url.replace(/^https?:\/\//i, '').split('/')[0];
nameCellChildren.push(para([
new ExternalHyperlink({
link: url,
children: [run('来源:' + host + ' ↗', { size: 16, color: '2E74B5', underline: true })]
})
], {}));
}
caseRows.push(new TableRow({
children: [
cell([para([run(String(i + 1), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell(nameCellChildren, { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 26 }),
cell([para([run(court, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 14 }),
cell([para([run(year, { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell([para([run(result, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 28 }),
cell([para([run(relation, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 16 })
]
}));
});
children.push(table(caseRows, [700, 2300, 1300, 700, 2500, 1500]));
}
// ===== 免责声明 =====
children.push(sectionTitle('八', '免责声明'));
const disText = disclaimer || '本报告由人工智能根据用户上传的合同文本自动生成,仅供内部风险排查参考,不构成正式法律意见,亦不能替代执业律师的专业审查。涉及重大交易、争议解决或诉讼事项,请务必委托执业律师复核。报告中的法律法规引用以官方公布文本为准,案例信息来源于公开检索结果,具体适用请人工核实。';
String(disText).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 19, color: C.gray })], {
after: 50,
shading: C.pinkBg,
indent: { left: 120, right: 120 },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.danger, space: 6 } }
}));
}
});
// ===== 文档 =====
const doc = new Document({
creator: '金久信签约宝',
title: contractName + ' - 合同审查报告',
description: 'AI 合同审查报告',
styles: {
default: {
document: {
run: { font: FONT, size: 21, color: C.text }
}
}
},
sections: [{
properties: {
page: {
margin: { top: 1134, right: 1134, bottom: 1134, left: 1134 }
}
},
headers: {
default: new Header({
children: [
para([
run('金久信签约宝 · AI 合同审查报告', { size: 18, color: C.gray }),
run(' ' + contractName, { size: 18, color: C.gray })
], { alignment: AlignmentType.RIGHT, after: 0 })
]
})
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '第 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页 / 共 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页', font: FONT, size: 18, color: C.gray })
]
})
]
})
},
children: children
}]
});
return await Packer.toBuffer(doc);
}
// ---------- HTML 解析(生产数据兜底通道) ----------
// 智能体实际返回的是纯 HTML 报告,未必带 jjx-report-data 数据块,
// 这里按区块结构把 HTML 还原成结构化数据,无需修改智能体提示词。
function decodeEntities(s) {
return String(s || '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#(\d+);/g, function (m, d) { return String.fromCharCode(Number(d)); })
.replace(/&#x([0-9a-fA-F]+);/g, function (m, h) { return String.fromCharCode(parseInt(h, 16)); });
}
function stripTags(html) {
return decodeEntities(
String(html || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h\d|section)>/gi, '\n')
.replace(/<li[^>]*>/gi, '\n')
.replace(/<[^>]+>/g, '')
)
.replace(/[ \t\u00a0]+/g, ' ')
.replace(/\n[ ]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function pickDiv(html, cls) {
const re = new RegExp('<div[^>]*class="[^"]*\\b' + cls + '\\b[^"]*"[^>]*>([\\s\\S]*?)<\\/div>', 'i');
const m = String(html || '').match(re);
return m ? stripTags(m[1]) : '';
}
// 按 <h2> 把报告切成区块,返回 { 区块名: 区块内HTML }
// 兼容:智能体偶尔用 <h3> 代替 <h2> 做区块标题,h2 不足 2 个时降级按 h3 切
function splitByH2(html) {
const map = {};
const h2Count = (html.match(/<h2[^>]*>/gi) || []).length;
const re = h2Count >= 2
? /<h2[^>]*>([\s\S]*?)<\/h2>/gi
: /<h3[^>]*>([\s\S]*?)<\/h3>/gi;
const marks = [];
let m;
while ((m = re.exec(html))) {
marks.push({ name: stripTags(m[1]).replace(/\s+/g, ''), start: m.index, contentStart: re.lastIndex });
}
for (let i = 0; i < marks.length; i++) {
const end = i + 1 < marks.length ? marks[i + 1].start : html.length;
map[marks[i].name] = html.slice(marks[i].contentStart, end);
}
return map;
}
function findSection(map, keywords) {
const keys = Object.keys(map);
for (let i = 0; i < keys.length; i++) {
for (let j = 0; j < keywords.length; j++) {
if (keys[i].indexOf(keywords[j]) >= 0) return map[keys[i]];
}
}
return '';
}
function parseTable(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const cells = [];
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(m[1]))) cells.push(stripTags(c[1]));
if (cells.length) rows.push(cells);
}
return rows;
}
function extractTables(html) {
const tables = [];
const re = /<table[^>]*>([\s\S]*?)<\/table>/gi;
let m;
while ((m = re.exec(html))) tables.push(parseTable(m[1]));
return tables;
}
// 案例表:需要在行内同时取出单元格文本和 a[href](按行取,避免跨行错位)
function parseRowsWithLinks(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const tr = m[1];
const cells = [];
let url = '';
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(tr))) {
cells.push(stripTags(c[1]));
if (!url) {
const am = c[1].match(/<a[^>]*href="([^"]+)"/i);
if (am) url = am[1];
}
}
if (cells.length) rows.push({ cells: cells, url: url });
}
return rows;
}
function shortClause(text) {
const t = String(text || '').trim();
if (!t) return '-';
if (/^[0-9.]+$/.test(t)) return t;
return t.length > 16 ? t.slice(0, 16) + '…' : t;
}
function levelOf(text, tagCls) {
if (/高/.test(text)) return 'high';
if (/中/.test(text)) return 'medium';
if (/低/.test(text)) return 'low';
if (tagCls) return tagCls.toLowerCase();
return '';
}
function collectRiskItems(body, level, out) {
const chunks = String(body || '').split(/<div[^>]*class="[^"]*\brisk-item\b[^"]*"[^>]*>/i).slice(1);
chunks.forEach(function (chunk) {
// 级别标签两种写法:<span class="risk-tag high"> 或 <span class="tag h">
const tagMatch = chunk.match(/<span[^>]*class="[^"]*risk-tag\s+(high|medium|low)[^"]*"[^>]*>/i)
|| chunk.match(/<span[^>]*class="[^"]*tag\s+(high|medium|low|h|m|l)\b[^"]*"[^>]*>/i);
const tagRaw = tagMatch ? tagMatch[1].toLowerCase() : '';
const tagLv = tagRaw === 'h' ? 'high' : tagRaw === 'm' ? 'medium' : tagRaw === 'l' ? 'low' : tagRaw;
// 优先级:所在区块标题 > 条目自身标签 > 默认 medium
const lv = level || tagLv || 'medium';
// 字段名两种写法:risk-clause/risk-desc/risk-fix 或 clause/desc/fix
let clauseText = pickDiv(chunk, 'risk-clause') || pickDiv(chunk, 'clause');
clauseText = clauseText.replace(/^[HML]\s*/, '').trim();
const clauseNum = clauseText.match(/条款\s*([0-9.]+)/);
const clause = shortClause(clauseNum ? clauseNum[1] : clauseText);
const issue = pickDiv(chunk, 'risk-desc') || pickDiv(chunk, 'desc');
let fix = (pickDiv(chunk, 'risk-fix') || pickDiv(chunk, 'fix')).replace(/^\s*(修复建议|建议)[::]\s*/, '');
if (!clauseText && !issue) return;
const item = {
level: lv,
clause: clause || '-',
issue: issue || clauseText || '-',
suggestion: fix || ''
};
// 条款标题里的补充信息(如“交付清单、全部附件空白”)拼进问题描述,避免丢信息
if (clauseNum && clauseText) {
const rest = clauseText.replace(/条款\s*[0-9.]+\s*/, '').trim();
if (rest && item.issue.indexOf(rest) < 0) item.issue = rest + ':' + item.issue;
}
if (!out[lv]) out[lv] = [];
out[lv].push(item);
});
}
function parseRisks(sectionHtml, hint) {
const out = { high: [], medium: [], low: [] };
const parts = String(sectionHtml || '').split(/<h3[^>]*>/i);
if (parts.length > 1) {
parts.slice(1).forEach(function (part) {
const headEnd = part.search(/<\/h3>/i);
const headText = headEnd >= 0 ? stripTags(part.slice(0, headEnd)) : '';
const body = headEnd >= 0 ? part.slice(headEnd + 5) : part;
const lv = levelOf(headText, '') || hint || '';
collectRiskItems(body, lv, out);
});
} else {
collectRiskItems(sectionHtml, hint || '', out);
}
return out;
}
/**
* 从智能体生成的真实 HTML 报告里还原结构化数据
*/
function parseHtmlReport(html) {
if (!html || typeof html !== 'string') return null;
if (html.indexOf('<') < 0) return null;
const raw = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const data = {};
// 标题 / 合同名 / 日期
const h1 = raw.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
const reportTitle = h1 ? stripTags(h1[1]) : '';
const nameM = raw.match(/合同名称[::]\s*([^<\n]+)/);
const dateM = raw.match(/审核日期[::]\s*([^<\n]+)/);
const titleM = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
data.contract_name = nameM ? nameM[1].trim()
: (reportTitle || (titleM ? stripTags(titleM[1]) : '')).replace(/审查报告$/, '').trim();
data.report_title = reportTitle || '';
data.review_date = dateM ? dateM[1].trim() : '';
// 统计卡片:按 num + label 配对抓取
const summary = {};
function applyCard(numRaw, labelRaw) {
const num = stripTags(numRaw).trim();
const label = stripTags(labelRaw).replace(/\s+/g, '');
if (!label) return;
const val = /^[0-9]+$/.test(num) ? Number(num) : num;
if (label.indexOf('部分') >= 0) summary.partial = val;
else if (label.indexOf('高') >= 0) summary.high = val;
else if (label.indexOf('中') >= 0) summary.medium = val;
else if (label.indexOf('低') >= 0) summary.low = val;
else if (label.indexOf('合规') >= 0) summary.compliant = val;
}
// 写法一:label 有 class="label"
const cardRe = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*class="[^"]*\blabel\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
let cm;
while ((cm = cardRe.exec(raw))) applyCard(cm[1], cm[2]);
// 写法二:label 是紧跟的无 class <div>
if (!Object.keys(summary).length) {
const cardRe2 = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*>([\s\S]*?)<\/div>/gi;
while ((cm = cardRe2.exec(raw))) applyCard(cm[1], cm[2]);
}
data.summary = summary;
const sections = splitByH2(raw);
// 审核结论
const conclusionHtml = findSection(sections, ['审核结论']);
data.conclusion = stripTags(conclusionHtml).replace(/\n{2,}/g, '\n');
// 需处理问题:兼容两种结构
// ① 单一「需处理问题」总区块(内含高风险/中风险小节)
// ② 「高风险问题」「中风险问题」各自独立成区块
const riskKeywords = ['需处理问题', '风险问题', '问题清单', '高风险', '中风险', '低风险'];
const risks = { high: [], medium: [], low: [] };
Object.keys(sections).forEach(function (key) {
const hit = riskKeywords.some(function (k) { return key.indexOf(k) >= 0; });
if (!hit) return;
const part = parseRisks(sections[key], levelOf(key, ''));
['high', 'medium', 'low'].forEach(function (lv) {
risks[lv] = risks[lv].concat(part[lv] || []);
});
});
data.high_risks = risks.high;
data.medium_risks = risks.medium;
data.low_risks = risks.low;
// 优化建议汇总表
const sugHtml = findSection(sections, ['优化建议', '建议汇总']);
const sugTables = extractTables(sugHtml);
const suggestions = [];
if (sugTables.length) {
sugTables[0].forEach(function (row, i) {
if (i === 0) return;
if (/优先级|条款编号/.test(row.join(''))) return;
suggestions.push({
priority: (row[0] || '').replace(/\s+/g, '').slice(0, 1).toUpperCase() || 'M',
clause: row[1] || '-',
issue: row[2] || '-',
solution: row[3] || '-'
});
});
}
data.suggestions = suggestions;
// 补充条款模板(部分报告没有该区块)
// 三种写法:<pre> 代码块 / <div class="clause-template"> 模板块 / 整段纯文本
const tplHtml = findSection(sections, ['补充条款']);
const templates = [];
if (tplHtml) {
const pres = tplHtml.match(/<pre[^>]*>([\s\S]*?)<\/pre>/gi) || [];
pres.forEach(function (p, i) {
templates.push({ title: '补充条款 ' + (i + 1), content: stripTags(p) });
});
if (!templates.length) {
// 模板块:class="clause-template",标题取内部 <strong>【...】</strong>
const tplDivs = tplHtml.match(/<div[^>]*class="[^"]*\bclause-template\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi) || [];
tplDivs.forEach(function (d, i) {
const text = stripTags(d).trim();
if (!text) return;
const tm = d.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i);
const title = tm ? stripTags(tm[1]).replace(/^【|】$/g, '').trim() : '';
templates.push({ title: title || '补充条款 ' + (i + 1), content: text.replace(/^(【[^】]*】)/, '').trim() });
});
}
if (!templates.length) {
const text = stripTags(tplHtml);
if (text) templates.push({ title: '补充条款', content: text });
}
}
data.clause_templates = templates;
// 法律依据
const lawHtml = findSection(sections, ['法律依据', '法律法规']);
const legalBases = [];
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let lm;
while ((lm = liRe.exec(lawHtml))) {
const text = stripTags(lm[1]);
if (!text) continue;
const seg = text.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: text });
}
if (!legalBases.length) {
const text = stripTags(lawHtml);
if (text) {
text.split(/\n+/).forEach(function (line) {
line = line.trim();
if (!line) return;
const seg = line.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: line });
});
}
}
data.legal_bases = legalBases;
// 案例
const caseHtml = findSection(sections, ['案例', '判例']);
const caseTableMatch = caseHtml.match(/<table[^>]*>([\s\S]*?)<\/table>/i);
const cases = [];
if (caseTableMatch) {
const rows = parseRowsWithLinks(caseTableMatch[1]);
rows.forEach(function (r) {
const row = r.cells;
if (!row.length) return;
if (/案件名称/.test(row.join('')) || /^序号$/.test(row[0])) return; // 表头
const hasLinkCol = row.length >= 7;
cases.push({
name: row[hasLinkCol ? 1 : 0] || '-',
url: r.url || '',
court: row[hasLinkCol ? 3 : 2] || '-',
year: row[hasLinkCol ? 4 : 3] || '-',
result: row[hasLinkCol ? 5 : 4] || '-',
relation: row[hasLinkCol ? 6 : 5] || '-'
});
});
}
data.cases = cases;
// 免责声明(优先按 h2 区块取,取不到再从全局找 class="disclaimer")
const disHtml = findSection(sections, ['免责声明', '免责']);
let disclaimer = pickDiv(disHtml, 'disclaimer') || stripTags(disHtml);
if (!disclaimer) {
const dm = raw.match(/<div[^>]*class="[^"]*\bdisclaimer\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
if (dm) disclaimer = stripTags(dm[1]);
}
data.disclaimer = disclaimer;
// 生成时间(页脚)
const footM = raw.match(/生成时间[::]\s*([^<\n]+)/);
if (footM && !data.review_date) data.review_date = footM[1].trim();
// 完全没解析出内容则视为失败
if (!data.conclusion && !data.high_risks.length && !data.suggestions.length) return null;
return data;
}
/**
* 从智能体回复里提取「插件生成的 Word 文件链接」
* 只认显式标记,避免误抓案例正文里的 .docx 链接
* 1. <a id="jjx-docx-link" href="https://...">下载 Word</a>
* 2. <!-- jjx-docx: https://... -->
*/
function extractDocxLink(answer) {
if (!answer) return '';
const a = answer.match(/<a[^>]*id=["']jjx-docx-link["'][^>]*href=["']([^"']+)["']/i);
if (a && a[1]) return a[1];
const b = answer.match(/<!--\s*jjx-docx\s*[::]\s*(https?:\/\/[^\s>]+)\s*-->/i);
if (b && b[1]) return b[1];
return '';
}
/**
* 判断智能体回复里是否有一份「可以转成 Word 的报告」
*
* 返回 true 的两种情况:
* 1. 智能体插件已经生成了 Word 并给了链接 → 直接复用,不用本地转
* 2. 回复里有完整的 HTML 报告(有 <html> 开头且有 </html> 结尾)
*
* 返回 false 的典型情况:
* - 「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」这类道歉/澄清文本
* - HTML 被 max token 截断,缺 </html> —— 强行转会生成一份残缺的 Word,宁可不转
*/
function isDocxConvertible(answer) {
if (!answer || typeof answer !== 'string') return false;
if (extractDocxLink(answer)) return true;
if (!/<!DOCTYPE\s+html|<html[\s>]/i.test(answer)) return false;
return /<\/html>/i.test(answer);
}
/**
* 从智能体返回的 HTML 中提取结构化报告数据
* 优先级:<script id="jjx-report-data"> → ```json 代码块 → HTML 结构解析
*/
function extractReportData(answer) {
if (!answer) return null;
const m = answer.match(/<script[^>]*id=["']jjx-report-data["'][^>]*>([\s\S]*?)<\/script>/i);
if (m && m[1]) {
try {
return JSON.parse(m[1].trim());
} catch (e) {
console.log('[Docx] jjx-report-data JSON 解析失败,改用 HTML 解析:', e.message);
}
}
const blocks = answer.match(/```json\s*([\s\S]*?)```/g) || [];
for (let i = blocks.length - 1; i >= 0; i--) {
try {
const raw = blocks[i].replace(/```json\s*/, '').replace(/```$/, '').trim();
const obj = JSON.parse(raw);
if (obj && (obj.high_risks || obj.highRisks || obj.summary)) return obj;
} catch (e) { /* 继续找 */ }
}
// 兜底:直接解析 HTML 报告结构
try {
const parsed = parseHtmlReport(answer);
if (parsed) {
console.log('[Docx] 已从 HTML 报告解析结构化数据');
return parsed;
}
// 诊断:打印实际 HTML 结构关键特征,定位解析失败原因
const h2s = (answer.match(/<h2[^>]*>([\s\S]*?)<\/h2>/gi) || [])
.map(function (h) { return h.replace(/<[^>]+>/g, '').replace(/\s+/g, '').slice(0, 20); });
console.log('[Docx] HTML 解析为空。诊断 → h2区块[' + h2s.length + ']:',
h2s.length ? h2s.join(' | ') : '无h2标签',
'| 有table:', /<table/i.test(answer),
'| 有risk-item:', /risk-item/i.test(answer),
'| 有审核结论字样:', /审核结论|审查结论/.test(answer),
'| 长度:', answer.length);
} catch (e) {
console.log('[Docx] HTML 报告解析失败:', e.message);
}
return null;
}
module.exports = {
buildDocx,
extractReportData,
parseHtmlReport,
extractDocxLink,
isDocxConvertible
};
@@ -11,6 +11,8 @@ const _ = db.command;
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG'; const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
const BOT_ID = '7689821072762011698'; const BOT_ID = '7689821072762011698';
const { buildDocx, extractReportData, extractDocxLink, isDocxConvertible } = require('./buildDocx.js');
async function doReview(record) { async function doReview(record) {
const { _id: record_id, file_id, user_id, file_name, file_size } = record; const { _id: record_id, file_id, user_id, file_name, file_size } = record;
@@ -156,11 +158,52 @@ async function doReview(record) {
console.log('HTML报告上传失败:', e.message); console.log('HTML报告上传失败:', e.message);
} }
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
let docxUrl = extractDocxLink(answer);
let docxFileId = '';
if (docxUrl) {
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
} else if (!isDocxConvertible(answer)) {
// 智能体没返回完整报告(如「文件解析失败」的道歉文本,或 HTML 被截断),
// 不生成 Word,避免产出一份残缺文档
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
} else {
try {
const reportData = extractReportData(answer);
if (!reportData) {
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
} else {
const docxBuffer = await buildDocx({
data: reportData,
fileName: file_name || '合同审查报告'
});
const baseName = (file_name || 'report').replace(/\.[^.]+$/, '');
const docxPath = 'review-reports/' + Date.now() + '_' + baseName + '_审查报告.docx';
const docxRes = await uniCloud.uploadFile({
cloudPath: docxPath,
fileContent: docxBuffer
});
if (docxRes.fileID) {
docxFileId = docxRes.fileID;
const docxUrlRes = await uniCloud.getTempFileURL({ fileList: [docxRes.fileID] });
if (docxUrlRes.fileList && docxUrlRes.fileList[0]) {
docxUrl = docxUrlRes.fileList[0].tempFileURL;
}
console.log('[Docx] 本地 Word 报告生成成功:', docxFileId);
}
}
} catch (e) {
console.log('[Docx] Word 报告生成失败(不影响审查结果):', e.message);
}
}
// 更新记录为完成 // 更新记录为完成
await db.collection('jjx-review-records').doc(record_id).update({ await db.collection('jjx-review-records').doc(record_id).update({
status: 'completed', status: 'completed',
result: answer, result: answer,
html_url: htmlUrl, html_url: htmlUrl,
docx_url: docxUrl,
docx_file_id: docxFileId,
token_count: tokenCount, token_count: tokenCount,
points_used: pointsUsed, points_used: pointsUsed,
conversation_id: conversationId || '', conversation_id: conversationId || '',
@@ -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('════════════════════════════════════════');
})();
@@ -3,6 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"description": "合同审查定时任务", "description": "合同审查定时任务",
"main": "index.js", "main": "index.js",
"dependencies": {
"docx": "^8.5.0"
},
"cloudfunction-config": { "cloudfunction-config": {
"memorySize": 256, "memorySize": 256,
"timeout": 600, "timeout": 600,
@@ -16,4 +19,4 @@
"path": "", "path": "",
"runtime": "Nodejs16" "runtime": "Nodejs16"
} }
} }
@@ -0,0 +1,922 @@
'use strict';
/**
* 合同审查报告 → Word(.docx) 生成器
*
* 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON
* <script type="application/json" id="jjx-report-data">{...}</script>
*
* 排版目标:正式法律文书风格,A4 纵向,可直接打印装订
*/
const {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
WidthType,
AlignmentType,
BorderStyle,
ShadingType,
TableLayoutType,
Footer,
Header,
PageNumber,
VerticalAlign,
ExternalHyperlink
} = require('docx');
const FONT = 'Microsoft YaHei';
const FONT_SONG = 'SimSun';
const C = {
primary: '1A5276',
danger: 'E74C3C',
success: '27AE60',
warning: 'F39C12',
text: '222222',
gray: '777777',
lightBg: 'F5F7F9',
pinkBg: 'FDF2F2',
border: 'D0D7DE'
};
const PAGE_BORDER = {
top: { style: BorderStyle.SINGLE, size: 4, color: C.border },
bottom: { style: BorderStyle.SINGLE, size: 4, color: C.border },
left: { style: BorderStyle.SINGLE, size: 4, color: C.border },
right: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: C.border },
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: C.border }
};
// ---------- 基础元件 ----------
function run(text, o) {
o = o || {};
const opt = {
text: String(text === null || text === undefined ? '' : text),
font: o.font || FONT,
size: o.size || 21,
bold: !!o.bold,
italics: !!o.italics,
color: o.color || C.text
};
if (o.underline) opt.underline = {};
return new TextRun(opt);
}
function para(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
spacing: {
before: o.before === undefined ? 60 : o.before,
after: o.after === undefined ? 60 : o.after,
line: o.line || 320
}
};
if (o.alignment) opt.alignment = o.alignment;
if (o.indent) opt.indent = o.indent;
if (o.border) opt.border = o.border;
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
return new Paragraph(opt);
}
function cell(children, o) {
o = o || {};
const opt = {
children: Array.isArray(children) ? children : [children],
verticalAlign: VerticalAlign.CENTER,
margins: { top: 60, bottom: 60, left: 110, right: 110 }
};
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
if (o.width) opt.width = { size: o.width, type: WidthType.PERCENTAGE };
if (o.columnSpan) opt.columnSpan = o.columnSpan;
return new TableCell(opt);
}
function table(rows, widths) {
return new Table({
rows: rows,
width: { size: 100, type: WidthType.PERCENTAGE },
columnWidths: widths,
layout: TableLayoutType.AUTOFIT,
borders: PAGE_BORDER
});
}
function sectionTitle(no, text) {
return para([run(no + '、' + text, { size: 28, bold: true, color: C.primary })], {
before: 320,
after: 140,
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: C.primary, space: 4 } }
});
}
function pick(obj, keys, dft) {
if (!obj) return dft;
for (let i = 0; i < keys.length; i++) {
const v = obj[keys[i]];
if (v !== undefined && v !== null && v !== '') return v;
}
return dft;
}
function asArray(v) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
// ---------- 风险条目 ----------
function riskBlock(item, color, levelText) {
const clause = pick(item, ['clause', 'no', '条款', 'clauseNo'], '');
const issue = pick(item, ['issue', 'risk', 'problem', '描述', '问题'], '');
const suggest = pick(item, ['suggestion', 'fix', 'advice', '建议', '修改建议'], '');
const basis = pick(item, ['legal_basis', 'legalBasis', 'basis', '法律依据'], '');
const blocks = [];
blocks.push(para([
run('[' + levelText + ']', { bold: true, color: color, size: 21 }),
run(clause ? ' 第 ' + clause + ' 条' : ' 合同条款', { bold: true, color: color, size: 21 })
], { before: 140, after: 40 }));
blocks.push(para([
run('问题:', { bold: true, size: 21 }),
run(issue, { size: 21 })
], { after: 40, indent: { left: 240 } }));
if (suggest) {
blocks.push(para([
run('建议:', { bold: true, size: 21 }),
run(suggest, { size: 21 })
], { after: 40, indent: { left: 240 } }));
}
if (basis) {
blocks.push(para([
run('依据:', { bold: true, size: 21 }),
run(basis, { size: 21, color: C.gray })
], { after: 80, indent: { left: 240 } }));
}
return blocks;
}
function riskSection(items, color, levelText, emptyText) {
const list = asArray(items);
if (!list.length) {
return [para([run(emptyText || '无', { size: 21, color: C.gray })], { after: 80 })];
}
let out = [];
list.forEach(function (it) {
out = out.concat(riskBlock(it, color, levelText));
});
return out;
}
// ---------- 主函数 ----------
async function buildDocx(input) {
const data = input.data || {};
const fileName = input.fileName || '合同审查报告';
const contractName = pick(data, ['contract_name', 'contractName', '协议名称', 'name'], fileName.replace(/\.[^.]+$/, ''));
const reportTitle = pick(data, ['report_title', 'reportTitle', '报告标题'], '合同审查报告');
const reviewDate = pick(data, ['review_date', 'reviewDate', '审核日期'], new Date().toLocaleDateString('zh-CN'));
const summary = pick(data, ['summary', '统计'], {}) || {};
const conclusion = pick(data, ['conclusion', '审核结论', 'summary_text'], '');
const highRisks = pick(data, ['high_risks', 'highRisks', '高风险'], []);
const mediumRisks = pick(data, ['medium_risks', 'mediumRisks', '中风险'], []);
const lowRisks = pick(data, ['low_risks', 'lowRisks', '低风险'], []);
const suggestions = pick(data, ['suggestions', '优化建议', 'advice_table'], []);
const templates = pick(data, ['clause_templates', 'clauseTemplates', '补充条款'], []);
const legalBases = pick(data, ['legal_bases', 'legalBases', '法律依据'], []);
const cases = pick(data, ['cases', '案例'], []);
const disclaimer = pick(data, ['disclaimer', '免责声明'], '');
const nCompliant = pick(summary, ['compliant', '合规'], 0);
const nPartial = pick(summary, ['partial', '部分合规'], 0);
const nHigh = pick(summary, ['high', '高风险'], asArray(highRisks).length);
const nMedium = pick(summary, ['medium', '中风险'], asArray(mediumRisks).length);
const children = [];
// ===== 抬头 =====
children.push(para([run(reportTitle, { size: 44, bold: true, color: C.primary })], {
alignment: AlignmentType.CENTER,
before: 200,
after: 120
}));
children.push(para([run(contractName, { size: 26, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 60
}));
children.push(para([run('审核日期:' + reviewDate + ' 出具机构:金久信签约宝', { size: 20, color: C.gray })], {
alignment: AlignmentType.CENTER,
after: 240
}));
// ===== 目录 =====
children.push(sectionTitle('一', '报告目录'));
const tocItems = [
'二、审核结论',
'三、需处理问题(高风险 / 中风险)',
'四、优化建议汇总表',
'五、补充条款模板',
'六、法律依据',
'七、相关案例',
'八、免责声明'
];
tocItems.forEach(function (item) {
children.push(para([run(item, { size: 21 })], { after: 30, indent: { left: 240 } }));
});
// ===== 审核结论 =====
children.push(sectionTitle('二', '审核结论'));
const statRow = new TableRow({
children: [
cell([para([run(String(nCompliant), { size: 40, bold: true, color: C.success })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('合规条款', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'F0F9EB', width: 25 }),
cell([para([run(String(nPartial), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('部分合规', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 }),
cell([para([run(String(nHigh), { size: 40, bold: true, color: C.danger })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('高风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FEF0F0', width: 25 }),
cell([para([run(String(nMedium), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
para([run('中风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
{ shading: 'FDF6EC', width: 25 })
]
});
children.push(table([statRow], [2000, 2000, 2000, 2000]));
if (conclusion) {
children.push(para([run('审核结论', { size: 21, bold: true, color: C.primary })], { before: 200, after: 80 }));
String(conclusion).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 21 })], { after: 60, indent: { left: 240 } }));
}
});
}
// ===== 需处理问题 =====
children.push(sectionTitle('三', '需处理问题'));
children.push(para([run('(一)高风险问题', { size: 24, bold: true, color: C.danger })], { before: 140, after: 80 }));
riskSection(highRisks, C.danger, '高风险', '本次审查未发现高风险问题。').forEach(function (p) { children.push(p); });
children.push(para([run('(二)中风险问题', { size: 24, bold: true, color: C.warning })], { before: 200, after: 80 }));
riskSection(mediumRisks, C.warning, '中风险', '本次审查未发现中风险问题。').forEach(function (p) { children.push(p); });
if (asArray(lowRisks).length) {
children.push(para([run('(三)低风险问题', { size: 24, bold: true, color: C.gray })], { before: 200, after: 80 }));
riskSection(lowRisks, C.gray, '低风险', '无').forEach(function (p) { children.push(p); });
}
// ===== 优化建议汇总表 =====
children.push(sectionTitle('四', '优化建议汇总表'));
const sugList = asArray(suggestions);
if (!sugList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
const headRow = new TableRow({
tableHeader: true,
children: [
cell([para([run('优先级', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 12 }),
cell([para([run('条款编号', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 15 }),
cell([para([run('问题描述', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 38 }),
cell([para([run('建议方案', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 35 })
]
});
const rows = [headRow];
sugList.forEach(function (s, i) {
const lv = String(pick(s, ['priority', 'level', '优先级'], 'M')).toUpperCase();
const lvBg = lv === 'H' ? C.danger : (lv === 'L' ? '999999' : C.warning);
rows.push(new TableRow({
children: [
cell([para([run(lv, { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: lvBg, width: 12 }),
cell([para([run(pick(s, ['clause', 'no', '条款编号'], '-'), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 15 }),
cell([para([run(pick(s, ['issue', 'problem', '问题描述', '描述'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 38 }),
cell([para([run(pick(s, ['solution', 'suggestion', '建议方案', '建议'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 35 })
]
}));
});
children.push(table(rows, [1200, 1500, 3800, 3500]));
}
// ===== 补充条款模板 =====
children.push(sectionTitle('五', '补充条款模板'));
const tplList = asArray(templates);
if (!tplList.length) {
children.push(para([run('无', { size: 21, color: C.gray })]));
} else {
tplList.forEach(function (t, i) {
const title = pick(t, ['title', 'name', '条款名称'], '补充条款 ' + (i + 1));
const content = pick(t, ['content', 'text', '条文', '内容'], '');
children.push(para([run(String(i + 1) + '. ' + title, { size: 21, bold: true, color: C.primary })], { before: 140, after: 60 }));
String(content).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 20, font: FONT_SONG })], {
after: 50,
indent: { left: 240 },
shading: C.lightBg,
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.primary, space: 6 } }
}));
}
});
});
}
// ===== 法律依据 =====
children.push(sectionTitle('六', '法律依据'));
const lawList = asArray(legalBases);
if (!lawList.length) {
children.push(para([run('本报告未引用具体法条,相关意见为实务建议,具体法律适用需律师人工核实。', { size: 20, color: C.gray })]));
} else {
lawList.forEach(function (l, i) {
const law = pick(l, ['law', 'name', '法律名称'], '');
const article = pick(l, ['article', 'no', '条款编号'], '');
const content = pick(l, ['content', 'text', '引用内容', '内容'], '');
children.push(para([
run(String(i + 1) + '. ', { size: 20, bold: true }),
run(law + (article ? ' ' + article : ''), { size: 20, bold: true, color: C.primary }),
run(content ? ':' + content : '', { size: 20 })
], { after: 60, indent: { left: 120 } }));
});
}
// ===== 相关案例 =====
children.push(sectionTitle('七', '相关案例'));
const caseList = asArray(cases);
if (!caseList.length) {
children.push(para([run('暂未检索到公开案例,建议人工核实。', { size: 20, color: C.gray })]));
} else {
const caseHead = new TableRow({
tableHeader: true,
children: [
cell([para([run('序号', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('案件名称·案由', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 26 }),
cell([para([run('法院·机构', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 14 }),
cell([para([run('年份', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
cell([para([run('判决要点', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 28 }),
cell([para([run('与风险点关联', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 16 })
]
});
const caseRows = [caseHead];
caseList.forEach(function (c, i) {
const name = pick(c, ['name', 'title', '案件名称'], '-');
const url = pick(c, ['url', 'link', '链接'], '');
const court = pick(c, ['court', 'org', '法院'], '-');
const year = pick(c, ['year', '年份'], '-');
const result = pick(c, ['result', 'point', '判决要点', '要点'], '-');
const relation = pick(c, ['relation', '关联', '与本次风险点关联'], '-');
const nameCellChildren = [para([run(name, { size: 19, bold: true })], { after: url ? 20 : 0 })];
if (url) {
const host = url.replace(/^https?:\/\//i, '').split('/')[0];
nameCellChildren.push(para([
new ExternalHyperlink({
link: url,
children: [run('来源:' + host + ' ↗', { size: 16, color: '2E74B5', underline: true })]
})
], {}));
}
caseRows.push(new TableRow({
children: [
cell([para([run(String(i + 1), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell(nameCellChildren, { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 26 }),
cell([para([run(court, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 14 }),
cell([para([run(year, { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
cell([para([run(result, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 28 }),
cell([para([run(relation, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 16 })
]
}));
});
children.push(table(caseRows, [700, 2300, 1300, 700, 2500, 1500]));
}
// ===== 免责声明 =====
children.push(sectionTitle('八', '免责声明'));
const disText = disclaimer || '本报告由人工智能根据用户上传的合同文本自动生成,仅供内部风险排查参考,不构成正式法律意见,亦不能替代执业律师的专业审查。涉及重大交易、争议解决或诉讼事项,请务必委托执业律师复核。报告中的法律法规引用以官方公布文本为准,案例信息来源于公开检索结果,具体适用请人工核实。';
String(disText).split(/\n+/).forEach(function (line) {
if (line.trim()) {
children.push(para([run(line.trim(), { size: 19, color: C.gray })], {
after: 50,
shading: C.pinkBg,
indent: { left: 120, right: 120 },
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.danger, space: 6 } }
}));
}
});
// ===== 文档 =====
const doc = new Document({
creator: '金久信签约宝',
title: contractName + ' - 合同审查报告',
description: 'AI 合同审查报告',
styles: {
default: {
document: {
run: { font: FONT, size: 21, color: C.text }
}
}
},
sections: [{
properties: {
page: {
margin: { top: 1134, right: 1134, bottom: 1134, left: 1134 }
}
},
headers: {
default: new Header({
children: [
para([
run('金久信签约宝 · AI 合同审查报告', { size: 18, color: C.gray }),
run(' ' + contractName, { size: 18, color: C.gray })
], { alignment: AlignmentType.RIGHT, after: 0 })
]
})
},
footers: {
default: new Footer({
children: [
new Paragraph({
alignment: AlignmentType.CENTER,
children: [
new TextRun({ text: '第 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页 / 共 ', font: FONT, size: 18, color: C.gray }),
new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 18, color: C.gray }),
new TextRun({ text: ' 页', font: FONT, size: 18, color: C.gray })
]
})
]
})
},
children: children
}]
});
return await Packer.toBuffer(doc);
}
// ---------- HTML 解析(生产数据兜底通道) ----------
// 智能体实际返回的是纯 HTML 报告,未必带 jjx-report-data 数据块,
// 这里按区块结构把 HTML 还原成结构化数据,无需修改智能体提示词。
function decodeEntities(s) {
return String(s || '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&#(\d+);/g, function (m, d) { return String.fromCharCode(Number(d)); })
.replace(/&#x([0-9a-fA-F]+);/g, function (m, h) { return String.fromCharCode(parseInt(h, 16)); });
}
function stripTags(html) {
return decodeEntities(
String(html || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h\d|section)>/gi, '\n')
.replace(/<li[^>]*>/gi, '\n')
.replace(/<[^>]+>/g, '')
)
.replace(/[ \t\u00a0]+/g, ' ')
.replace(/\n[ ]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function pickDiv(html, cls) {
const re = new RegExp('<div[^>]*class="[^"]*\\b' + cls + '\\b[^"]*"[^>]*>([\\s\\S]*?)<\\/div>', 'i');
const m = String(html || '').match(re);
return m ? stripTags(m[1]) : '';
}
// 按 <h2> 把报告切成区块,返回 { 区块名: 区块内HTML }
// 兼容:智能体偶尔用 <h3> 代替 <h2> 做区块标题,h2 不足 2 个时降级按 h3 切
function splitByH2(html) {
const map = {};
const h2Count = (html.match(/<h2[^>]*>/gi) || []).length;
const re = h2Count >= 2
? /<h2[^>]*>([\s\S]*?)<\/h2>/gi
: /<h3[^>]*>([\s\S]*?)<\/h3>/gi;
const marks = [];
let m;
while ((m = re.exec(html))) {
marks.push({ name: stripTags(m[1]).replace(/\s+/g, ''), start: m.index, contentStart: re.lastIndex });
}
for (let i = 0; i < marks.length; i++) {
const end = i + 1 < marks.length ? marks[i + 1].start : html.length;
map[marks[i].name] = html.slice(marks[i].contentStart, end);
}
return map;
}
function findSection(map, keywords) {
const keys = Object.keys(map);
for (let i = 0; i < keys.length; i++) {
for (let j = 0; j < keywords.length; j++) {
if (keys[i].indexOf(keywords[j]) >= 0) return map[keys[i]];
}
}
return '';
}
function parseTable(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const cells = [];
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(m[1]))) cells.push(stripTags(c[1]));
if (cells.length) rows.push(cells);
}
return rows;
}
function extractTables(html) {
const tables = [];
const re = /<table[^>]*>([\s\S]*?)<\/table>/gi;
let m;
while ((m = re.exec(html))) tables.push(parseTable(m[1]));
return tables;
}
// 案例表:需要在行内同时取出单元格文本和 a[href](按行取,避免跨行错位)
function parseRowsWithLinks(tableHtml) {
const rows = [];
const trRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
let m;
while ((m = trRe.exec(tableHtml))) {
const tr = m[1];
const cells = [];
let url = '';
const tdRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
let c;
while ((c = tdRe.exec(tr))) {
cells.push(stripTags(c[1]));
if (!url) {
const am = c[1].match(/<a[^>]*href="([^"]+)"/i);
if (am) url = am[1];
}
}
if (cells.length) rows.push({ cells: cells, url: url });
}
return rows;
}
function shortClause(text) {
const t = String(text || '').trim();
if (!t) return '-';
if (/^[0-9.]+$/.test(t)) return t;
return t.length > 16 ? t.slice(0, 16) + '…' : t;
}
function levelOf(text, tagCls) {
if (/高/.test(text)) return 'high';
if (/中/.test(text)) return 'medium';
if (/低/.test(text)) return 'low';
if (tagCls) return tagCls.toLowerCase();
return '';
}
function collectRiskItems(body, level, out) {
const chunks = String(body || '').split(/<div[^>]*class="[^"]*\brisk-item\b[^"]*"[^>]*>/i).slice(1);
chunks.forEach(function (chunk) {
// 级别标签两种写法:<span class="risk-tag high"> 或 <span class="tag h">
const tagMatch = chunk.match(/<span[^>]*class="[^"]*risk-tag\s+(high|medium|low)[^"]*"[^>]*>/i)
|| chunk.match(/<span[^>]*class="[^"]*tag\s+(high|medium|low|h|m|l)\b[^"]*"[^>]*>/i);
const tagRaw = tagMatch ? tagMatch[1].toLowerCase() : '';
const tagLv = tagRaw === 'h' ? 'high' : tagRaw === 'm' ? 'medium' : tagRaw === 'l' ? 'low' : tagRaw;
// 优先级:所在区块标题 > 条目自身标签 > 默认 medium
const lv = level || tagLv || 'medium';
// 字段名两种写法:risk-clause/risk-desc/risk-fix 或 clause/desc/fix
let clauseText = pickDiv(chunk, 'risk-clause') || pickDiv(chunk, 'clause');
clauseText = clauseText.replace(/^[HML]\s*/, '').trim();
const clauseNum = clauseText.match(/条款\s*([0-9.]+)/);
const clause = shortClause(clauseNum ? clauseNum[1] : clauseText);
const issue = pickDiv(chunk, 'risk-desc') || pickDiv(chunk, 'desc');
let fix = (pickDiv(chunk, 'risk-fix') || pickDiv(chunk, 'fix')).replace(/^\s*(修复建议|建议)[::]\s*/, '');
if (!clauseText && !issue) return;
const item = {
level: lv,
clause: clause || '-',
issue: issue || clauseText || '-',
suggestion: fix || ''
};
// 条款标题里的补充信息(如“交付清单、全部附件空白”)拼进问题描述,避免丢信息
if (clauseNum && clauseText) {
const rest = clauseText.replace(/条款\s*[0-9.]+\s*/, '').trim();
if (rest && item.issue.indexOf(rest) < 0) item.issue = rest + ':' + item.issue;
}
if (!out[lv]) out[lv] = [];
out[lv].push(item);
});
}
function parseRisks(sectionHtml, hint) {
const out = { high: [], medium: [], low: [] };
const parts = String(sectionHtml || '').split(/<h3[^>]*>/i);
if (parts.length > 1) {
parts.slice(1).forEach(function (part) {
const headEnd = part.search(/<\/h3>/i);
const headText = headEnd >= 0 ? stripTags(part.slice(0, headEnd)) : '';
const body = headEnd >= 0 ? part.slice(headEnd + 5) : part;
const lv = levelOf(headText, '') || hint || '';
collectRiskItems(body, lv, out);
});
} else {
collectRiskItems(sectionHtml, hint || '', out);
}
return out;
}
/**
* 从智能体生成的真实 HTML 报告里还原结构化数据
*/
function parseHtmlReport(html) {
if (!html || typeof html !== 'string') return null;
if (html.indexOf('<') < 0) return null;
const raw = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '');
const data = {};
// 标题 / 合同名 / 日期
const h1 = raw.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
const reportTitle = h1 ? stripTags(h1[1]) : '';
const nameM = raw.match(/合同名称[::]\s*([^<\n]+)/);
const dateM = raw.match(/审核日期[::]\s*([^<\n]+)/);
const titleM = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
data.contract_name = nameM ? nameM[1].trim()
: (reportTitle || (titleM ? stripTags(titleM[1]) : '')).replace(/审查报告$/, '').trim();
data.report_title = reportTitle || '';
data.review_date = dateM ? dateM[1].trim() : '';
// 统计卡片:按 num + label 配对抓取
const summary = {};
function applyCard(numRaw, labelRaw) {
const num = stripTags(numRaw).trim();
const label = stripTags(labelRaw).replace(/\s+/g, '');
if (!label) return;
const val = /^[0-9]+$/.test(num) ? Number(num) : num;
if (label.indexOf('部分') >= 0) summary.partial = val;
else if (label.indexOf('高') >= 0) summary.high = val;
else if (label.indexOf('中') >= 0) summary.medium = val;
else if (label.indexOf('低') >= 0) summary.low = val;
else if (label.indexOf('合规') >= 0) summary.compliant = val;
}
// 写法一:label 有 class="label"
const cardRe = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*class="[^"]*\blabel\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
let cm;
while ((cm = cardRe.exec(raw))) applyCard(cm[1], cm[2]);
// 写法二:label 是紧跟的无 class <div>
if (!Object.keys(summary).length) {
const cardRe2 = /<div[^>]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*<div[^>]*>([\s\S]*?)<\/div>/gi;
while ((cm = cardRe2.exec(raw))) applyCard(cm[1], cm[2]);
}
data.summary = summary;
const sections = splitByH2(raw);
// 审核结论
const conclusionHtml = findSection(sections, ['审核结论']);
data.conclusion = stripTags(conclusionHtml).replace(/\n{2,}/g, '\n');
// 需处理问题:兼容两种结构
// ① 单一「需处理问题」总区块(内含高风险/中风险小节)
// ② 「高风险问题」「中风险问题」各自独立成区块
const riskKeywords = ['需处理问题', '风险问题', '问题清单', '高风险', '中风险', '低风险'];
const risks = { high: [], medium: [], low: [] };
Object.keys(sections).forEach(function (key) {
const hit = riskKeywords.some(function (k) { return key.indexOf(k) >= 0; });
if (!hit) return;
const part = parseRisks(sections[key], levelOf(key, ''));
['high', 'medium', 'low'].forEach(function (lv) {
risks[lv] = risks[lv].concat(part[lv] || []);
});
});
data.high_risks = risks.high;
data.medium_risks = risks.medium;
data.low_risks = risks.low;
// 优化建议汇总表
const sugHtml = findSection(sections, ['优化建议', '建议汇总']);
const sugTables = extractTables(sugHtml);
const suggestions = [];
if (sugTables.length) {
sugTables[0].forEach(function (row, i) {
if (i === 0) return;
if (/优先级|条款编号/.test(row.join(''))) return;
suggestions.push({
priority: (row[0] || '').replace(/\s+/g, '').slice(0, 1).toUpperCase() || 'M',
clause: row[1] || '-',
issue: row[2] || '-',
solution: row[3] || '-'
});
});
}
data.suggestions = suggestions;
// 补充条款模板(部分报告没有该区块)
// 三种写法:<pre> 代码块 / <div class="clause-template"> 模板块 / 整段纯文本
const tplHtml = findSection(sections, ['补充条款']);
const templates = [];
if (tplHtml) {
const pres = tplHtml.match(/<pre[^>]*>([\s\S]*?)<\/pre>/gi) || [];
pres.forEach(function (p, i) {
templates.push({ title: '补充条款 ' + (i + 1), content: stripTags(p) });
});
if (!templates.length) {
// 模板块:class="clause-template",标题取内部 <strong>【...】</strong>
const tplDivs = tplHtml.match(/<div[^>]*class="[^"]*\bclause-template\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi) || [];
tplDivs.forEach(function (d, i) {
const text = stripTags(d).trim();
if (!text) return;
const tm = d.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i);
const title = tm ? stripTags(tm[1]).replace(/^【|】$/g, '').trim() : '';
templates.push({ title: title || '补充条款 ' + (i + 1), content: text.replace(/^(【[^】]*】)/, '').trim() });
});
}
if (!templates.length) {
const text = stripTags(tplHtml);
if (text) templates.push({ title: '补充条款', content: text });
}
}
data.clause_templates = templates;
// 法律依据
const lawHtml = findSection(sections, ['法律依据', '法律法规']);
const legalBases = [];
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let lm;
while ((lm = liRe.exec(lawHtml))) {
const text = stripTags(lm[1]);
if (!text) continue;
const seg = text.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: text });
}
if (!legalBases.length) {
const text = stripTags(lawHtml);
if (text) {
text.split(/\n+/).forEach(function (line) {
line = line.trim();
if (!line) return;
const seg = line.match(/^(.+?第[0-9]+条[之]*[一二三四五六七八九十]*)[::]\s*([\s\S]*)$/);
if (seg) legalBases.push({ law: seg[1], article: '', content: seg[2] });
else legalBases.push({ law: '', article: '', content: line });
});
}
}
data.legal_bases = legalBases;
// 案例
const caseHtml = findSection(sections, ['案例', '判例']);
const caseTableMatch = caseHtml.match(/<table[^>]*>([\s\S]*?)<\/table>/i);
const cases = [];
if (caseTableMatch) {
const rows = parseRowsWithLinks(caseTableMatch[1]);
rows.forEach(function (r) {
const row = r.cells;
if (!row.length) return;
if (/案件名称/.test(row.join('')) || /^序号$/.test(row[0])) return; // 表头
const hasLinkCol = row.length >= 7;
cases.push({
name: row[hasLinkCol ? 1 : 0] || '-',
url: r.url || '',
court: row[hasLinkCol ? 3 : 2] || '-',
year: row[hasLinkCol ? 4 : 3] || '-',
result: row[hasLinkCol ? 5 : 4] || '-',
relation: row[hasLinkCol ? 6 : 5] || '-'
});
});
}
data.cases = cases;
// 免责声明(优先按 h2 区块取,取不到再从全局找 class="disclaimer")
const disHtml = findSection(sections, ['免责声明', '免责']);
let disclaimer = pickDiv(disHtml, 'disclaimer') || stripTags(disHtml);
if (!disclaimer) {
const dm = raw.match(/<div[^>]*class="[^"]*\bdisclaimer\b[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
if (dm) disclaimer = stripTags(dm[1]);
}
data.disclaimer = disclaimer;
// 生成时间(页脚)
const footM = raw.match(/生成时间[::]\s*([^<\n]+)/);
if (footM && !data.review_date) data.review_date = footM[1].trim();
// 完全没解析出内容则视为失败
if (!data.conclusion && !data.high_risks.length && !data.suggestions.length) return null;
return data;
}
/**
* 从智能体回复里提取「插件生成的 Word 文件链接」
* 只认显式标记,避免误抓案例正文里的 .docx 链接
* 1. <a id="jjx-docx-link" href="https://...">下载 Word</a>
* 2. <!-- jjx-docx: https://... -->
*/
function extractDocxLink(answer) {
if (!answer) return '';
const a = answer.match(/<a[^>]*id=["']jjx-docx-link["'][^>]*href=["']([^"']+)["']/i);
if (a && a[1]) return a[1];
const b = answer.match(/<!--\s*jjx-docx\s*[::]\s*(https?:\/\/[^\s>]+)\s*-->/i);
if (b && b[1]) return b[1];
return '';
}
/**
* 判断智能体回复里是否有一份「可以转成 Word 的报告」
*
* 返回 true 的两种情况:
* 1. 智能体插件已经生成了 Word 并给了链接 → 直接复用,不用本地转
* 2. 回复里有完整的 HTML 报告(有 <html> 开头且有 </html> 结尾)
*
* 返回 false 的典型情况:
* - 「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」这类道歉/澄清文本
* - HTML 被 max token 截断,缺 </html> —— 强行转会生成一份残缺的 Word,宁可不转
*/
function isDocxConvertible(answer) {
if (!answer || typeof answer !== 'string') return false;
if (extractDocxLink(answer)) return true;
if (!/<!DOCTYPE\s+html|<html[\s>]/i.test(answer)) return false;
return /<\/html>/i.test(answer);
}
/**
* 从智能体返回的 HTML 中提取结构化报告数据
* 优先级:<script id="jjx-report-data"> → ```json 代码块 → HTML 结构解析
*/
function extractReportData(answer) {
if (!answer) return null;
const m = answer.match(/<script[^>]*id=["']jjx-report-data["'][^>]*>([\s\S]*?)<\/script>/i);
if (m && m[1]) {
try {
return JSON.parse(m[1].trim());
} catch (e) {
console.log('[Docx] jjx-report-data JSON 解析失败,改用 HTML 解析:', e.message);
}
}
const blocks = answer.match(/```json\s*([\s\S]*?)```/g) || [];
for (let i = blocks.length - 1; i >= 0; i--) {
try {
const raw = blocks[i].replace(/```json\s*/, '').replace(/```$/, '').trim();
const obj = JSON.parse(raw);
if (obj && (obj.high_risks || obj.highRisks || obj.summary)) return obj;
} catch (e) { /* 继续找 */ }
}
// 兜底:直接解析 HTML 报告结构
try {
const parsed = parseHtmlReport(answer);
if (parsed) {
console.log('[Docx] 已从 HTML 报告解析结构化数据');
return parsed;
}
// 诊断:打印实际 HTML 结构关键特征,定位解析失败原因
const h2s = (answer.match(/<h2[^>]*>([\s\S]*?)<\/h2>/gi) || [])
.map(function (h) { return h.replace(/<[^>]+>/g, '').replace(/\s+/g, '').slice(0, 20); });
console.log('[Docx] HTML 解析为空。诊断 → h2区块[' + h2s.length + ']:',
h2s.length ? h2s.join(' | ') : '无h2标签',
'| 有table:', /<table/i.test(answer),
'| 有risk-item:', /risk-item/i.test(answer),
'| 有审核结论字样:', /审核结论|审查结论/.test(answer),
'| 长度:', answer.length);
} catch (e) {
console.log('[Docx] HTML 报告解析失败:', e.message);
}
return null;
}
module.exports = {
buildDocx,
extractReportData,
parseHtmlReport,
extractDocxLink,
isDocxConvertible
};
@@ -1,4 +1,20 @@
// client/ai/pub/jjxDoReview.js // client/ai/pub/jjxDoReview.js
// Word 报告生成器(依赖 docx,未安装时降级跳过,不影响审查)
let buildDocx = null;
let extractReportData = null;
let extractDocxLink = null;
let isDocxConvertible = null;
try {
const docxMod = require('./buildDocx.js');
buildDocx = docxMod.buildDocx;
extractReportData = docxMod.extractReportData;
extractDocxLink = docxMod.extractDocxLink;
isDocxConvertible = docxMod.isDocxConvertible;
} catch (e) {
console.log('[Docx] docx 依赖未安装,跳过 Word 生成:', e.message);
}
module.exports = { module.exports = {
main: async (event) => { main: async (event) => {
let { data = {}, userInfo } = event; let { data = {}, userInfo } = event;
@@ -171,11 +187,50 @@ module.exports = {
if (jsonMatch) contractInfo = JSON.parse(jsonMatch[1].trim()); if (jsonMatch) contractInfo = JSON.parse(jsonMatch[1].trim());
} catch (e) {} } catch (e) {}
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
let docxUrl = extractDocxLink ? extractDocxLink(answer) : '';
let docxFileId = '';
if (docxUrl) {
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
} else if (isDocxConvertible && !isDocxConvertible(answer)) {
// 智能体没返回完整报告(道歉/澄清文本,或 HTML 被截断),不生成残缺 Word
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
} else if (buildDocx && extractReportData) {
try {
const reportData = extractReportData(answer);
if (reportData) {
const docxBuffer = await buildDocx({
data: reportData,
fileName: file_name || '合同审查报告'
});
const baseName = (file_name || 'report').replace(/\.[^.]+$/, '');
const docxPath = 'review-reports/' + Date.now() + '_' + baseName + '_审查报告.docx';
const docxRes = await uniCloud.uploadFile({
cloudPath: docxPath,
fileContent: docxBuffer
});
if (docxRes.fileID) {
docxFileId = docxRes.fileID;
const docxUrlRes = await uniCloud.getTempFileURL({ fileList: [docxRes.fileID] });
if (docxUrlRes.fileList && docxUrlRes.fileList[0]) {
docxUrl = docxUrlRes.fileList[0].tempFileURL;
}
}
} else {
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
}
} catch (e) {
console.log('[Docx] Word 报告生成失败(不影响审查结果):', e.message);
}
}
// 更新审查记录为完成 // 更新审查记录为完成
await db.collection('jjx-review-records').doc(record_id).update({ await db.collection('jjx-review-records').doc(record_id).update({
status: 'completed', status: 'completed',
result: answer, result: answer,
html_url: htmlUrl, html_url: htmlUrl,
docx_url: docxUrl,
docx_file_id: docxFileId,
contract_info: contractInfo, contract_info: contractInfo,
points_used: pointsUsed, points_used: pointsUsed,
conversation_id: conversationId || '', conversation_id: conversationId || '',
@@ -94,7 +94,6 @@ module.exports = {
needPermission, needPermission,
queryField queryField
}); });
console.log('login res', res)
} }
if (res.token) { if (res.token) {
if (!res.msg) { if (!res.msg) {