95024bb2a6
- 云函数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文件
923 lines
36 KiB
JavaScript
923 lines
36 KiB
JavaScript
'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(/ /g, ' ')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'|'/g, "'")
|
||
.replace(/&/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
|
||
};
|
||
|