diff --git a/.gitignore b/.gitignore index 2f0d4c5..9652905 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,7 @@ /uni_modules/ /.idea /uniCloud-aliyun/cloudfunctions/router/node_modules/ +/uniCloud-tcb/cloudfunctions/**/node_modules/ +/uniCloud-tcb/cloudfunctions/**/package-lock.json /pnpm-lock.yaml /金久信源码/ \ No newline at end of file diff --git a/pages/contract/index.vue b/pages/contract/index.vue index 8d7e595..7530810 100644 --- a/pages/contract/index.vue +++ b/pages/contract/index.vue @@ -633,8 +633,10 @@ 已完成 失败 报告下载 + 下载 Word 查看上传文件 在线查看报告 + 重新审查 @@ -1652,14 +1654,16 @@ export default { try { const res = await vk.callFunction({ 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) { this.reportList = res.data.rows.map(r => ({ fileName: r.file_name, fileSize: r.file_size, + fileId: r.file_id || '', fileUrl: r.file_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'), amount: r.points_used || 0, result: r.result, @@ -1678,6 +1682,56 @@ export default { this.reviewResult = r.result || '暂无审查结果'; 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) { if (!r.fileUrl) { this.$message.warning('文件链接不存在'); @@ -1723,6 +1777,7 @@ export default { file_base64: fileBase64, file_name: file.name, file_size: file.size, + uid: vk.getVuex('$user.userInfo._id') || '', upload_only: true } }); @@ -1769,7 +1824,7 @@ export default { file_id: this.reviewFileId, file_name: this.reviewFile.name, file_size: this.reviewFile.size, - uid: this.userInfo.uid + uid: vk.getVuex('$user.userInfo._id') || '' } }); diff --git a/uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.js b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.js new file mode 100644 index 0000000..f69dcc4 --- /dev/null +++ b/uniCloud-tcb/cloudfunctions/jjxReviewTimer/buildDocx.js @@ -0,0 +1,922 @@ +'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(/&#x([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(/]*>/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(']*class="[^"]*\\b' + cls + '\\b[^"]*"[^>]*>([\\s\\S]*?)<\\/div>', 'i'); + const m = String(html || '').match(re); + return m ? stripTags(m[1]) : ''; +} + +// 按

把报告切成区块,返回 { 区块名: 区块内HTML } +// 兼容:智能体偶尔用

代替

做区块标题,h2 不足 2 个时降级按 h3 切 +function splitByH2(html) { + const map = {}; + const h2Count = (html.match(/]*>/gi) || []).length; + const re = h2Count >= 2 + ? /]*>([\s\S]*?)<\/h2>/gi + : /]*>([\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 = /]*>([\s\S]*?)<\/tr>/gi; + let m; + while ((m = trRe.exec(tableHtml))) { + const cells = []; + const tdRe = /]*>([\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 = /]*>([\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 = /]*>([\s\S]*?)<\/tr>/gi; + let m; + while ((m = trRe.exec(tableHtml))) { + const tr = m[1]; + const cells = []; + let url = ''; + const tdRe = /]*>([\s\S]*?)<\/t[dh]>/gi; + let c; + while ((c = tdRe.exec(tr))) { + cells.push(stripTags(c[1])); + if (!url) { + const am = c[1].match(/]*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(/]*class="[^"]*\brisk-item\b[^"]*"[^>]*>/i).slice(1); + chunks.forEach(function (chunk) { + // 级别标签两种写法: 或 + const tagMatch = chunk.match(/]*class="[^"]*risk-tag\s+(high|medium|low)[^"]*"[^>]*>/i) + || chunk.match(/]*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(/]*>/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(//gi, '') + .replace(//gi, ''); + + const data = {}; + + // 标题 / 合同名 / 日期 + const h1 = raw.match(/]*>([\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(/]*>([\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 = /]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*]*class="[^"]*\blabel\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi; + let cm; + while ((cm = cardRe.exec(raw))) applyCard(cm[1], cm[2]); + // 写法二:label 是紧跟的无 class
+ if (!Object.keys(summary).length) { + const cardRe2 = /]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*]*>([\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; + + // 补充条款模板(部分报告没有该区块) + // 三种写法:
 代码块 / 
模板块 / 整段纯文本 + const tplHtml = findSection(sections, ['补充条款']); + const templates = []; + if (tplHtml) { + const pres = tplHtml.match(/]*>([\s\S]*?)<\/pre>/gi) || []; + pres.forEach(function (p, i) { + templates.push({ title: '补充条款 ' + (i + 1), content: stripTags(p) }); + }); + if (!templates.length) { + // 模板块:class="clause-template",标题取内部 【...】 + const tplDivs = tplHtml.match(/]*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(/]*>([\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 = /]*>([\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(/]*>([\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(/]*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. 下载 Word + * 2. + */ +function extractDocxLink(answer) { + if (!answer) return ''; + const a = answer.match(/]*id=["']jjx-docx-link["'][^>]*href=["']([^"']+)["']/i); + if (a && a[1]) return a[1]; + const b = answer.match(//i); + if (b && b[1]) return b[1]; + return ''; +} + +/** + * 判断智能体回复里是否有一份「可以转成 Word 的报告」 + * + * 返回 true 的两种情况: + * 1. 智能体插件已经生成了 Word 并给了链接 → 直接复用,不用本地转 + * 2. 回复里有完整的 HTML 报告(有 开头且有 结尾) + * + * 返回 false 的典型情况: + * - 「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」这类道歉/澄清文本 + * - HTML 被 max token 截断,缺 —— 强行转会生成一份残缺的 Word,宁可不转 + */ +function isDocxConvertible(answer) { + if (!answer || typeof answer !== 'string') return false; + if (extractDocxLink(answer)) return true; + if (!/]/i.test(answer)) return false; + return /<\/html>/i.test(answer); +} + +/** + * 从智能体返回的 HTML 中提取结构化报告数据 + * 优先级: + * + * 排版目标:正式法律文书风格,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(//gi, '\n') + .replace(/<\/(p|div|li|tr|h\d|section)>/gi, '\n') + .replace(/]*>/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(']*class="[^"]*\\b' + cls + '\\b[^"]*"[^>]*>([\\s\\S]*?)<\\/div>', 'i'); + const m = String(html || '').match(re); + return m ? stripTags(m[1]) : ''; +} + +// 按

把报告切成区块,返回 { 区块名: 区块内HTML } +// 兼容:智能体偶尔用

代替

做区块标题,h2 不足 2 个时降级按 h3 切 +function splitByH2(html) { + const map = {}; + const h2Count = (html.match(/]*>/gi) || []).length; + const re = h2Count >= 2 + ? /]*>([\s\S]*?)<\/h2>/gi + : /]*>([\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 = /]*>([\s\S]*?)<\/tr>/gi; + let m; + while ((m = trRe.exec(tableHtml))) { + const cells = []; + const tdRe = /]*>([\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 = /]*>([\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 = /]*>([\s\S]*?)<\/tr>/gi; + let m; + while ((m = trRe.exec(tableHtml))) { + const tr = m[1]; + const cells = []; + let url = ''; + const tdRe = /]*>([\s\S]*?)<\/t[dh]>/gi; + let c; + while ((c = tdRe.exec(tr))) { + cells.push(stripTags(c[1])); + if (!url) { + const am = c[1].match(/]*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(/]*class="[^"]*\brisk-item\b[^"]*"[^>]*>/i).slice(1); + chunks.forEach(function (chunk) { + // 级别标签两种写法: 或 + const tagMatch = chunk.match(/]*class="[^"]*risk-tag\s+(high|medium|low)[^"]*"[^>]*>/i) + || chunk.match(/]*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(/]*>/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(//gi, '') + .replace(//gi, ''); + + const data = {}; + + // 标题 / 合同名 / 日期 + const h1 = raw.match(/]*>([\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(/]*>([\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 = /]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*]*class="[^"]*\blabel\b[^"]*"[^>]*>([\s\S]*?)<\/div>/gi; + let cm; + while ((cm = cardRe.exec(raw))) applyCard(cm[1], cm[2]); + // 写法二:label 是紧跟的无 class
+ if (!Object.keys(summary).length) { + const cardRe2 = /]*class="[^"]*\bnum\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*]*>([\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; + + // 补充条款模板(部分报告没有该区块) + // 三种写法:
 代码块 / 
模板块 / 整段纯文本 + const tplHtml = findSection(sections, ['补充条款']); + const templates = []; + if (tplHtml) { + const pres = tplHtml.match(/]*>([\s\S]*?)<\/pre>/gi) || []; + pres.forEach(function (p, i) { + templates.push({ title: '补充条款 ' + (i + 1), content: stripTags(p) }); + }); + if (!templates.length) { + // 模板块:class="clause-template",标题取内部 【...】 + const tplDivs = tplHtml.match(/]*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(/]*>([\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 = /]*>([\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(/]*>([\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(/]*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. 下载 Word + * 2. + */ +function extractDocxLink(answer) { + if (!answer) return ''; + const a = answer.match(/]*id=["']jjx-docx-link["'][^>]*href=["']([^"']+)["']/i); + if (a && a[1]) return a[1]; + const b = answer.match(//i); + if (b && b[1]) return b[1]; + return ''; +} + +/** + * 判断智能体回复里是否有一份「可以转成 Word 的报告」 + * + * 返回 true 的两种情况: + * 1. 智能体插件已经生成了 Word 并给了链接 → 直接复用,不用本地转 + * 2. 回复里有完整的 HTML 报告(有 开头且有 结尾) + * + * 返回 false 的典型情况: + * - 「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」这类道歉/澄清文本 + * - HTML 被 max token 截断,缺 —— 强行转会生成一份残缺的 Word,宁可不转 + */ +function isDocxConvertible(answer) { + if (!answer || typeof answer !== 'string') return false; + if (extractDocxLink(answer)) return true; + if (!/]/i.test(answer)) return false; + return /<\/html>/i.test(answer); +} + +/** + * 从智能体返回的 HTML 中提取结构化报告数据 + * 优先级: