'use strict';
/**
* 合同审查报告 → Word(.docx) 生成器
*
* 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON
*
*
* 排版目标:正式法律文书风格,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(/ /g, ' ')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'|'/g, "'")
.replace(/&/g, '&')
.replace(/(\d+);/g, function (m, d) { return String.fromCharCode(Number(d)); })
.replace(/([0-9a-fA-F]+);/g, function (m, h) { return String.fromCharCode(parseInt(h, 16)); });
}
function stripTags(html) {
return decodeEntities(
String(html || '')
.replace(/
/gi, '\n')
.replace(/<\/(p|div|li|tr|h\d|section)>/gi, '\n')
.replace(/