Files
jjx-web/uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.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

923 lines
36 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.
'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
};