Compare commits
8 Commits
904fd1f8f1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 14ac0a3661 | |||
| 3c29b7c38f | |||
| 3011f41df1 | |||
| 1c1dfc1221 | |||
| 2d411de7d8 | |||
| 39ac3e6996 | |||
| 4700ea4d98 | |||
| 4625e7c130 |
@@ -12,10 +12,10 @@
|
||||
<view class="bubble">
|
||||
您好!我是金久信签约宝AI智能客服,可以帮您解答金久信签约宝使用过程中注册、操作、适用场景等问题。请问有什么可以帮您?
|
||||
<view class="quick-btns">
|
||||
<view class="quick-btn" @click="chatQuickSend('注册准备资料事项')">注册准备资料事项</view>
|
||||
<view class="quick-btn" @click="chatQuickSend('关键字无法识别')">关键字无法识别</view>
|
||||
<view class="quick-btn" @click="chatQuickSend('如何下询价单')">金久信签约宝法律效力</view>
|
||||
<view class="quick-btn" @click="chatQuickSend('电子签约不适用场景')">电子签约不适用场景</view>
|
||||
<view class="quick-btn" @click="quickSend('注册准备资料事项')">注册准备资料事项</view>
|
||||
<view class="quick-btn" @click="quickSend('关键字无法识别')">关键字无法识别</view>
|
||||
<view class="quick-btn" @click="quickSend('金久信签约宝法律效力')">金久信签约宝法律效力</view>
|
||||
<view class="quick-btn" @click="quickSend('电子签约不适用场景')">电子签约不适用场景</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+325
-177
@@ -478,7 +478,7 @@
|
||||
<view class="quick-btns">
|
||||
<view class="quick-btn" @click="chatQuickSend('注册准备资料事项')">注册准备资料事项</view>
|
||||
<view class="quick-btn" @click="chatQuickSend('关键字无法识别')">关键字无法识别</view>
|
||||
<view class="quick-btn" @click="chatQuickSend('如何下询价单')">金久信签约宝法律效力</view>
|
||||
<view class="quick-btn" @click="chatQuickSend('金久信签约宝法律效力')">金久信签约宝法律效力</view>
|
||||
<view class="quick-btn" @click="chatQuickSend('电子签约不适用场景')">电子签约不适用场景</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -502,22 +502,27 @@
|
||||
<!-- 合同审查 -->
|
||||
<view v-if="menuIndex === '4'" class="review-container">
|
||||
<view class="review-title">📋 AI 合同审查</view>
|
||||
<!-- 积分提示 -->
|
||||
<view class="points-notice">
|
||||
<span class="pn-icon">💎</span>
|
||||
<view class="pn-text">
|
||||
<view class="pn-title">合同审查消耗积分</view>
|
||||
<view class="pn-desc">每页消耗 60 积分,上传后自动计算。</view>
|
||||
</view>
|
||||
<view class="pn-balance">余额:{{ pointsBalance }} 分</view>
|
||||
</view>
|
||||
<!-- 上传区 -->
|
||||
<view v-if="!reviewFile" class="upload-area" @click="selectReviewFile">
|
||||
<view class="upload-icon">📤</view>
|
||||
<view class="upload-title">上传合同文件</view>
|
||||
<view class="upload-desc">支持 PDF / Word / 图片,最大 20MB</view>
|
||||
<view class="upload-btn">选择文件上传</view>
|
||||
<!-- 积分提示 -->
|
||||
<view class="points-notice">
|
||||
<span class="pn-icon">💎</span>
|
||||
<view class="pn-text">
|
||||
<view class="pn-title">合同审查消耗积分</view>
|
||||
<view class="pn-desc">根据文件大小自动计算,每 100KB 约 200 积分。审查后余额需保留至少 200 积分。</view>
|
||||
</view>
|
||||
<view class="pn-balance">余额:{{ pointsBalance }} 分</view>
|
||||
</view>
|
||||
<!-- 免责声明 -->
|
||||
<view class="disclaimer-notice">
|
||||
<span class="dn-icon">⚠️</span>
|
||||
<view class="dn-text">请确保合同内容清晰可见。如因合同不清晰导致分析失败,平台不承担后果,积分正常消耗。</view>
|
||||
</view>
|
||||
<!-- 上传区 -->
|
||||
<view v-if="!reviewFile" class="upload-area" @click="selectReviewFile">
|
||||
<view class="upload-icon">📤</view>
|
||||
<view class="upload-title">上传合同文件</view>
|
||||
<view class="upload-desc">仅支持 PDF / Word(.pdf .doc .docx),最大 20MB</view>
|
||||
<view class="upload-btn">选择文件上传</view>
|
||||
</view>
|
||||
<!-- 上传中提示 -->
|
||||
<view v-if="reviewUploading" style="text-align:center;padding:30px 0;">
|
||||
<i class="el-icon-loading" style="font-size:32px;color:#a92924;"></i>
|
||||
@@ -538,7 +543,7 @@
|
||||
<view class="opt-icon">🔍</view>
|
||||
<view class="opt-title">标准审查</view>
|
||||
<view class="opt-desc">风险条款识别 · 合规性检查</view>
|
||||
<view class="opt-points">{{ reviewPoints }} 积分</view>
|
||||
<!-- <view class="opt-points">{{ reviewPoints }} 积分</view> -->
|
||||
</view>
|
||||
<!-- <view :class="['review-opt', reviewMode === 1 && 'selected']" @click="reviewMode = 1">
|
||||
<view class="opt-icon">📊</view>
|
||||
@@ -547,11 +552,17 @@
|
||||
<view class="opt-points">{{ reviewPoints * 2 }} 积分</view>
|
||||
</view> -->
|
||||
</view>
|
||||
<view style="text-align:center;margin-top:16px;">
|
||||
<el-button type="primary" :loading="reviewLoading" @click="startReview">
|
||||
{{ reviewLoading ? '审查中...' : '开始审查(' + (reviewMode === 0 ? reviewPoints : reviewPoints * 2) + ' 积分)' }}
|
||||
</el-button>
|
||||
</view>
|
||||
<view style="text-align:center;margin-top:16px;">
|
||||
<el-button type="primary" :loading="reviewLoading"
|
||||
:disabled="pointsBalance < reviewPoints + 200"
|
||||
@click="startReview">
|
||||
{{ reviewLoading ? '审查中...' : '开始审查' }}
|
||||
</el-button>
|
||||
<p v-if="pointsBalance < reviewPoints + 200" style="font-size:12px;color:#f56c6c;margin-top:6px;">
|
||||
⚠️ 积分不足,当前余额 {{ pointsBalance }},需要 {{ reviewPoints }} 积分且审查后余额需保留至少 200 积分。
|
||||
<span style="color:#a92924;cursor:pointer;text-decoration:underline;" @click="goRecharge">去充值</span>
|
||||
</p>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 审查中 -->
|
||||
<view v-if="reviewLoading" style="text-align:center;padding:40px 0;">
|
||||
@@ -560,34 +571,51 @@
|
||||
<el-progress :percentage="reviewProgress" :stroke-width="8"
|
||||
style="max-width:400px;margin:0 auto;"></el-progress>
|
||||
</view>
|
||||
<!-- 审查结果 -->
|
||||
<view v-if="reviewResult">
|
||||
<!-- 审查结果 -->
|
||||
<view v-if="reviewResult">
|
||||
<view class="result-header">
|
||||
<h3>📋 审查结果 — {{ reviewFile.name }}</h3>
|
||||
<span class="result-status">✅ 审查完成</span>
|
||||
<h3>📋 {{ reviewStreaming ? 'AI 正在分析...' : '审查结果' }} — {{ reviewFile.name }}</h3>
|
||||
<span class="result-status">{{ reviewStreaming ? '⏳ 分析中...' : '✅ 审查完成' }}</span>
|
||||
</view>
|
||||
<view class="result-body">
|
||||
<view class="result-body" :style="reviewStreaming ? 'max-height:400px;overflow-y:auto;' : ''">
|
||||
<div v-html="reviewResult"></div>
|
||||
<div v-if="reviewStreaming" style="margin-top:8px;">
|
||||
<span style="display:inline-block;width:8px;height:8px;background:#a92924;border-radius:50%;animation:pulse 1s infinite;"></span>
|
||||
<span style="font-size:12px;color:#999;margin-left:6px;">AI 正在输出分析结果...</span>
|
||||
</div>
|
||||
</view>
|
||||
<view class="result-footer">
|
||||
<el-button type="primary" size="small" @click="resetReview">重新审查</el-button>
|
||||
</view>
|
||||
<view class="result-footer">
|
||||
<el-button v-if="reviewHtmlUrl" type="success" size="small" @click="openUrl(reviewHtmlUrl)">📄 查看完整 HTML 报告</el-button>
|
||||
<el-button size="small" @click="resetReview">重新审查</el-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的报告 -->
|
||||
<view v-if="menuIndex === '5'" class="review-container">
|
||||
<view class="review-title">📈 我的报告</view>
|
||||
<view class="review-title">
|
||||
📈 我的报告
|
||||
<el-button size="mini" type="primary" style="margin-left:12px;" @click="queryReports()">🔍 查询报告</el-button>
|
||||
</view>
|
||||
<view v-if="reportList.length === 0" style="text-align:center;padding:60px 0;color:#999;">
|
||||
<div style="font-size:48px;margin-bottom:12px;">📋</div>
|
||||
暂无审查报告
|
||||
暂无审查报告,点击上方「查询报告」从服务器加载
|
||||
</view>
|
||||
<view v-for="(r, i) in reportList" :key="i" class="report-item">
|
||||
<view class="report-info">
|
||||
<view class="report-name">{{ r.fileName }}</view>
|
||||
<view class="report-time">{{ r.time }}</view>
|
||||
<view class="report-name">📄 {{ r.fileName }}</view>
|
||||
<view class="report-time">{{ r.time }} · 消耗 {{ Math.abs(r.amount) }} 积分</view>
|
||||
</view>
|
||||
<el-button size="mini" type="text" @click="viewReport(r)">查看</el-button>
|
||||
<el-tag v-if="r.status === 'uploaded'" size="mini" type="info">待审查</el-tag>
|
||||
<el-tag v-else-if="r.status === 'reviewing'" size="mini" type="warning">排队中</el-tag>
|
||||
<el-tag v-else-if="r.status === 'processing'" size="mini" type="warning">审查中</el-tag>
|
||||
<el-tag v-else-if="r.status === 'completed'" size="mini" type="success">已完成</el-tag>
|
||||
<el-tag v-else-if="r.status === 'failed'" size="mini" type="danger">失败</el-tag>
|
||||
<el-button v-if="r.htmlUrl" size="mini" type="text" @click="openUrl(r.htmlUrl)">报告下载</el-button>
|
||||
<el-button v-if="r.docxUrl" size="mini" type="text" @click="openUrl(r.docxUrl)">下载 Word</el-button>
|
||||
<el-button v-if="r.fileUrl" size="mini" type="text" @click="previewFile(r)">查看上传文件</el-button>
|
||||
<el-button v-if="r.status === 'completed' && r.result" size="mini" type="text" @click="viewReport(r)">在线查看报告</el-button>
|
||||
<el-button v-if="r.status === 'uploaded'" size="mini" type="primary" plain :loading="r._submitting" @click="reReview(r)">重新审查</el-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -625,12 +653,15 @@
|
||||
<!-- 使用说明 -->
|
||||
<view class="review-title" style="font-size:14px;margin-top:16px;">📖 积分使用说明</view>
|
||||
<view style="font-size:13px;color:#666;line-height:2;margin-bottom:20px;">
|
||||
<p>• <strong>标准审查</strong>:每页 60 积分 — 风险条款识别、合规性检查</p>
|
||||
<p>• <strong>深度分析</strong>:标准审查 × 2 — 标准审查 + 估价参考 + 修改建议</p>
|
||||
<p>• <strong>合同审查</strong>:每 100KB 约 200 积分(根据文件大小自动计算)</p>
|
||||
<p>• <strong>AI 客服</strong>:免费 — 签约宝专属问答</p>
|
||||
<p>• 新用户注册自动赠送 500 体验积分</p>
|
||||
<p>• 积分长期有效,不过期</p>
|
||||
</view>
|
||||
<view class="review-title" style="font-size:14px;">
|
||||
📜 积分明细
|
||||
<el-button size="mini" type="text" style="margin-left:12px;" @click="getPointsLog()">🔍 查看</el-button>
|
||||
</view>
|
||||
<view class="review-title" style="font-size:14px;">📜 积分明细</view>
|
||||
<el-table :data="pointsLogList" style="width:100%">
|
||||
<el-table-column prop="_add_time_str" label="时间" width="180"></el-table-column>
|
||||
<el-table-column label="类型" width="80">
|
||||
@@ -844,6 +875,11 @@
|
||||
// 合同审查数据
|
||||
reviewFile: null,
|
||||
reviewFileId: null,
|
||||
reviewRecordId: null,
|
||||
reviewHtmlUrl: '',
|
||||
reviewPdfUrl: '',
|
||||
reviewContractInfo: {},
|
||||
reviewStreaming: false,
|
||||
reviewUploading: false,
|
||||
reviewMode: 0,
|
||||
reviewPoints: 100,
|
||||
@@ -854,34 +890,34 @@
|
||||
// 积分充值
|
||||
selectedRecharge: 1,
|
||||
rechargePackages: [{
|
||||
points: 500,
|
||||
price: '30元',
|
||||
points: 10000,
|
||||
price: '20元',
|
||||
bonus: ''
|
||||
},
|
||||
{
|
||||
points: 1000,
|
||||
price: '50元',
|
||||
bonus: '送100积分'
|
||||
points: 50000,
|
||||
price: '100元',
|
||||
bonus: ''
|
||||
},
|
||||
{
|
||||
points: 2000,
|
||||
price: '98元',
|
||||
bonus: '送300积分'
|
||||
points: 100000,
|
||||
price: '200元',
|
||||
bonus: ''
|
||||
},
|
||||
{
|
||||
points: 5000,
|
||||
price: '198元',
|
||||
bonus: '送1000积分'
|
||||
points: 450000,
|
||||
price: '900元',
|
||||
bonus: ''
|
||||
},
|
||||
{
|
||||
points: 10000,
|
||||
price: '368元',
|
||||
bonus: '送3000积分'
|
||||
points: 1000000,
|
||||
price: '2000元',
|
||||
bonus: ''
|
||||
},
|
||||
{
|
||||
points: 20000,
|
||||
price: '688元',
|
||||
bonus: '送8000积分'
|
||||
points: 2000000,
|
||||
price: '4000元',
|
||||
bonus: ''
|
||||
}
|
||||
],
|
||||
// 我的报告
|
||||
@@ -1474,9 +1510,20 @@
|
||||
this.userInfo = vk.getVuex('$user.userInfo')
|
||||
this.getUserInfo()
|
||||
this.getPointsBalance()
|
||||
if (this.menuIndex === '6') this.getPointsLog();
|
||||
if (this.menuIndex === '5') this.getReportList();
|
||||
},
|
||||
methods: {
|
||||
dayjs,
|
||||
// 获取当前登录用户uid(vuex优先,storage兜底)
|
||||
getUid() {
|
||||
let uid = (typeof vk.getVuex === 'function' && vk.getVuex('$user.userInfo._id')) || ''
|
||||
if (!uid) {
|
||||
const cache = vk.getStorageSync('uni_id_user_info') || {}
|
||||
uid = cache._id || cache.uid || ''
|
||||
}
|
||||
return uid
|
||||
},
|
||||
menuSelect(e) {
|
||||
this.menuIndex = String(e)
|
||||
this.formData.status = Number(e)
|
||||
@@ -1487,8 +1534,6 @@
|
||||
this.isSignTips = false
|
||||
// AI 智能服务菜单(3/4/5/6)不调 query,加载对应数据
|
||||
if (e >= 3) {
|
||||
if (e === 6) this.getPointsLog();
|
||||
if (e === 5) this.getReportList();
|
||||
if (e === 4 || e === 6) this.getPointsBalance();
|
||||
return;
|
||||
}
|
||||
@@ -1640,7 +1685,55 @@
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
},
|
||||
openUrl(url) {
|
||||
if (url) window.open(url, '_blank');
|
||||
},
|
||||
fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result.split(',')[1]);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
formatReviewResult(text) {
|
||||
if (!text) return '';
|
||||
let html = text;
|
||||
// 处理 markdown 标题 ## / ###
|
||||
html = html.replace(/^###\s+(.+)$/gm, '<h4 style="margin:16px 0 8px;color:#333;">$1</h4>');
|
||||
html = html.replace(/^##\s+(.+)$/gm, '<h3 style="margin:20px 0 10px;color:#222;">$1</h3>');
|
||||
// 处理加粗
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong style="color:#a92924;">$1</strong>');
|
||||
// 处理链接(PDF下载链接等)— 自动识别 http/https 链接
|
||||
html = html.replace(/(https?:\/\/[^\s\)\]\"\<]+)/g, '<a href="$1" target="_blank" style="color:#a92924;text-decoration:underline;word-break:break-all;">$1</a>');
|
||||
// 处理列表
|
||||
html = html.replace(/^[\-\•]\s+(.+)$/gm, '<div style="padding-left:16px;margin:4px 0;">• $1</div>');
|
||||
html = html.replace(/^(\d+)\.\s+(.+)$/gm, '<div style="padding-left:16px;margin:4px 0;">$1. $2</div>');
|
||||
// 处理表格行(简单的 | 分隔表格)
|
||||
html = html.replace(/\|(.+)\|/g, function(match) {
|
||||
const cells = match.split('|').filter(c => c.trim());
|
||||
if (cells.length > 1) {
|
||||
return '<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #eee;">' +
|
||||
cells.map(c => '<span style="flex:1;font-size:13px;">' + c.trim() + '</span>').join('') + '</div>';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
// 换行
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
},
|
||||
// ===== 积分相关 =====
|
||||
// 积分不足时跳转到「积分管理」并刷新余额与明细
|
||||
goRecharge() {
|
||||
this.menuIndex = '6';
|
||||
this.isSign = false;
|
||||
this.isSignIntro = false;
|
||||
this.isSignLog = false;
|
||||
this.isSignFlow = false;
|
||||
this.isSignTips = false;
|
||||
this.getPointsBalance();
|
||||
this.getPointsLog();
|
||||
},
|
||||
async getPointsBalance() {
|
||||
try {
|
||||
const res = await vk.callFunction({
|
||||
@@ -1663,36 +1756,116 @@
|
||||
}
|
||||
});
|
||||
if (res.code === 0) {
|
||||
this.pointsLogList = (res.data.rows || []).map(r => ({
|
||||
...r,
|
||||
_add_time_str: dayjs(r._add_time).format('YYYY-MM-DD HH:mm:ss')
|
||||
this.pointsLogList = (res.data.rows || []).map(r => ({
|
||||
...r,
|
||||
_add_time_str: dayjs(r._add_time && r._add_time['$date'] ? r._add_time['$date'] : r._add_time).format('YYYY-MM-DD HH:mm:ss')
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
/* 静默 */ }
|
||||
},
|
||||
async getReportList() {
|
||||
try {
|
||||
const reports = JSON.parse(localStorage.getItem('jjx_review_reports') || '[]');
|
||||
this.reportList = reports.map(r => ({
|
||||
fileName: r.fileName,
|
||||
fileSize: r.fileSize,
|
||||
time: r.time,
|
||||
amount: r.points,
|
||||
result: r.result,
|
||||
status: 'completed'
|
||||
}));
|
||||
} catch (e) { /* 静默 */ }
|
||||
},
|
||||
async queryReports() {
|
||||
try {
|
||||
const res = await vk.callFunction({
|
||||
url: 'client/points/kh/getLog',
|
||||
url: 'client/ai/pub/getMyReports',
|
||||
data: { pageIndex: 1, pageSize: 50, uid: this.getUid() }
|
||||
});
|
||||
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,
|
||||
status: r.status,
|
||||
recordId: r._id
|
||||
}));
|
||||
this.$message.success('加载了 ' + this.reportList.length + ' 条报告');
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('查询失败');
|
||||
}
|
||||
},
|
||||
viewReport(r) {
|
||||
// 跳转到合同审查页并显示该报告的结果
|
||||
this.menuIndex = '4';
|
||||
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: {
|
||||
pageIndex: 1,
|
||||
pageSize: 50,
|
||||
biz_type: 'contract_review'
|
||||
record_id: r.recordId,
|
||||
file_id: r.fileId,
|
||||
file_name: r.fileName,
|
||||
file_size: r.fileSize || 0,
|
||||
uid: this.getUid()
|
||||
}
|
||||
});
|
||||
if (res.code === 0) {
|
||||
this.reportList = (res.data.rows || []).map(r => ({
|
||||
fileName: r.reason ? r.reason.replace('合同审查 — ', '') : '未知文件',
|
||||
time: dayjs(r._add_time).format('YYYY-MM-DD HH:mm:ss'),
|
||||
bizId: r.biz_id
|
||||
}));
|
||||
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);
|
||||
},
|
||||
viewReport(r) {
|
||||
this.$message.info('查看报告:' + r.fileName);
|
||||
previewFile(r) {
|
||||
if (!r.fileUrl) {
|
||||
this.$message.warning('文件链接不存在');
|
||||
return;
|
||||
}
|
||||
// 新窗口打开文件(PDF直接预览,Word下载)
|
||||
window.open(r.fileUrl, '_blank');
|
||||
},
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
@@ -1703,7 +1876,7 @@
|
||||
selectReviewFile() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf,.doc,.docx,.jpg,.png';
|
||||
input.accept = '.pdf,.doc,.docx';
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
@@ -1713,7 +1886,7 @@
|
||||
}
|
||||
this.reviewFile = file;
|
||||
this.reviewFileId = null;
|
||||
this.reviewPoints = Math.max(150, Math.ceil(file.size / (100 * 1024)) * 150);
|
||||
this.reviewPoints = Math.max(200, Math.ceil(file.size / (100 * 1024)) * 200);
|
||||
|
||||
// 立即上传文件到扣子
|
||||
this.reviewUploading = true;
|
||||
@@ -1725,17 +1898,20 @@
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const res = await vk.callFunction({
|
||||
url: 'client/ai/pub/review',
|
||||
data: {
|
||||
file_base64: fileBase64,
|
||||
file_name: file.name,
|
||||
upload_only: true
|
||||
}
|
||||
});
|
||||
const res = await vk.callFunction({
|
||||
url: 'client/ai/pub/review',
|
||||
data: {
|
||||
file_base64: fileBase64,
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
uid: this.getUid(),
|
||||
upload_only: true
|
||||
}
|
||||
});
|
||||
|
||||
if (res.code === 0 && res.data && res.data.file_id) {
|
||||
this.reviewFileId = res.data.file_id;
|
||||
this.reviewRecordId = res.data.record_id; // 保存待审查记录ID
|
||||
this.$message.success('文件上传成功');
|
||||
} else {
|
||||
this.$message.error(res.msg || '文件上传失败');
|
||||
@@ -1749,97 +1925,62 @@
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
// 前端 startReview() 完整改造
|
||||
async startReview() {
|
||||
if (!this.reviewFileId) {
|
||||
this.$message.warning('文件尚未上传完成,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const pointsNeeded = this.reviewMode === 0 ? this.reviewPoints : this.reviewPoints * 2;
|
||||
if (this.pointsBalance < pointsNeeded) {
|
||||
this.$message.warning('积分不足,当前余额 ' + this.pointsBalance + ',需要 ' + pointsNeeded);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reviewLoading = true;
|
||||
this.reviewProgress = 0;
|
||||
|
||||
// 模拟进度
|
||||
const interval = setInterval(() => {
|
||||
this.reviewProgress = Math.min(this.reviewProgress + Math.random() * 10, 95);
|
||||
}, 800);
|
||||
|
||||
try {
|
||||
// 1. 扣积分
|
||||
const deductRes = await vk.callFunction({
|
||||
url: 'client/points/kh/deduct',
|
||||
data: {
|
||||
amount: pointsNeeded,
|
||||
reason: '合同审查 — ' + this.reviewFile.name,
|
||||
biz_type: 'contract_review'
|
||||
}
|
||||
});
|
||||
if (deductRes.code !== 0) {
|
||||
this.$message.error(deductRes.msg || '扣积分失败');
|
||||
this.reviewLoading = false;
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
this.pointsBalance = deductRes.data.balance;
|
||||
|
||||
// 2. 调用合同审查(异步,立即返回)
|
||||
const res = await vk.callFunction({
|
||||
url: 'client/ai/pub/review',
|
||||
data: { file_id: this.reviewFileId, file_name: this.reviewFile.name }
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
this.$message.error(res.msg || '审查发起失败');
|
||||
this.reviewLoading = false;
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = res.data.chat_id;
|
||||
const conversationId = res.data.conversation_id;
|
||||
|
||||
// 3. 轮询结果(每 3 秒查一次,最多 5 分钟)
|
||||
let answer = '';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
this.reviewProgress = Math.min(this.reviewProgress + 1, 95);
|
||||
|
||||
const pollRes = await vk.callFunction({
|
||||
url: 'client/ai/pub/getReviewResult',
|
||||
data: { chat_id: chatId, conversation_id: conversationId }
|
||||
});
|
||||
|
||||
if (pollRes.code === 0 && pollRes.data.status === 'completed') {
|
||||
answer = pollRes.data.answer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(interval);
|
||||
this.reviewProgress = 100;
|
||||
|
||||
if (answer) {
|
||||
this.reviewResult = answer;
|
||||
this.getReportList();
|
||||
this.$message.success('合同审查完成');
|
||||
} else {
|
||||
this.$message.error('审查超时,请稍后在「我的报告」中查看');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
clearInterval(interval);
|
||||
this.$message.error('审查异常,请稍后重试');
|
||||
}
|
||||
|
||||
this.reviewLoading = false;
|
||||
if (!this.reviewFileId) {
|
||||
this.$message.warning('请先选择合同文件并等待上传完成');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 检查积分
|
||||
const balanceRes = await vk.callFunction({
|
||||
url: 'client/points/kh/getBalance'
|
||||
});
|
||||
if (balanceRes.code !== 0 || !balanceRes.data || balanceRes.data.balance < 200) {
|
||||
this.$message.warning('积分不足,请先充值');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reviewLoading = true;
|
||||
|
||||
// 2. 发起审查(更新已有记录状态为 reviewing,用 selectReviewFile 时保存的 file_id)
|
||||
const reviewRes = await vk.callFunction({
|
||||
url: 'client/ai/pub/review',
|
||||
data: {
|
||||
record_id: this.reviewRecordId,
|
||||
file_id: this.reviewFileId,
|
||||
file_name: this.reviewFile.name,
|
||||
file_size: this.reviewFile.size,
|
||||
uid: this.getUid()
|
||||
}
|
||||
});
|
||||
|
||||
this.reviewLoading = false;
|
||||
|
||||
if (reviewRes.code !== 0) {
|
||||
this.$message.error(reviewRes.msg || '发起审查失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 提示用户 + 跳转到我的报告
|
||||
this.$alert(
|
||||
'合同已提交审查,AI 正在分析中,预计 3-5 分钟完成。您可以在「我的报告」中查看审查结果。',
|
||||
'审查已提交',
|
||||
{
|
||||
confirmButtonText: '查看我的报告',
|
||||
callback: () => {
|
||||
this.menuSelect('5');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 重置上传区
|
||||
this.resetReview();
|
||||
},
|
||||
resetReview() {
|
||||
this.reviewFile = null;
|
||||
this.reviewFileId = null;
|
||||
this.reviewRecordId = null;
|
||||
this.reviewUploading = false;
|
||||
this.reviewResult = null;
|
||||
this.reviewProgress = 0;
|
||||
@@ -1847,15 +1988,11 @@
|
||||
},
|
||||
async doRecharge() {
|
||||
const pkg = this.rechargePackages[this.selectedRecharge];
|
||||
const priceMap = {
|
||||
'30元': 3000,
|
||||
'50元': 5000,
|
||||
'98元': 9800,
|
||||
'198元': 19800,
|
||||
'368元': 36800,
|
||||
'688元': 1
|
||||
};
|
||||
const price = priceMap[pkg.price] || 5000;
|
||||
const priceMap = {
|
||||
'20元': 2000, '100元': 10000, '200元': 20000,
|
||||
'900元': 90000, '2000元': 200000, '4000元': 400000
|
||||
}
|
||||
const price = priceMap[pkg.price] || 2000;
|
||||
const pointsMatch = pkg.points;
|
||||
|
||||
try {
|
||||
@@ -2739,6 +2876,17 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.2); }
|
||||
}
|
||||
.disclaimer-notice {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
background: #fff8e1; border: 1px solid #ffe082; border-radius: 8px;
|
||||
padding: 12px 16px; margin-bottom: 20px; font-size: 12px; color: #e6a23c;
|
||||
}
|
||||
.dn-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; }
|
||||
.dn-text { line-height: 1.6; }
|
||||
.points-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"triggers": [
|
||||
{
|
||||
"name": "http",
|
||||
"type": "http",
|
||||
"config": {
|
||||
"path": "/review-stream",
|
||||
"method": ["POST"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use strict';
|
||||
|
||||
exports.main = async (event, context) => {
|
||||
// uniCloud HTTP 触发器:event 本身就是请求信息
|
||||
// event.httpMethod = 'GET'/'POST'/'OPTIONS'
|
||||
// event.body = 请求体
|
||||
// event.headers = 请求头
|
||||
// context.callbackWaitsForEmptyEventLoop = true 保持连接
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400'
|
||||
};
|
||||
|
||||
// 处理 CORS 预检
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 204,
|
||||
headers: corsHeaders,
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
|
||||
// 设置等待空事件循环(保持 SSE 连接)
|
||||
context.callbackWaitsForEmptyEventLoop = false;
|
||||
|
||||
// 解析请求体
|
||||
let body = {};
|
||||
try {
|
||||
const raw = event.body || '';
|
||||
if (event.isBase64Encoded) {
|
||||
body = JSON.parse(Buffer.from(raw, 'base64').toString());
|
||||
} else {
|
||||
try { body = JSON.parse(raw); } catch (e) {
|
||||
const params = new URLSearchParams(raw);
|
||||
for (const [k, v] of params) body[k] = v;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const { file_id, user_id, file_name, file_size, record_id } = body;
|
||||
|
||||
if (!file_id) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ error: '缺少 file_id' })
|
||||
};
|
||||
}
|
||||
|
||||
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||||
const BOT_ID = '7689821072762011698';
|
||||
|
||||
const objectString = JSON.stringify([
|
||||
{ type: 'file', file_id: file_id },
|
||||
{ type: 'text', text: '请审查这份合同' }
|
||||
]);
|
||||
|
||||
// 调用扣子(流式)
|
||||
try {
|
||||
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||||
method: 'POST',
|
||||
dataType: 'text',
|
||||
contentType: 'json',
|
||||
timeout: 300000,
|
||||
headers: { 'Authorization': 'Bearer ' + COZE_TOKEN },
|
||||
data: {
|
||||
bot_id: BOT_ID,
|
||||
user_id: user_id || 'anonymous',
|
||||
stream: true,
|
||||
auto_save_history: true,
|
||||
additional_messages: [{
|
||||
role: 'user',
|
||||
content: objectString,
|
||||
content_type: 'object_string'
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
// 返回 SSE 格式
|
||||
const text = chatRes.data || '';
|
||||
const sseBody = text.split('\n').map(line => {
|
||||
if (line.startsWith('data:')) return line + '\n\n';
|
||||
return '';
|
||||
}).join('') + 'data: [DONE]\n\n';
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
},
|
||||
body: sseBody
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ error: e.message })
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "jjxReviewStream",
|
||||
"version": "1.0.0",
|
||||
"description": "合同审查流式输出云函数",
|
||||
"main": "index.js"
|
||||
}
|
||||
@@ -0,0 +1,922 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 合同审查报告 → Word(.docx) 生成器
|
||||
*
|
||||
* 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON
|
||||
* <script type="application/json" id="jjx-report-data">{...}</script>
|
||||
*
|
||||
* 排版目标:正式法律文书风格,A4 纵向,可直接打印装订
|
||||
*/
|
||||
|
||||
const {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
ShadingType,
|
||||
TableLayoutType,
|
||||
Footer,
|
||||
Header,
|
||||
PageNumber,
|
||||
VerticalAlign,
|
||||
ExternalHyperlink
|
||||
} = require('docx');
|
||||
|
||||
const FONT = 'Microsoft YaHei';
|
||||
const FONT_SONG = 'SimSun';
|
||||
|
||||
const C = {
|
||||
primary: '1A5276',
|
||||
danger: 'E74C3C',
|
||||
success: '27AE60',
|
||||
warning: 'F39C12',
|
||||
text: '222222',
|
||||
gray: '777777',
|
||||
lightBg: 'F5F7F9',
|
||||
pinkBg: 'FDF2F2',
|
||||
border: 'D0D7DE'
|
||||
};
|
||||
|
||||
const PAGE_BORDER = {
|
||||
top: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
left: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
right: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: C.border }
|
||||
};
|
||||
|
||||
// ---------- 基础元件 ----------
|
||||
|
||||
function run(text, o) {
|
||||
o = o || {};
|
||||
const opt = {
|
||||
text: String(text === null || text === undefined ? '' : text),
|
||||
font: o.font || FONT,
|
||||
size: o.size || 21,
|
||||
bold: !!o.bold,
|
||||
italics: !!o.italics,
|
||||
color: o.color || C.text
|
||||
};
|
||||
if (o.underline) opt.underline = {};
|
||||
return new TextRun(opt);
|
||||
}
|
||||
|
||||
function para(children, o) {
|
||||
o = o || {};
|
||||
const opt = {
|
||||
children: Array.isArray(children) ? children : [children],
|
||||
spacing: {
|
||||
before: o.before === undefined ? 60 : o.before,
|
||||
after: o.after === undefined ? 60 : o.after,
|
||||
line: o.line || 320
|
||||
}
|
||||
};
|
||||
if (o.alignment) opt.alignment = o.alignment;
|
||||
if (o.indent) opt.indent = o.indent;
|
||||
if (o.border) opt.border = o.border;
|
||||
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
|
||||
return new Paragraph(opt);
|
||||
}
|
||||
|
||||
function cell(children, o) {
|
||||
o = o || {};
|
||||
const opt = {
|
||||
children: Array.isArray(children) ? children : [children],
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
margins: { top: 60, bottom: 60, left: 110, right: 110 }
|
||||
};
|
||||
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
|
||||
if (o.width) opt.width = { size: o.width, type: WidthType.PERCENTAGE };
|
||||
if (o.columnSpan) opt.columnSpan = o.columnSpan;
|
||||
return new TableCell(opt);
|
||||
}
|
||||
|
||||
function table(rows, widths) {
|
||||
return new Table({
|
||||
rows: rows,
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
columnWidths: widths,
|
||||
layout: TableLayoutType.AUTOFIT,
|
||||
borders: PAGE_BORDER
|
||||
});
|
||||
}
|
||||
|
||||
function sectionTitle(no, text) {
|
||||
return para([run(no + '、' + text, { size: 28, bold: true, color: C.primary })], {
|
||||
before: 320,
|
||||
after: 140,
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: C.primary, space: 4 } }
|
||||
});
|
||||
}
|
||||
|
||||
function pick(obj, keys, dft) {
|
||||
if (!obj) return dft;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const v = obj[keys[i]];
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
}
|
||||
return dft;
|
||||
}
|
||||
|
||||
function asArray(v) {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
// ---------- 风险条目 ----------
|
||||
|
||||
function riskBlock(item, color, levelText) {
|
||||
const clause = pick(item, ['clause', 'no', '条款', 'clauseNo'], '');
|
||||
const issue = pick(item, ['issue', 'risk', 'problem', '描述', '问题'], '');
|
||||
const suggest = pick(item, ['suggestion', 'fix', 'advice', '建议', '修改建议'], '');
|
||||
const basis = pick(item, ['legal_basis', 'legalBasis', 'basis', '法律依据'], '');
|
||||
const blocks = [];
|
||||
|
||||
blocks.push(para([
|
||||
run('[' + levelText + ']', { bold: true, color: color, size: 21 }),
|
||||
run(clause ? ' 第 ' + clause + ' 条' : ' 合同条款', { bold: true, color: color, size: 21 })
|
||||
], { before: 140, after: 40 }));
|
||||
|
||||
blocks.push(para([
|
||||
run('问题:', { bold: true, size: 21 }),
|
||||
run(issue, { size: 21 })
|
||||
], { after: 40, indent: { left: 240 } }));
|
||||
|
||||
if (suggest) {
|
||||
blocks.push(para([
|
||||
run('建议:', { bold: true, size: 21 }),
|
||||
run(suggest, { size: 21 })
|
||||
], { after: 40, indent: { left: 240 } }));
|
||||
}
|
||||
|
||||
if (basis) {
|
||||
blocks.push(para([
|
||||
run('依据:', { bold: true, size: 21 }),
|
||||
run(basis, { size: 21, color: C.gray })
|
||||
], { after: 80, indent: { left: 240 } }));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function riskSection(items, color, levelText, emptyText) {
|
||||
const list = asArray(items);
|
||||
if (!list.length) {
|
||||
return [para([run(emptyText || '无', { size: 21, color: C.gray })], { after: 80 })];
|
||||
}
|
||||
let out = [];
|
||||
list.forEach(function (it) {
|
||||
out = out.concat(riskBlock(it, color, levelText));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- 主函数 ----------
|
||||
|
||||
async function buildDocx(input) {
|
||||
const data = input.data || {};
|
||||
const fileName = input.fileName || '合同审查报告';
|
||||
const contractName = pick(data, ['contract_name', 'contractName', '协议名称', 'name'], fileName.replace(/\.[^.]+$/, ''));
|
||||
const reportTitle = pick(data, ['report_title', 'reportTitle', '报告标题'], '合同审查报告');
|
||||
const reviewDate = pick(data, ['review_date', 'reviewDate', '审核日期'], new Date().toLocaleDateString('zh-CN'));
|
||||
const summary = pick(data, ['summary', '统计'], {}) || {};
|
||||
const conclusion = pick(data, ['conclusion', '审核结论', 'summary_text'], '');
|
||||
const highRisks = pick(data, ['high_risks', 'highRisks', '高风险'], []);
|
||||
const mediumRisks = pick(data, ['medium_risks', 'mediumRisks', '中风险'], []);
|
||||
const lowRisks = pick(data, ['low_risks', 'lowRisks', '低风险'], []);
|
||||
const suggestions = pick(data, ['suggestions', '优化建议', 'advice_table'], []);
|
||||
const templates = pick(data, ['clause_templates', 'clauseTemplates', '补充条款'], []);
|
||||
const legalBases = pick(data, ['legal_bases', 'legalBases', '法律依据'], []);
|
||||
const cases = pick(data, ['cases', '案例'], []);
|
||||
const disclaimer = pick(data, ['disclaimer', '免责声明'], '');
|
||||
|
||||
const nCompliant = pick(summary, ['compliant', '合规'], 0);
|
||||
const nPartial = pick(summary, ['partial', '部分合规'], 0);
|
||||
const nHigh = pick(summary, ['high', '高风险'], asArray(highRisks).length);
|
||||
const nMedium = pick(summary, ['medium', '中风险'], asArray(mediumRisks).length);
|
||||
|
||||
const children = [];
|
||||
|
||||
// ===== 抬头 =====
|
||||
children.push(para([run(reportTitle, { size: 44, bold: true, color: C.primary })], {
|
||||
alignment: AlignmentType.CENTER,
|
||||
before: 200,
|
||||
after: 120
|
||||
}));
|
||||
children.push(para([run(contractName, { size: 26, color: C.gray })], {
|
||||
alignment: AlignmentType.CENTER,
|
||||
after: 60
|
||||
}));
|
||||
children.push(para([run('审核日期:' + reviewDate + ' 出具机构:金久信签约宝', { size: 20, color: C.gray })], {
|
||||
alignment: AlignmentType.CENTER,
|
||||
after: 240
|
||||
}));
|
||||
|
||||
// ===== 目录 =====
|
||||
children.push(sectionTitle('一', '报告目录'));
|
||||
const tocItems = [
|
||||
'二、审核结论',
|
||||
'三、需处理问题(高风险 / 中风险)',
|
||||
'四、优化建议汇总表',
|
||||
'五、补充条款模板',
|
||||
'六、法律依据',
|
||||
'七、相关案例',
|
||||
'八、免责声明'
|
||||
];
|
||||
tocItems.forEach(function (item) {
|
||||
children.push(para([run(item, { size: 21 })], { after: 30, indent: { left: 240 } }));
|
||||
});
|
||||
|
||||
// ===== 审核结论 =====
|
||||
children.push(sectionTitle('二', '审核结论'));
|
||||
|
||||
const statRow = new TableRow({
|
||||
children: [
|
||||
cell([para([run(String(nCompliant), { size: 40, bold: true, color: C.success })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('合规条款', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'F0F9EB', width: 25 }),
|
||||
cell([para([run(String(nPartial), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('部分合规', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'FDF6EC', width: 25 }),
|
||||
cell([para([run(String(nHigh), { size: 40, bold: true, color: C.danger })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('高风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'FEF0F0', width: 25 }),
|
||||
cell([para([run(String(nMedium), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('中风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'FDF6EC', width: 25 })
|
||||
]
|
||||
});
|
||||
children.push(table([statRow], [2000, 2000, 2000, 2000]));
|
||||
|
||||
if (conclusion) {
|
||||
children.push(para([run('审核结论', { size: 21, bold: true, color: C.primary })], { before: 200, after: 80 }));
|
||||
String(conclusion).split(/\n+/).forEach(function (line) {
|
||||
if (line.trim()) {
|
||||
children.push(para([run(line.trim(), { size: 21 })], { after: 60, indent: { left: 240 } }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 需处理问题 =====
|
||||
children.push(sectionTitle('三', '需处理问题'));
|
||||
|
||||
children.push(para([run('(一)高风险问题', { size: 24, bold: true, color: C.danger })], { before: 140, after: 80 }));
|
||||
riskSection(highRisks, C.danger, '高风险', '本次审查未发现高风险问题。').forEach(function (p) { children.push(p); });
|
||||
|
||||
children.push(para([run('(二)中风险问题', { size: 24, bold: true, color: C.warning })], { before: 200, after: 80 }));
|
||||
riskSection(mediumRisks, C.warning, '中风险', '本次审查未发现中风险问题。').forEach(function (p) { children.push(p); });
|
||||
|
||||
if (asArray(lowRisks).length) {
|
||||
children.push(para([run('(三)低风险问题', { size: 24, bold: true, color: C.gray })], { before: 200, after: 80 }));
|
||||
riskSection(lowRisks, C.gray, '低风险', '无').forEach(function (p) { children.push(p); });
|
||||
}
|
||||
|
||||
// ===== 优化建议汇总表 =====
|
||||
children.push(sectionTitle('四', '优化建议汇总表'));
|
||||
const sugList = asArray(suggestions);
|
||||
if (!sugList.length) {
|
||||
children.push(para([run('无', { size: 21, color: C.gray })]));
|
||||
} else {
|
||||
const headRow = new TableRow({
|
||||
tableHeader: true,
|
||||
children: [
|
||||
cell([para([run('优先级', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 12 }),
|
||||
cell([para([run('条款编号', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 15 }),
|
||||
cell([para([run('问题描述', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 38 }),
|
||||
cell([para([run('建议方案', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 35 })
|
||||
]
|
||||
});
|
||||
const rows = [headRow];
|
||||
sugList.forEach(function (s, i) {
|
||||
const lv = String(pick(s, ['priority', 'level', '优先级'], 'M')).toUpperCase();
|
||||
const lvBg = lv === 'H' ? C.danger : (lv === 'L' ? '999999' : C.warning);
|
||||
rows.push(new TableRow({
|
||||
children: [
|
||||
cell([para([run(lv, { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: lvBg, width: 12 }),
|
||||
cell([para([run(pick(s, ['clause', 'no', '条款编号'], '-'), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 15 }),
|
||||
cell([para([run(pick(s, ['issue', 'problem', '问题描述', '描述'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 38 }),
|
||||
cell([para([run(pick(s, ['solution', 'suggestion', '建议方案', '建议'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 35 })
|
||||
]
|
||||
}));
|
||||
});
|
||||
children.push(table(rows, [1200, 1500, 3800, 3500]));
|
||||
}
|
||||
|
||||
// ===== 补充条款模板 =====
|
||||
children.push(sectionTitle('五', '补充条款模板'));
|
||||
const tplList = asArray(templates);
|
||||
if (!tplList.length) {
|
||||
children.push(para([run('无', { size: 21, color: C.gray })]));
|
||||
} else {
|
||||
tplList.forEach(function (t, i) {
|
||||
const title = pick(t, ['title', 'name', '条款名称'], '补充条款 ' + (i + 1));
|
||||
const content = pick(t, ['content', 'text', '条文', '内容'], '');
|
||||
children.push(para([run(String(i + 1) + '. ' + title, { size: 21, bold: true, color: C.primary })], { before: 140, after: 60 }));
|
||||
String(content).split(/\n+/).forEach(function (line) {
|
||||
if (line.trim()) {
|
||||
children.push(para([run(line.trim(), { size: 20, font: FONT_SONG })], {
|
||||
after: 50,
|
||||
indent: { left: 240 },
|
||||
shading: C.lightBg,
|
||||
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.primary, space: 6 } }
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 法律依据 =====
|
||||
children.push(sectionTitle('六', '法律依据'));
|
||||
const lawList = asArray(legalBases);
|
||||
if (!lawList.length) {
|
||||
children.push(para([run('本报告未引用具体法条,相关意见为实务建议,具体法律适用需律师人工核实。', { size: 20, color: C.gray })]));
|
||||
} else {
|
||||
lawList.forEach(function (l, i) {
|
||||
const law = pick(l, ['law', 'name', '法律名称'], '');
|
||||
const article = pick(l, ['article', 'no', '条款编号'], '');
|
||||
const content = pick(l, ['content', 'text', '引用内容', '内容'], '');
|
||||
children.push(para([
|
||||
run(String(i + 1) + '. ', { size: 20, bold: true }),
|
||||
run(law + (article ? ' ' + article : ''), { size: 20, bold: true, color: C.primary }),
|
||||
run(content ? ':' + content : '', { size: 20 })
|
||||
], { after: 60, indent: { left: 120 } }));
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 相关案例 =====
|
||||
children.push(sectionTitle('七', '相关案例'));
|
||||
const caseList = asArray(cases);
|
||||
if (!caseList.length) {
|
||||
children.push(para([run('暂未检索到公开案例,建议人工核实。', { size: 20, color: C.gray })]));
|
||||
} else {
|
||||
const caseHead = new TableRow({
|
||||
tableHeader: true,
|
||||
children: [
|
||||
cell([para([run('序号', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
|
||||
cell([para([run('案件名称·案由', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 26 }),
|
||||
cell([para([run('法院·机构', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 14 }),
|
||||
cell([para([run('年份', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
|
||||
cell([para([run('判决要点', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 28 }),
|
||||
cell([para([run('与风险点关联', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 16 })
|
||||
]
|
||||
});
|
||||
const caseRows = [caseHead];
|
||||
caseList.forEach(function (c, i) {
|
||||
const name = pick(c, ['name', 'title', '案件名称'], '-');
|
||||
const url = pick(c, ['url', 'link', '链接'], '');
|
||||
const court = pick(c, ['court', 'org', '法院'], '-');
|
||||
const year = pick(c, ['year', '年份'], '-');
|
||||
const result = pick(c, ['result', 'point', '判决要点', '要点'], '-');
|
||||
const relation = pick(c, ['relation', '关联', '与本次风险点关联'], '-');
|
||||
const nameCellChildren = [para([run(name, { size: 19, bold: true })], { after: url ? 20 : 0 })];
|
||||
if (url) {
|
||||
const host = url.replace(/^https?:\/\//i, '').split('/')[0];
|
||||
nameCellChildren.push(para([
|
||||
new ExternalHyperlink({
|
||||
link: url,
|
||||
children: [run('来源:' + host + ' ↗', { size: 16, color: '2E74B5', underline: true })]
|
||||
})
|
||||
], {}));
|
||||
}
|
||||
caseRows.push(new TableRow({
|
||||
children: [
|
||||
cell([para([run(String(i + 1), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
|
||||
cell(nameCellChildren, { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 26 }),
|
||||
cell([para([run(court, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 14 }),
|
||||
cell([para([run(year, { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
|
||||
cell([para([run(result, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 28 }),
|
||||
cell([para([run(relation, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 16 })
|
||||
]
|
||||
}));
|
||||
});
|
||||
children.push(table(caseRows, [700, 2300, 1300, 700, 2500, 1500]));
|
||||
}
|
||||
|
||||
// ===== 免责声明 =====
|
||||
children.push(sectionTitle('八', '免责声明'));
|
||||
const disText = disclaimer || '本报告由人工智能根据用户上传的合同文本自动生成,仅供内部风险排查参考,不构成正式法律意见,亦不能替代执业律师的专业审查。涉及重大交易、争议解决或诉讼事项,请务必委托执业律师复核。报告中的法律法规引用以官方公布文本为准,案例信息来源于公开检索结果,具体适用请人工核实。';
|
||||
String(disText).split(/\n+/).forEach(function (line) {
|
||||
if (line.trim()) {
|
||||
children.push(para([run(line.trim(), { size: 19, color: C.gray })], {
|
||||
after: 50,
|
||||
shading: C.pinkBg,
|
||||
indent: { left: 120, right: 120 },
|
||||
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.danger, space: 6 } }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 文档 =====
|
||||
const doc = new Document({
|
||||
creator: '金久信签约宝',
|
||||
title: contractName + ' - 合同审查报告',
|
||||
description: 'AI 合同审查报告',
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: { font: FONT, size: 21, color: C.text }
|
||||
}
|
||||
}
|
||||
},
|
||||
sections: [{
|
||||
properties: {
|
||||
page: {
|
||||
margin: { top: 1134, right: 1134, bottom: 1134, left: 1134 }
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
para([
|
||||
run('金久信签约宝 · AI 合同审查报告', { size: 18, color: C.gray }),
|
||||
run(' ' + contractName, { size: 18, color: C.gray })
|
||||
], { alignment: AlignmentType.RIGHT, after: 0 })
|
||||
]
|
||||
})
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({ text: '第 ', font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ text: ' 页 / 共 ', font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ text: ' 页', font: FONT, size: 18, color: C.gray })
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
},
|
||||
children: children
|
||||
}]
|
||||
});
|
||||
|
||||
return await Packer.toBuffer(doc);
|
||||
}
|
||||
|
||||
// ---------- HTML 解析(生产数据兜底通道) ----------
|
||||
// 智能体实际返回的是纯 HTML 报告,未必带 jjx-report-data 数据块,
|
||||
// 这里按区块结构把 HTML 还原成结构化数据,无需修改智能体提示词。
|
||||
|
||||
function decodeEntities(s) {
|
||||
return String(s || '')
|
||||
.replace(/ /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
|
||||
};
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
'use strict';
|
||||
|
||||
// 定时任务:每分钟检查 reviewing 状态的记录,触发审查
|
||||
const db = uniCloud.database();
|
||||
const _ = db.command;
|
||||
|
||||
// 导入审查执行函数(从 router 的 service 目录)
|
||||
// 注意:定时任务是独立云函数,不能直接 require router 的 service
|
||||
// 所以把审查逻辑直接写在这里
|
||||
|
||||
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||||
const BOT_ID = '7689821072762011698';
|
||||
|
||||
const { buildDocx, extractReportData, extractDocxLink, isDocxConvertible } = require('./buildDocx.js');
|
||||
|
||||
async function doReview(record) {
|
||||
const { _id: record_id, file_id, user_id, file_name, file_size } = record;
|
||||
|
||||
const objectString = JSON.stringify([
|
||||
{ type: 'file', file_id: file_id },
|
||||
{ type: 'text', text: '请审查这份合同' }
|
||||
]);
|
||||
|
||||
try {
|
||||
// 1. 非流式发起对话(立即返回 chat_id)
|
||||
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||||
method: 'POST',
|
||||
dataType: 'json',
|
||||
contentType: 'json',
|
||||
timeout: 30000,
|
||||
headers: { 'Authorization': 'Bearer ' + COZE_TOKEN },
|
||||
data: {
|
||||
bot_id: BOT_ID,
|
||||
user_id: user_id || 'anonymous',
|
||||
stream: false,
|
||||
auto_save_history: true,
|
||||
additional_messages: [{
|
||||
role: 'user',
|
||||
content: objectString,
|
||||
content_type: 'object_string'
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
const chatData = chatRes.data && chatRes.data.data;
|
||||
const chatId = chatData && chatData.id;
|
||||
const conversationId = chatData && chatData.conversation_id;
|
||||
console.log('[审查] chat_id:', chatId, 'conversation_id:', conversationId);
|
||||
|
||||
if (!chatId) {
|
||||
throw new Error('未获取到 chat_id: ' + JSON.stringify(chatRes.data));
|
||||
}
|
||||
|
||||
// 保存 chat_id 和 conversation_id 到数据库
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
chat_id: chatId,
|
||||
conversation_id: conversationId || '',
|
||||
update_time: new Date()
|
||||
});
|
||||
console.log('[审查] 已保存 chat_id:', chatId);
|
||||
|
||||
// 2. 轮询对话状态(每5秒查一次,最多5分钟)
|
||||
let answer = '';
|
||||
let tokenCount = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
const statusRes = await uniCloud.httpclient.request(
|
||||
'https://api.coze.cn/v3/chat/retrieve?chat_id=' + chatId + '&conversation_id=' + conversationId,
|
||||
{ method: 'GET', dataType: 'json', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
|
||||
);
|
||||
|
||||
const status = statusRes.data && statusRes.data.data && statusRes.data.data.status;
|
||||
console.log('[审查] 第', i + 1, '次轮询, 状态:', status);
|
||||
|
||||
if (status === 'completed') {
|
||||
// 保存 token 消耗信息
|
||||
const usage = statusRes.data && statusRes.data.data && statusRes.data.data.usage;
|
||||
tokenCount = (usage && usage.token_count) || 0;
|
||||
console.log('[审查] Token 消耗:', tokenCount);
|
||||
|
||||
// 3. 获取消息列表
|
||||
const msgRes = await uniCloud.httpclient.request(
|
||||
'https://api.coze.cn/v3/chat/message/list?chat_id=' + chatId + '&conversation_id=' + conversationId,
|
||||
{ method: 'GET', dataType: 'json', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
|
||||
);
|
||||
|
||||
const messages = msgRes.data && msgRes.data.data;
|
||||
if (Array.isArray(messages)) {
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'answer' && msg.role === 'assistant' && msg.content) {
|
||||
answer = msg.content; // answer 类型的完整内容
|
||||
}
|
||||
if (msg.type === 'tool_response' && msg.content) {
|
||||
try {
|
||||
const toolData = JSON.parse(msg.content);
|
||||
if (toolData.url) answer += '\n\n📎 报告下载:' + toolData.url;
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
throw new Error('对话失败');
|
||||
}
|
||||
}
|
||||
|
||||
if (!answer) answer = '审查未能获取结果,请稍后重试';
|
||||
console.log('[审查] 结果长度:', answer.length);
|
||||
|
||||
// 计算消耗积分
|
||||
// 积分 = token消耗 / 100(最低1积分)
|
||||
const pointsUsed = Math.max(1, Math.round(tokenCount / 100));
|
||||
console.log('[积分] token_count:', tokenCount, '→ 扣除积分:', pointsUsed);
|
||||
|
||||
// 扣积分
|
||||
const userPoints = await db.collection('jjx-user-points').doc(user_id).get();
|
||||
if (userPoints.data.length && userPoints.data[0].balance >= pointsUsed) {
|
||||
await db.collection('jjx-user-points').doc(user_id).update({
|
||||
balance: _.inc(-pointsUsed),
|
||||
total_spent: _.inc(pointsUsed),
|
||||
update_time: new Date()
|
||||
});
|
||||
const updatedPoints = await db.collection('jjx-user-points').doc(user_id).get();
|
||||
await db.collection('jjx-points-log').add({
|
||||
user_id: user_id,
|
||||
type: 'spend',
|
||||
amount: -pointsUsed,
|
||||
balance_after: updatedPoints.data[0] ? updatedPoints.data[0].balance : 0,
|
||||
reason: '合同审查 — ' + (file_name || '') + '(' + tokenCount + ' token)',
|
||||
biz_type: 'contract_review',
|
||||
biz_id: record_id,
|
||||
_add_time: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// 保存 HTML 报告到云存储
|
||||
let htmlUrl = '';
|
||||
try {
|
||||
let htmlContent = answer;
|
||||
const htmlMatch = answer.match(/```html\s*([\s\S]*?)```/);
|
||||
if (htmlMatch && htmlMatch[1]) htmlContent = htmlMatch[1].trim();
|
||||
|
||||
if (htmlContent.includes('<!DOCTYPE') || htmlContent.includes('<html')) {
|
||||
const cloudPath = 'review-reports/' + Date.now() + '_' + (file_name || 'report').replace(/\.[^.]+$/, '') + '.html';
|
||||
const uploadRes = await uniCloud.uploadFile({
|
||||
cloudPath: cloudPath,
|
||||
fileContent: Buffer.from(htmlContent, 'utf-8')
|
||||
});
|
||||
if (uploadRes.fileID) {
|
||||
const urlRes = await uniCloud.getTempFileURL({ fileList: [uploadRes.fileID] });
|
||||
if (urlRes.fileList && urlRes.fileList[0]) htmlUrl = urlRes.fileList[0].tempFileURL;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('HTML报告上传失败:', e.message);
|
||||
}
|
||||
|
||||
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
|
||||
let docxUrl = extractDocxLink(answer);
|
||||
let docxFileId = '';
|
||||
if (docxUrl) {
|
||||
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
|
||||
} else if (!isDocxConvertible(answer)) {
|
||||
// 智能体没返回完整报告(如「文件解析失败」的道歉文本,或 HTML 被截断),
|
||||
// 不生成 Word,避免产出一份残缺文档
|
||||
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
|
||||
} else {
|
||||
try {
|
||||
const reportData = extractReportData(answer);
|
||||
if (!reportData) {
|
||||
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
|
||||
} else {
|
||||
const docxBuffer = await buildDocx({
|
||||
data: reportData,
|
||||
fileName: file_name || '合同审查报告'
|
||||
});
|
||||
const baseName = (file_name || 'report').replace(/\.[^.]+$/, '');
|
||||
const docxPath = 'review-reports/' + Date.now() + '_' + baseName + '_审查报告.docx';
|
||||
const docxRes = await uniCloud.uploadFile({
|
||||
cloudPath: docxPath,
|
||||
fileContent: docxBuffer
|
||||
});
|
||||
if (docxRes.fileID) {
|
||||
docxFileId = docxRes.fileID;
|
||||
const docxUrlRes = await uniCloud.getTempFileURL({ fileList: [docxRes.fileID] });
|
||||
if (docxUrlRes.fileList && docxUrlRes.fileList[0]) {
|
||||
docxUrl = docxUrlRes.fileList[0].tempFileURL;
|
||||
}
|
||||
console.log('[Docx] 本地 Word 报告生成成功:', docxFileId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Docx] Word 报告生成失败(不影响审查结果):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新记录为完成
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'completed',
|
||||
result: answer,
|
||||
html_url: htmlUrl,
|
||||
docx_url: docxUrl,
|
||||
docx_file_id: docxFileId,
|
||||
token_count: tokenCount,
|
||||
points_used: pointsUsed,
|
||||
conversation_id: conversationId || '',
|
||||
update_time: new Date()
|
||||
});
|
||||
|
||||
console.log('审查完成:', record_id, file_name);
|
||||
return { code: 0, msg: '审查完成' };
|
||||
|
||||
} catch (e) {
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'failed',
|
||||
result: '审查失败: ' + e.message,
|
||||
error: e.message,
|
||||
update_time: new Date()
|
||||
});
|
||||
console.error('审查失败:', record_id, e.message);
|
||||
return { code: -1, msg: '审查失败: ' + e.message };
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = async (event, context) => {
|
||||
// 先把卡在 processing 超过10分钟的记录重置为 reviewing(超时保护)
|
||||
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
|
||||
const stuckRecords = await db.collection('jjx-review-records')
|
||||
.where({ status: 'processing', update_time: _.lt(tenMinutesAgo) })
|
||||
.get();
|
||||
for (const stuck of stuckRecords.data) {
|
||||
await db.collection('jjx-review-records').doc(stuck._id).update({
|
||||
status: 'reviewing',
|
||||
update_time: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// 查询所有 reviewing 状态的记录(超过1分钟的,避免重复触发)
|
||||
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||||
|
||||
const pendingRecords = await db.collection('jjx-review-records')
|
||||
.where({
|
||||
status: 'reviewing',
|
||||
review_started_at: _.lt(oneMinuteAgo)
|
||||
})
|
||||
.limit(5) // 每次最多处理5条
|
||||
.get();
|
||||
|
||||
if (!pendingRecords.data.length) {
|
||||
return { code: 0, msg: '无待处理记录' };
|
||||
}
|
||||
|
||||
console.log('发现待处理审查记录:', pendingRecords.data.length, '条');
|
||||
|
||||
// 逐条处理(串行,避免并发问题)
|
||||
const results = [];
|
||||
for (const record of pendingRecords.data) {
|
||||
// 先标记为 processing,避免重复触发
|
||||
await db.collection('jjx-review-records').doc(record._id).update({
|
||||
status: 'processing',
|
||||
update_time: new Date()
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await doReview(record);
|
||||
results.push({ record_id: record._id, ...result });
|
||||
} catch (e) {
|
||||
// 审查失败,重置为 reviewing 以便下次重试
|
||||
await db.collection('jjx-review-records').doc(record._id).update({
|
||||
status: 'reviewing',
|
||||
update_time: new Date()
|
||||
});
|
||||
console.error('审查异常,已重置状态:', record._id, e.message);
|
||||
results.push({ record_id: record._id, code: -1, msg: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { code: 0, msg: '处理完成', data: results };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// 本文件中的json内容将在云函数【运行】时作为参数传给云函数。
|
||||
// 配置教程参考:https://uniapp.dcloud.net.cn/uniCloud/rundebug.html#runparam
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 本地自测脚本:智能体返回的 HTML 审查报告 → Word 文档
|
||||
* 纯本地运行,不依赖 uniCloud,不联网,不动数据库。
|
||||
*
|
||||
* 用法(在本目录下执行):
|
||||
* node local-test.js # 用内置样例跑一遍,验证环境
|
||||
* node local-test.js report.html # 转换指定 HTML(输出到同目录)
|
||||
* node local-test.js report.html out.docx # 指定输出路径
|
||||
* node local-test.js dir/ # 批量转换目录下所有 .html/.txt
|
||||
* node local-test.js report.html --json # 只打印解析结果,不生成 Word
|
||||
* node local-test.js report.html --open # 生成后自动用 Word 打开
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
const { buildDocx, parseHtmlReport, extractDocxLink, isDocxConvertible } = require('./buildDocx.js');
|
||||
|
||||
// ==================== 内置样例(覆盖全部区块,用于环境自检) ====================
|
||||
const SAMPLE_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><title>样例合同审查报告</title></head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>技术开发与服务合同审查报告</h1>
|
||||
<p>合同名称:金久信钢城平台AI功能接入开发及运维服务合同</p>
|
||||
<p>审核日期:2026年10月23日</p>
|
||||
</div>
|
||||
<div class="card-wrap">
|
||||
<div class="stat-card green"><div class="num">0</div><div class="label">合规</div></div>
|
||||
<div class="stat-card orange"><div class="num">C</div><div class="label">部分合规</div></div>
|
||||
<div class="stat-card red"><div class="num">2</div><div class="label">高风险</div></div>
|
||||
<div class="stat-card yellow"><div class="num">1</div><div class="label">中风险</div></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>审核结论</h2>
|
||||
<p>本合同核心架构完整,但存在多处核心条款空白,整体风险等级为中高风险,需补充完善后方可签署。</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>需处理问题</h2>
|
||||
<h3>🔴 高风险问题</h3>
|
||||
<div class="risk-item high">
|
||||
<div class="risk-clause"><span class="risk-tag high">H</span>条款2.3 交付清单、全部附件空白</div>
|
||||
<div class="risk-desc">合同第2.3条交付清单表格完全空白,未明确交付物名称与交付时间,极易引发履约争议。</div>
|
||||
<div class="risk-fix">修复建议:详细填写交付清单,明确每项交付物的名称、数量、交付时间。</div>
|
||||
</div>
|
||||
<div class="risk-item high">
|
||||
<div class="risk-clause"><span class="risk-tag high">H</span>合同首部多项核心字段空白</div>
|
||||
<div class="risk-desc">合同名称、签订地点、合同金额等核心字段均为空白,属于典型的空白合同。</div>
|
||||
<div class="risk-fix">修复建议:将所有空白字段填写完整,无需填写的标注"无"。</div>
|
||||
</div>
|
||||
<h3>🟠 中风险问题</h3>
|
||||
<div class="risk-item medium">
|
||||
<div class="risk-clause"><span class="risk-tag medium">M</span>条款9 违约责任约定过于笼统</div>
|
||||
<div class="risk-desc">违约责任仅约定逾期违约金,未约定交付不合格、侵犯知识产权等情形。</div>
|
||||
<div class="risk-fix">修复建议:补充交付不合格、侵权、擅自转包的违约责任。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>优化建议汇总表</h2>
|
||||
<table>
|
||||
<thead><tr><th>优先级</th><th>条款编号</th><th>问题描述</th><th>建议方案</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>H</td><td>2.3</td><td>交付清单空白</td><td>详细填写交付清单</td></tr>
|
||||
<tr><td>H</td><td>合同首部</td><td>核心字段空白</td><td>填写所有空白字段</td></tr>
|
||||
<tr><td>M</td><td>9</td><td>违约责任笼统</td><td>补充违约情形</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>补充条款模板</h2>
|
||||
<div class="clause-template"><h3>转包限制条款</h3>
|
||||
<pre>未经甲方书面同意,乙方不得将本合同项下的任何工作转包或分包给第三方,否则视为乙方根本违约。</pre></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>法律依据</h2>
|
||||
<ul>
|
||||
<li>《民法典》第510条:合同生效后,当事人就质量、价款或者报酬等内容没有约定或者约定不明确的,可以协议补充。</li>
|
||||
<li>《民法典》第577条:当事人一方不履行合同义务或者履行合同义务不符合约定的,应当承担违约责任。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>相关司法案例</h2>
|
||||
<table>
|
||||
<thead><tr><th>序号</th><th>案件名称·案由</th><th>资料链接</th><th>法院·机构</th><th>裁判年份</th><th>判决要点</th><th>关联风险点</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>1</td><td>空白合同效力认定纠纷</td>
|
||||
<td><a class="case-link" href="https://example.com/case-1" target="_blank">打开裁判页面 ↗</a></td>
|
||||
<td>最高人民法院</td><td>2019年</td><td>交付空白合同视为概括授权</td><td>空白合同字段风险</td></tr>
|
||||
<tr><td>2</td><td>技术开发合同交付约定不明纠纷</td>
|
||||
<td><a class="case-link" href="https://example.com/case-2" target="_blank">打开裁判页面 ↗</a></td>
|
||||
<td>某中级人民法院</td><td>2021年</td><td>约定不明按交易习惯确定</td><td>交付方式不明风险</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="disclaimer">本报告仅基于您提供的合同文本出具,不构成任何法律意见。</div>
|
||||
<div class="footer">金久信签约宝 · AI合同审查服务 © 2026 | 生成时间:2026年10月23日</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ==================== 用例:智能体返回「文件读不了」的道歉文本 ====================
|
||||
// 这种回复里没有报告,线上应当跳过 Word 生成,而不是产出一份空文档
|
||||
const APOLOGY_TEXT = '很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败,暂时无法对该文件开展审查工作。建议您选择以下任一方式解决: 1. 将该DOC文件转换为DOCX、PDF、TXT等兼容性更好的格式后重新上传; 2. 直接粘贴合同的完整文本内容发送给我,我会立即为您生成专业的合同审查报告。';
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/** 清洗智能体回复:去掉 markdown 包裹、前后说明文字,只留 HTML 本体 */
|
||||
function cleanAnswer(text) {
|
||||
let s = String(text || '');
|
||||
const fence = s.match(/```(?:html)?\s*([\s\S]*?)```/i);
|
||||
if (fence && fence[1] && /<(!DOCTYPE|html)/i.test(fence[1])) s = fence[1];
|
||||
const start = s.search(/<!DOCTYPE\s+html|<html[\s>]/i);
|
||||
if (start > 0) s = s.slice(start);
|
||||
const end = s.toLowerCase().lastIndexOf('</html>');
|
||||
if (end > 0) s = s.slice(0, end + 7);
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
function pad(s, n) {
|
||||
const str = String(s);
|
||||
// 中文按 2 宽度算
|
||||
let w = 0;
|
||||
for (const ch of str) w += /[\u4e00-\u9fa5\uff00-\uffef]/.test(ch) ? 2 : 1;
|
||||
return str + ' '.repeat(Math.max(0, n - w));
|
||||
}
|
||||
|
||||
function line(label, value) {
|
||||
console.log(' ' + pad(label, 14) + ': ' + value);
|
||||
}
|
||||
|
||||
/** 打印解析结果摘要 */
|
||||
function summarize(data) {
|
||||
if (!data) {
|
||||
console.log(' ✗ 解析失败:未提取到任何有效内容(结论/风险/建议全空)');
|
||||
return false;
|
||||
}
|
||||
const s = data.summary || {};
|
||||
line('报告标题', data.report_title || '(空)');
|
||||
line('合同名称', data.contract_name || '(空)');
|
||||
line('审核日期', data.review_date || '(空)');
|
||||
line('统计卡片', `合规 ${s.compliant || 0} / 部分合规 ${s.partial || 0} / 高风险 ${s.high || 0} / 中风险 ${s.medium || 0}`);
|
||||
line('风险项', `高 ${(data.high_risks || []).length} / 中 ${(data.medium_risks || []).length} / 低 ${(data.low_risks || []).length}`);
|
||||
line('优化建议', (data.suggestions || []).length + ' 条');
|
||||
line('补充条款', (data.clause_templates || []).length + ' 条');
|
||||
line('法律依据', (data.legal_bases || []).length + ' 条');
|
||||
line('案例', (data.cases || []).length + ' 个');
|
||||
line('免责声明', data.disclaimer ? `已提取(${data.disclaimer.length} 字)` : '(空)');
|
||||
|
||||
const cases = data.cases || [];
|
||||
if (cases.length) {
|
||||
console.log(' --- 案例链接核对 ---');
|
||||
cases.forEach((c, i) => {
|
||||
const url = c.url ? c.url : '(无链接)';
|
||||
console.log(` ${i + 1}. ${c.name || '-'}`);
|
||||
console.log(` 法院/年份: ${c.court || '-'} / ${c.year || '-'}`);
|
||||
console.log(` 链接: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
const risks = [...(data.high_risks || []), ...(data.medium_risks || []), ...(data.low_risks || [])];
|
||||
if (risks.length) {
|
||||
console.log(' --- 风险条款号 ---');
|
||||
risks.forEach((r, i) => {
|
||||
console.log(` ${i + 1}. [${r.level || '-'}] ${r.clause || '-'} — ${(r.issue || '').slice(0, 40)}`);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 转换单个 HTML 文件/字符串 */
|
||||
async function convert(label, html, outPath) {
|
||||
console.log('');
|
||||
console.log('════════════════════════════════════════');
|
||||
console.log('▶ ' + label);
|
||||
console.log('════════════════════════════════════════');
|
||||
|
||||
const cleaned = cleanAnswer(html);
|
||||
if (!cleaned) {
|
||||
console.log(' ✗ 内容为空');
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
// 通道判断(与线上逻辑一致)
|
||||
const pluginLink = extractDocxLink(cleaned);
|
||||
if (pluginLink) {
|
||||
console.log(' ⚑ 检测到智能体插件返回的 Word 链接:');
|
||||
console.log(' ' + pluginLink);
|
||||
console.log(' (线上会直接使用该链接,不本地生成)');
|
||||
if (!/<!DOCTYPE\s+html|<html[\s>]/i.test(cleaned)) {
|
||||
console.log(' ⊘ 回复里没有 HTML 报告,线上直接复用插件链接,不做本地转换');
|
||||
return 'skip';
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' 原文长度: ' + cleaned.length + ' 字符');
|
||||
|
||||
// 关键拦截:智能体有时不返回报告,只回一段道歉/澄清文字
|
||||
// (例如「很抱歉,当前系统多次尝试读取您上传的DOC格式合同文件均解析失败…」)
|
||||
// 线上此时不生成 Word,这里保持一致,避免产出一份残缺文档。
|
||||
if (!isDocxConvertible(cleaned)) {
|
||||
console.log(' ⊘ 这不是一份完整报告,线上会跳过 Word 生成(不产出空文档)');
|
||||
console.log(' 开头内容:' + cleaned.slice(0, 70).replace(/\s+/g, ' ') + '…');
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
const data = parseHtmlReport(cleaned);
|
||||
const ok = summarize(data);
|
||||
if (!ok) return 'fail';
|
||||
|
||||
if (FLAGS.jsonOnly) {
|
||||
console.log(' (--json 模式,未生成 Word)');
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const buffer = await buildDocx({ data: data, fileName: data.contract_name || '合同审查报告' });
|
||||
const ms = Date.now() - t0;
|
||||
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, buffer);
|
||||
|
||||
console.log(' ✓ Word 已生成');
|
||||
line('输出文件', outPath);
|
||||
line('文件大小', (buffer.length / 1024).toFixed(1) + ' KB');
|
||||
line('耗时', ms + ' ms');
|
||||
|
||||
if (FLAGS.open) {
|
||||
exec(`start "" "${outPath}"`, { shell: 'cmd.exe' });
|
||||
console.log(' → 已调用系统默认程序打开');
|
||||
}
|
||||
return 'done';
|
||||
}
|
||||
|
||||
// ==================== 主流程 ====================
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const FLAGS = {
|
||||
jsonOnly: argv.includes('--json'),
|
||||
open: argv.includes('--open'),
|
||||
help: argv.includes('-h') || argv.includes('--help')
|
||||
};
|
||||
const positional = argv.filter((a) => !a.startsWith('-'));
|
||||
|
||||
(async function main() {
|
||||
if (FLAGS.help) {
|
||||
console.log(`
|
||||
本地自测:HTML 审查报告 → Word
|
||||
|
||||
node local-test.js 用内置样例自检
|
||||
node local-test.js report.html 转换指定文件(输出到同目录)
|
||||
node local-test.js report.html out.docx 指定输出路径
|
||||
node local-test.js someDir/ 批量转换目录下 .html / .txt
|
||||
node local-test.js report.html --json 只看解析结果
|
||||
node local-test.js report.html --open 生成后自动打开
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
let ok = 0;
|
||||
let skip = 0;
|
||||
let fail = 0;
|
||||
|
||||
const tally = (r) => {
|
||||
if (r === 'done') ok++;
|
||||
else if (r === 'skip') skip++;
|
||||
else fail++;
|
||||
};
|
||||
|
||||
try {
|
||||
if (!positional.length) {
|
||||
// 三个内置用例,覆盖「能转」和「不该转」两类情况
|
||||
tally(await convert(
|
||||
'用例1:正常 HTML 报告(应生成 Word)',
|
||||
SAMPLE_HTML,
|
||||
path.join(os.tmpdir(), 'jjx_本地自测_审查报告.docx')
|
||||
));
|
||||
|
||||
tally(await convert(
|
||||
'用例2:智能体只回「文件解析失败」道歉文本(应跳过)',
|
||||
APOLOGY_TEXT,
|
||||
path.join(os.tmpdir(), 'jjx_用例2_不应生成.docx')
|
||||
));
|
||||
|
||||
tally(await convert(
|
||||
'用例3:HTML 被截断、缺 </html>(应跳过,不产出残缺文档)',
|
||||
SAMPLE_HTML.slice(0, 1800),
|
||||
path.join(os.tmpdir(), 'jjx_用例3_不应生成.docx')
|
||||
));
|
||||
} else {
|
||||
const target = positional[0];
|
||||
const stat = fs.statSync(target);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const files = fs.readdirSync(target)
|
||||
.filter((f) => /\.(html?|txt)$/i.test(f))
|
||||
.map((f) => path.join(target, f));
|
||||
if (!files.length) {
|
||||
console.log('目录下没有 .html / .txt 文件:' + target);
|
||||
return;
|
||||
}
|
||||
console.log(`批量转换:共 ${files.length} 个文件`);
|
||||
for (const f of files) {
|
||||
const out = path.join(target, path.basename(f).replace(/\.[^.]+$/, '') + '_审查报告.docx');
|
||||
try {
|
||||
tally(await convert(path.basename(f), fs.readFileSync(f, 'utf8'), out));
|
||||
} catch (e) {
|
||||
console.log(' ✗ 异常:' + e.message);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const out = positional[1] || path.join(
|
||||
path.dirname(target),
|
||||
path.basename(target).replace(/\.[^.]+$/, '') + '_审查报告.docx'
|
||||
);
|
||||
tally(await convert(path.basename(target), fs.readFileSync(target, 'utf8'), out));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('');
|
||||
console.log('✗ 运行失败:' + e.message);
|
||||
if (e.code === 'ENOENT') console.log(' 提示:文件路径不存在,注意用引号包裹含空格的路径');
|
||||
if (e.code === 'MODULE_NOT_FOUND') console.log(' 提示:先执行 npm install 安装 docx 依赖');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('════════════════════════════════════════');
|
||||
console.log(`完成:生成 ${ok} 个,跳过 ${skip} 个,失败 ${fail} 个`);
|
||||
if (fail > 0) console.log('提示:解析失败通常是 HTML 结构与预期不符,把该文件发我看看。');
|
||||
console.log('════════════════════════════════════════');
|
||||
})();
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "jjxReviewTimer",
|
||||
"version": "1.0.0",
|
||||
"description": "合同审查定时任务",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"docx": "^8.5.0"
|
||||
},
|
||||
"cloudfunction-config": {
|
||||
"memorySize": 256,
|
||||
"timeout": 600,
|
||||
"triggers": [
|
||||
{
|
||||
"name": "myTrigger",
|
||||
"type": "timer",
|
||||
"config": "0 */1 * * * * *"
|
||||
}
|
||||
],
|
||||
"path": "",
|
||||
"runtime": "Nodejs16"
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"concurrency": 1,
|
||||
"memorySize": 512,
|
||||
"path": "/http/router",
|
||||
"timeout": 60,
|
||||
"timeout": 600,
|
||||
"triggers": [],
|
||||
"keepRunningAfterReturn": false
|
||||
},
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 合同审查报告 → Word(.docx) 生成器
|
||||
*
|
||||
* 数据来源:智能体在 HTML 报告末尾输出的结构化 JSON
|
||||
* <script type="application/json" id="jjx-report-data">{...}</script>
|
||||
*
|
||||
* 排版目标:正式法律文书风格,A4 纵向,可直接打印装订
|
||||
*/
|
||||
|
||||
const {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
ShadingType,
|
||||
TableLayoutType,
|
||||
Footer,
|
||||
Header,
|
||||
PageNumber,
|
||||
VerticalAlign,
|
||||
ExternalHyperlink
|
||||
} = require('docx');
|
||||
|
||||
const FONT = 'Microsoft YaHei';
|
||||
const FONT_SONG = 'SimSun';
|
||||
|
||||
const C = {
|
||||
primary: '1A5276',
|
||||
danger: 'E74C3C',
|
||||
success: '27AE60',
|
||||
warning: 'F39C12',
|
||||
text: '222222',
|
||||
gray: '777777',
|
||||
lightBg: 'F5F7F9',
|
||||
pinkBg: 'FDF2F2',
|
||||
border: 'D0D7DE'
|
||||
};
|
||||
|
||||
const PAGE_BORDER = {
|
||||
top: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
left: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
right: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: C.border },
|
||||
insideVertical: { style: BorderStyle.SINGLE, size: 4, color: C.border }
|
||||
};
|
||||
|
||||
// ---------- 基础元件 ----------
|
||||
|
||||
function run(text, o) {
|
||||
o = o || {};
|
||||
const opt = {
|
||||
text: String(text === null || text === undefined ? '' : text),
|
||||
font: o.font || FONT,
|
||||
size: o.size || 21,
|
||||
bold: !!o.bold,
|
||||
italics: !!o.italics,
|
||||
color: o.color || C.text
|
||||
};
|
||||
if (o.underline) opt.underline = {};
|
||||
return new TextRun(opt);
|
||||
}
|
||||
|
||||
function para(children, o) {
|
||||
o = o || {};
|
||||
const opt = {
|
||||
children: Array.isArray(children) ? children : [children],
|
||||
spacing: {
|
||||
before: o.before === undefined ? 60 : o.before,
|
||||
after: o.after === undefined ? 60 : o.after,
|
||||
line: o.line || 320
|
||||
}
|
||||
};
|
||||
if (o.alignment) opt.alignment = o.alignment;
|
||||
if (o.indent) opt.indent = o.indent;
|
||||
if (o.border) opt.border = o.border;
|
||||
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
|
||||
return new Paragraph(opt);
|
||||
}
|
||||
|
||||
function cell(children, o) {
|
||||
o = o || {};
|
||||
const opt = {
|
||||
children: Array.isArray(children) ? children : [children],
|
||||
verticalAlign: VerticalAlign.CENTER,
|
||||
margins: { top: 60, bottom: 60, left: 110, right: 110 }
|
||||
};
|
||||
if (o.shading) opt.shading = { type: ShadingType.CLEAR, fill: o.shading, color: 'auto' };
|
||||
if (o.width) opt.width = { size: o.width, type: WidthType.PERCENTAGE };
|
||||
if (o.columnSpan) opt.columnSpan = o.columnSpan;
|
||||
return new TableCell(opt);
|
||||
}
|
||||
|
||||
function table(rows, widths) {
|
||||
return new Table({
|
||||
rows: rows,
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
columnWidths: widths,
|
||||
layout: TableLayoutType.AUTOFIT,
|
||||
borders: PAGE_BORDER
|
||||
});
|
||||
}
|
||||
|
||||
function sectionTitle(no, text) {
|
||||
return para([run(no + '、' + text, { size: 28, bold: true, color: C.primary })], {
|
||||
before: 320,
|
||||
after: 140,
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: C.primary, space: 4 } }
|
||||
});
|
||||
}
|
||||
|
||||
function pick(obj, keys, dft) {
|
||||
if (!obj) return dft;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const v = obj[keys[i]];
|
||||
if (v !== undefined && v !== null && v !== '') return v;
|
||||
}
|
||||
return dft;
|
||||
}
|
||||
|
||||
function asArray(v) {
|
||||
if (!v) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
// ---------- 风险条目 ----------
|
||||
|
||||
function riskBlock(item, color, levelText) {
|
||||
const clause = pick(item, ['clause', 'no', '条款', 'clauseNo'], '');
|
||||
const issue = pick(item, ['issue', 'risk', 'problem', '描述', '问题'], '');
|
||||
const suggest = pick(item, ['suggestion', 'fix', 'advice', '建议', '修改建议'], '');
|
||||
const basis = pick(item, ['legal_basis', 'legalBasis', 'basis', '法律依据'], '');
|
||||
const blocks = [];
|
||||
|
||||
blocks.push(para([
|
||||
run('[' + levelText + ']', { bold: true, color: color, size: 21 }),
|
||||
run(clause ? ' 第 ' + clause + ' 条' : ' 合同条款', { bold: true, color: color, size: 21 })
|
||||
], { before: 140, after: 40 }));
|
||||
|
||||
blocks.push(para([
|
||||
run('问题:', { bold: true, size: 21 }),
|
||||
run(issue, { size: 21 })
|
||||
], { after: 40, indent: { left: 240 } }));
|
||||
|
||||
if (suggest) {
|
||||
blocks.push(para([
|
||||
run('建议:', { bold: true, size: 21 }),
|
||||
run(suggest, { size: 21 })
|
||||
], { after: 40, indent: { left: 240 } }));
|
||||
}
|
||||
|
||||
if (basis) {
|
||||
blocks.push(para([
|
||||
run('依据:', { bold: true, size: 21 }),
|
||||
run(basis, { size: 21, color: C.gray })
|
||||
], { after: 80, indent: { left: 240 } }));
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function riskSection(items, color, levelText, emptyText) {
|
||||
const list = asArray(items);
|
||||
if (!list.length) {
|
||||
return [para([run(emptyText || '无', { size: 21, color: C.gray })], { after: 80 })];
|
||||
}
|
||||
let out = [];
|
||||
list.forEach(function (it) {
|
||||
out = out.concat(riskBlock(it, color, levelText));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- 主函数 ----------
|
||||
|
||||
async function buildDocx(input) {
|
||||
const data = input.data || {};
|
||||
const fileName = input.fileName || '合同审查报告';
|
||||
const contractName = pick(data, ['contract_name', 'contractName', '协议名称', 'name'], fileName.replace(/\.[^.]+$/, ''));
|
||||
const reportTitle = pick(data, ['report_title', 'reportTitle', '报告标题'], '合同审查报告');
|
||||
const reviewDate = pick(data, ['review_date', 'reviewDate', '审核日期'], new Date().toLocaleDateString('zh-CN'));
|
||||
const summary = pick(data, ['summary', '统计'], {}) || {};
|
||||
const conclusion = pick(data, ['conclusion', '审核结论', 'summary_text'], '');
|
||||
const highRisks = pick(data, ['high_risks', 'highRisks', '高风险'], []);
|
||||
const mediumRisks = pick(data, ['medium_risks', 'mediumRisks', '中风险'], []);
|
||||
const lowRisks = pick(data, ['low_risks', 'lowRisks', '低风险'], []);
|
||||
const suggestions = pick(data, ['suggestions', '优化建议', 'advice_table'], []);
|
||||
const templates = pick(data, ['clause_templates', 'clauseTemplates', '补充条款'], []);
|
||||
const legalBases = pick(data, ['legal_bases', 'legalBases', '法律依据'], []);
|
||||
const cases = pick(data, ['cases', '案例'], []);
|
||||
const disclaimer = pick(data, ['disclaimer', '免责声明'], '');
|
||||
|
||||
const nCompliant = pick(summary, ['compliant', '合规'], 0);
|
||||
const nPartial = pick(summary, ['partial', '部分合规'], 0);
|
||||
const nHigh = pick(summary, ['high', '高风险'], asArray(highRisks).length);
|
||||
const nMedium = pick(summary, ['medium', '中风险'], asArray(mediumRisks).length);
|
||||
|
||||
const children = [];
|
||||
|
||||
// ===== 抬头 =====
|
||||
children.push(para([run(reportTitle, { size: 44, bold: true, color: C.primary })], {
|
||||
alignment: AlignmentType.CENTER,
|
||||
before: 200,
|
||||
after: 120
|
||||
}));
|
||||
children.push(para([run(contractName, { size: 26, color: C.gray })], {
|
||||
alignment: AlignmentType.CENTER,
|
||||
after: 60
|
||||
}));
|
||||
children.push(para([run('审核日期:' + reviewDate + ' 出具机构:金久信签约宝', { size: 20, color: C.gray })], {
|
||||
alignment: AlignmentType.CENTER,
|
||||
after: 240
|
||||
}));
|
||||
|
||||
// ===== 目录 =====
|
||||
children.push(sectionTitle('一', '报告目录'));
|
||||
const tocItems = [
|
||||
'二、审核结论',
|
||||
'三、需处理问题(高风险 / 中风险)',
|
||||
'四、优化建议汇总表',
|
||||
'五、补充条款模板',
|
||||
'六、法律依据',
|
||||
'七、相关案例',
|
||||
'八、免责声明'
|
||||
];
|
||||
tocItems.forEach(function (item) {
|
||||
children.push(para([run(item, { size: 21 })], { after: 30, indent: { left: 240 } }));
|
||||
});
|
||||
|
||||
// ===== 审核结论 =====
|
||||
children.push(sectionTitle('二', '审核结论'));
|
||||
|
||||
const statRow = new TableRow({
|
||||
children: [
|
||||
cell([para([run(String(nCompliant), { size: 40, bold: true, color: C.success })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('合规条款', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'F0F9EB', width: 25 }),
|
||||
cell([para([run(String(nPartial), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('部分合规', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'FDF6EC', width: 25 }),
|
||||
cell([para([run(String(nHigh), { size: 40, bold: true, color: C.danger })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('高风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'FEF0F0', width: 25 }),
|
||||
cell([para([run(String(nMedium), { size: 40, bold: true, color: C.warning })], { alignment: AlignmentType.CENTER, before: 40, after: 20 }),
|
||||
para([run('中风险', { size: 18, color: C.gray })], { alignment: AlignmentType.CENTER, after: 40 })],
|
||||
{ shading: 'FDF6EC', width: 25 })
|
||||
]
|
||||
});
|
||||
children.push(table([statRow], [2000, 2000, 2000, 2000]));
|
||||
|
||||
if (conclusion) {
|
||||
children.push(para([run('审核结论', { size: 21, bold: true, color: C.primary })], { before: 200, after: 80 }));
|
||||
String(conclusion).split(/\n+/).forEach(function (line) {
|
||||
if (line.trim()) {
|
||||
children.push(para([run(line.trim(), { size: 21 })], { after: 60, indent: { left: 240 } }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 需处理问题 =====
|
||||
children.push(sectionTitle('三', '需处理问题'));
|
||||
|
||||
children.push(para([run('(一)高风险问题', { size: 24, bold: true, color: C.danger })], { before: 140, after: 80 }));
|
||||
riskSection(highRisks, C.danger, '高风险', '本次审查未发现高风险问题。').forEach(function (p) { children.push(p); });
|
||||
|
||||
children.push(para([run('(二)中风险问题', { size: 24, bold: true, color: C.warning })], { before: 200, after: 80 }));
|
||||
riskSection(mediumRisks, C.warning, '中风险', '本次审查未发现中风险问题。').forEach(function (p) { children.push(p); });
|
||||
|
||||
if (asArray(lowRisks).length) {
|
||||
children.push(para([run('(三)低风险问题', { size: 24, bold: true, color: C.gray })], { before: 200, after: 80 }));
|
||||
riskSection(lowRisks, C.gray, '低风险', '无').forEach(function (p) { children.push(p); });
|
||||
}
|
||||
|
||||
// ===== 优化建议汇总表 =====
|
||||
children.push(sectionTitle('四', '优化建议汇总表'));
|
||||
const sugList = asArray(suggestions);
|
||||
if (!sugList.length) {
|
||||
children.push(para([run('无', { size: 21, color: C.gray })]));
|
||||
} else {
|
||||
const headRow = new TableRow({
|
||||
tableHeader: true,
|
||||
children: [
|
||||
cell([para([run('优先级', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 12 }),
|
||||
cell([para([run('条款编号', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 15 }),
|
||||
cell([para([run('问题描述', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 38 }),
|
||||
cell([para([run('建议方案', { bold: true, color: 'FFFFFF', size: 20 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 35 })
|
||||
]
|
||||
});
|
||||
const rows = [headRow];
|
||||
sugList.forEach(function (s, i) {
|
||||
const lv = String(pick(s, ['priority', 'level', '优先级'], 'M')).toUpperCase();
|
||||
const lvBg = lv === 'H' ? C.danger : (lv === 'L' ? '999999' : C.warning);
|
||||
rows.push(new TableRow({
|
||||
children: [
|
||||
cell([para([run(lv, { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: lvBg, width: 12 }),
|
||||
cell([para([run(pick(s, ['clause', 'no', '条款编号'], '-'), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 15 }),
|
||||
cell([para([run(pick(s, ['issue', 'problem', '问题描述', '描述'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 38 }),
|
||||
cell([para([run(pick(s, ['solution', 'suggestion', '建议方案', '建议'], '-'), { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 35 })
|
||||
]
|
||||
}));
|
||||
});
|
||||
children.push(table(rows, [1200, 1500, 3800, 3500]));
|
||||
}
|
||||
|
||||
// ===== 补充条款模板 =====
|
||||
children.push(sectionTitle('五', '补充条款模板'));
|
||||
const tplList = asArray(templates);
|
||||
if (!tplList.length) {
|
||||
children.push(para([run('无', { size: 21, color: C.gray })]));
|
||||
} else {
|
||||
tplList.forEach(function (t, i) {
|
||||
const title = pick(t, ['title', 'name', '条款名称'], '补充条款 ' + (i + 1));
|
||||
const content = pick(t, ['content', 'text', '条文', '内容'], '');
|
||||
children.push(para([run(String(i + 1) + '. ' + title, { size: 21, bold: true, color: C.primary })], { before: 140, after: 60 }));
|
||||
String(content).split(/\n+/).forEach(function (line) {
|
||||
if (line.trim()) {
|
||||
children.push(para([run(line.trim(), { size: 20, font: FONT_SONG })], {
|
||||
after: 50,
|
||||
indent: { left: 240 },
|
||||
shading: C.lightBg,
|
||||
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.primary, space: 6 } }
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 法律依据 =====
|
||||
children.push(sectionTitle('六', '法律依据'));
|
||||
const lawList = asArray(legalBases);
|
||||
if (!lawList.length) {
|
||||
children.push(para([run('本报告未引用具体法条,相关意见为实务建议,具体法律适用需律师人工核实。', { size: 20, color: C.gray })]));
|
||||
} else {
|
||||
lawList.forEach(function (l, i) {
|
||||
const law = pick(l, ['law', 'name', '法律名称'], '');
|
||||
const article = pick(l, ['article', 'no', '条款编号'], '');
|
||||
const content = pick(l, ['content', 'text', '引用内容', '内容'], '');
|
||||
children.push(para([
|
||||
run(String(i + 1) + '. ', { size: 20, bold: true }),
|
||||
run(law + (article ? ' ' + article : ''), { size: 20, bold: true, color: C.primary }),
|
||||
run(content ? ':' + content : '', { size: 20 })
|
||||
], { after: 60, indent: { left: 120 } }));
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 相关案例 =====
|
||||
children.push(sectionTitle('七', '相关案例'));
|
||||
const caseList = asArray(cases);
|
||||
if (!caseList.length) {
|
||||
children.push(para([run('暂未检索到公开案例,建议人工核实。', { size: 20, color: C.gray })]));
|
||||
} else {
|
||||
const caseHead = new TableRow({
|
||||
tableHeader: true,
|
||||
children: [
|
||||
cell([para([run('序号', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
|
||||
cell([para([run('案件名称·案由', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 26 }),
|
||||
cell([para([run('法院·机构', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 14 }),
|
||||
cell([para([run('年份', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 8 }),
|
||||
cell([para([run('判决要点', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 28 }),
|
||||
cell([para([run('与风险点关联', { bold: true, color: 'FFFFFF', size: 19 })], { alignment: AlignmentType.CENTER })], { shading: C.primary, width: 16 })
|
||||
]
|
||||
});
|
||||
const caseRows = [caseHead];
|
||||
caseList.forEach(function (c, i) {
|
||||
const name = pick(c, ['name', 'title', '案件名称'], '-');
|
||||
const url = pick(c, ['url', 'link', '链接'], '');
|
||||
const court = pick(c, ['court', 'org', '法院'], '-');
|
||||
const year = pick(c, ['year', '年份'], '-');
|
||||
const result = pick(c, ['result', 'point', '判决要点', '要点'], '-');
|
||||
const relation = pick(c, ['relation', '关联', '与本次风险点关联'], '-');
|
||||
const nameCellChildren = [para([run(name, { size: 19, bold: true })], { after: url ? 20 : 0 })];
|
||||
if (url) {
|
||||
const host = url.replace(/^https?:\/\//i, '').split('/')[0];
|
||||
nameCellChildren.push(para([
|
||||
new ExternalHyperlink({
|
||||
link: url,
|
||||
children: [run('来源:' + host + ' ↗', { size: 16, color: '2E74B5', underline: true })]
|
||||
})
|
||||
], {}));
|
||||
}
|
||||
caseRows.push(new TableRow({
|
||||
children: [
|
||||
cell([para([run(String(i + 1), { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
|
||||
cell(nameCellChildren, { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 26 }),
|
||||
cell([para([run(court, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 14 }),
|
||||
cell([para([run(year, { size: 19 })], { alignment: AlignmentType.CENTER })], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 8 }),
|
||||
cell([para([run(result, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 28 }),
|
||||
cell([para([run(relation, { size: 19 })])], { shading: i % 2 ? 'F7F9FA' : 'FFFFFF', width: 16 })
|
||||
]
|
||||
}));
|
||||
});
|
||||
children.push(table(caseRows, [700, 2300, 1300, 700, 2500, 1500]));
|
||||
}
|
||||
|
||||
// ===== 免责声明 =====
|
||||
children.push(sectionTitle('八', '免责声明'));
|
||||
const disText = disclaimer || '本报告由人工智能根据用户上传的合同文本自动生成,仅供内部风险排查参考,不构成正式法律意见,亦不能替代执业律师的专业审查。涉及重大交易、争议解决或诉讼事项,请务必委托执业律师复核。报告中的法律法规引用以官方公布文本为准,案例信息来源于公开检索结果,具体适用请人工核实。';
|
||||
String(disText).split(/\n+/).forEach(function (line) {
|
||||
if (line.trim()) {
|
||||
children.push(para([run(line.trim(), { size: 19, color: C.gray })], {
|
||||
after: 50,
|
||||
shading: C.pinkBg,
|
||||
indent: { left: 120, right: 120 },
|
||||
border: { left: { style: BorderStyle.SINGLE, size: 12, color: C.danger, space: 6 } }
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 文档 =====
|
||||
const doc = new Document({
|
||||
creator: '金久信签约宝',
|
||||
title: contractName + ' - 合同审查报告',
|
||||
description: 'AI 合同审查报告',
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: { font: FONT, size: 21, color: C.text }
|
||||
}
|
||||
}
|
||||
},
|
||||
sections: [{
|
||||
properties: {
|
||||
page: {
|
||||
margin: { top: 1134, right: 1134, bottom: 1134, left: 1134 }
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
para([
|
||||
run('金久信签约宝 · AI 合同审查报告', { size: 18, color: C.gray }),
|
||||
run(' ' + contractName, { size: 18, color: C.gray })
|
||||
], { alignment: AlignmentType.RIGHT, after: 0 })
|
||||
]
|
||||
})
|
||||
},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({ text: '第 ', font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ children: [PageNumber.CURRENT], font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ text: ' 页 / 共 ', font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ children: [PageNumber.TOTAL_PAGES], font: FONT, size: 18, color: C.gray }),
|
||||
new TextRun({ text: ' 页', font: FONT, size: 18, color: C.gray })
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
},
|
||||
children: children
|
||||
}]
|
||||
});
|
||||
|
||||
return await Packer.toBuffer(doc);
|
||||
}
|
||||
|
||||
// ---------- HTML 解析(生产数据兜底通道) ----------
|
||||
// 智能体实际返回的是纯 HTML 报告,未必带 jjx-report-data 数据块,
|
||||
// 这里按区块结构把 HTML 还原成结构化数据,无需修改智能体提示词。
|
||||
|
||||
function decodeEntities(s) {
|
||||
return String(s || '')
|
||||
.replace(/ /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
|
||||
};
|
||||
|
||||
@@ -25,22 +25,52 @@ module.exports = {
|
||||
}
|
||||
});
|
||||
|
||||
let answer = '';
|
||||
// 扣子 v3 流式事件说明:
|
||||
// conversation.message.delta —— 增量片段(应当拼接)
|
||||
// conversation.message.completed —— 该条消息的「完整内容」(不能再次拼接,否则重复)
|
||||
// 因此必须按事件类型区分,只累加 delta;completed 仅用于兜底。
|
||||
let conversationId = '';
|
||||
const msgMap = new Map(); // messageId -> { text }
|
||||
let curEvent = '';
|
||||
const text = res.data || '';
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
curEvent = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const d = line.slice(5).trim();
|
||||
if (d === '[DONE]' || d === '"[DONE]"') continue;
|
||||
try {
|
||||
const p = JSON.parse(d);
|
||||
if (p.conversation_id) conversationId = p.conversation_id;
|
||||
if (p.type === 'answer' && p.content) {
|
||||
answer += p.content; // 拼接,不是覆盖
|
||||
// 只取正文,忽略 verbose(工具调用日志)等其他类型
|
||||
if (p.type !== 'answer' || typeof p.content !== 'string') continue;
|
||||
const key = p.id || '__default__';
|
||||
const rec = msgMap.get(key) || { text: '' };
|
||||
|
||||
if (curEvent === 'conversation.message.delta') {
|
||||
// 防御:万一上游发的是累计内容而非增量,用长度+前缀判断避免重复
|
||||
if (p.content.length > rec.text.length && p.content.indexOf(rec.text) === 0) {
|
||||
rec.text = p.content;
|
||||
} else {
|
||||
rec.text += p.content;
|
||||
}
|
||||
msgMap.set(key, rec);
|
||||
} else if (curEvent === 'conversation.message.completed') {
|
||||
// 已有 delta 拼出的内容就丢掉 completed,避免重复;没有则用它兜底
|
||||
if (!rec.text && p.content) {
|
||||
rec.text = p.content;
|
||||
msgMap.set(key, rec);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 跳过 */ }
|
||||
}
|
||||
|
||||
// 按消息首次出现顺序拼接(多段消息之间自带换行,直接相连)
|
||||
let answer = '';
|
||||
for (const rec of msgMap.values()) answer += rec.text;
|
||||
if (!answer) answer = '暂时无法回复,请拨打 400-686-5559 咨询';
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// client/ai/pub/doReviewTimer.js
|
||||
module.exports = {
|
||||
main: async (event) => {
|
||||
let { data = {} } = event;
|
||||
const { record_id, file_id, user_id, file_name, file_size } = data;
|
||||
|
||||
if (!record_id || !file_id) return { code: -1, msg: '参数错误' };
|
||||
|
||||
const db = uniCloud.database();
|
||||
|
||||
// 检查是否已经在审查中(防止重复触发)
|
||||
const record = await db.collection('jjx-review-records').doc(record_id).get();
|
||||
if (record.data.length && record.data[0].status === 'reviewing') {
|
||||
// 检查是否刚触发(5分钟内不重复触发)
|
||||
const lastUpdate = record.data[0].update_time;
|
||||
if (lastUpdate && (Date.now() - new Date(lastUpdate).getTime() < 5 * 60 * 1000)) {
|
||||
return { code: 0, msg: '已在审查中,跳过' };
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态为审查中
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'reviewing',
|
||||
review_started_at: new Date(),
|
||||
update_time: new Date()
|
||||
});
|
||||
|
||||
// 调用实际的审查逻辑
|
||||
try {
|
||||
const doReview = require('./jjxDoReview.js');
|
||||
await doReview.main({
|
||||
data: {
|
||||
record_id,
|
||||
file_id,
|
||||
user_id: user_id || 'anonymous',
|
||||
file_name: file_name || '',
|
||||
file_size: file_size || 0
|
||||
}
|
||||
});
|
||||
return { code: 0, msg: '审查完成' };
|
||||
} catch (e) {
|
||||
console.error('审查失败:', e);
|
||||
// 更新状态为失败
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'failed',
|
||||
result: '审查失败: ' + e.message,
|
||||
error: e.message,
|
||||
update_time: new Date()
|
||||
});
|
||||
return { code: -1, msg: '审查失败: ' + e.message };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
main: async (event) => {
|
||||
let { data = {}, userInfo, util } = event;
|
||||
let { db } = util;
|
||||
const { pageIndex = 1, pageSize = 50 } = data;
|
||||
|
||||
const uid = (data && data.uid) || '';
|
||||
if (!uid) return { code: -1, msg: '未登录' };
|
||||
|
||||
const where = { user_id: uid };
|
||||
|
||||
const countRes = await db.collection('jjx-review-records').where(where).count();
|
||||
|
||||
const listRes = await db.collection('jjx-review-records')
|
||||
.where(where)
|
||||
.orderBy('_add_time', 'desc')
|
||||
.skip((pageIndex - 1) * pageSize)
|
||||
.limit(pageSize)
|
||||
.get();
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
msg: 'ok',
|
||||
data: {
|
||||
total: countRes.total,
|
||||
rows: listRes.data
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,47 +1,27 @@
|
||||
// 云函数:client/ai/pub/getReviewResult
|
||||
module.exports = {
|
||||
main: async (event) => {
|
||||
let { data = {} } = event;
|
||||
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||||
const { chat_id, conversation_id } = data;
|
||||
|
||||
if (!chat_id || !conversation_id) return { code: -1, msg: '参数不完整' };
|
||||
|
||||
// 查询对话状态
|
||||
const statusRes = await uniCloud.httpclient.request(
|
||||
'https://api.coze.cn/v3/chat?chat_id=' + chat_id + '&conversation_id=' + conversation_id + '&bot_id=7689821072762011698',
|
||||
{ method: 'GET', dataType: 'text', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
|
||||
);
|
||||
|
||||
let status = 'unknown';
|
||||
try {
|
||||
const parsed = JSON.parse(statusRes.data);
|
||||
status = parsed.data && parsed.data.status;
|
||||
} catch (e) {}
|
||||
|
||||
if (status !== 'completed') {
|
||||
return { code: 0, data: { status } };
|
||||
}
|
||||
|
||||
// 获取消息
|
||||
const msgRes = await uniCloud.httpclient.request(
|
||||
'https://api.coze.cn/v3/chat/message/list?chat_id=' + chat_id + '&conversation_id=' + conversation_id,
|
||||
{ method: 'GET', dataType: 'text', headers: { 'Authorization': 'Bearer ' + COZE_TOKEN }, timeout: 15000 }
|
||||
);
|
||||
|
||||
let answer = '';
|
||||
try {
|
||||
const parsed = JSON.parse(msgRes.data);
|
||||
const messages = parsed.data;
|
||||
if (Array.isArray(messages)) {
|
||||
for (const m of messages) {
|
||||
if (m.type === 'answer' && m.role === 'assistant') {
|
||||
answer = m.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let { data = {}, userInfo, util } = event;
|
||||
let { db } = util;
|
||||
const { record_id } = data;
|
||||
|
||||
if (!record_id) return { code: -1, msg: '缺少 record_id' };
|
||||
|
||||
const record = await db.collection('jjx-review-records').doc(record_id).get();
|
||||
if (!record.data.length) return { code: -1, msg: '记录不存在' };
|
||||
|
||||
const rec = record.data[0];
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
status: rec.status,
|
||||
result: rec.result || '',
|
||||
points_used: rec.points_used || 0,
|
||||
html_url: rec.html_url || '',
|
||||
contract_info: rec.contract_info || {},
|
||||
error: rec.error || ''
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return { code: 0, data: { status: 'completed', answer } };
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
// client/ai/pub/jjxDoReview.js
|
||||
|
||||
// Word 报告生成器(依赖 docx,未安装时降级跳过,不影响审查)
|
||||
let buildDocx = null;
|
||||
let extractReportData = null;
|
||||
let extractDocxLink = null;
|
||||
let isDocxConvertible = null;
|
||||
try {
|
||||
const docxMod = require('./buildDocx.js');
|
||||
buildDocx = docxMod.buildDocx;
|
||||
extractReportData = docxMod.extractReportData;
|
||||
extractDocxLink = docxMod.extractDocxLink;
|
||||
isDocxConvertible = docxMod.isDocxConvertible;
|
||||
} catch (e) {
|
||||
console.log('[Docx] docx 依赖未安装,跳过 Word 生成:', e.message);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
main: async (event) => {
|
||||
let { data = {}, userInfo } = event;
|
||||
const { record_id, file_id, user_id, file_name, file_size } = data;
|
||||
|
||||
if (!record_id || !file_id) return { code: -1, msg: '参数错误' };
|
||||
|
||||
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||||
const BOT_ID = '7689821072762011698';
|
||||
const db = uniCloud.database();
|
||||
const _ = db.command;
|
||||
|
||||
try {
|
||||
// 构造请求
|
||||
const objectString = JSON.stringify([
|
||||
{ type: 'file', file_id: file_id },
|
||||
{ type: 'text', text: '请审查这份合同' }
|
||||
]);
|
||||
|
||||
// 调用 Coze 接口(流式模式)
|
||||
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||||
method: 'POST',
|
||||
dataType: 'stream', // 关键:改成 stream
|
||||
contentType: 'json',
|
||||
timeout: 300000, // 5分钟超时
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + COZE_TOKEN,
|
||||
'Accept': 'text/event-stream'
|
||||
},
|
||||
data: {
|
||||
bot_id: BOT_ID,
|
||||
user_id: user_id || 'anonymous',
|
||||
stream: true,
|
||||
auto_save_history: true,
|
||||
additional_messages: [{
|
||||
role: 'user',
|
||||
content: objectString,
|
||||
content_type: 'object_string'
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
// 从流中逐步解析
|
||||
const stream = chatRes.data || chatRes.res;
|
||||
let buffer = '';
|
||||
let answer = '';
|
||||
let conversationId = '';
|
||||
let lastUpdateLen = 0;
|
||||
|
||||
// 返回 Promise,等待流结束
|
||||
return await new Promise((resolve, reject) => {
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop();
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n');
|
||||
let eventName = '';
|
||||
let dataStr = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||||
if (line.startsWith('data:')) dataStr = line.slice(5).trim();
|
||||
}
|
||||
|
||||
if (!dataStr || dataStr === '[DONE]' || dataStr === '"[DONE]"') continue;
|
||||
|
||||
try {
|
||||
const p = JSON.parse(dataStr);
|
||||
if (p.conversation_id) conversationId = p.conversation_id;
|
||||
|
||||
// 处理回答内容
|
||||
// 注意:conversation.message.completed 携带的是该条消息的完整内容,
|
||||
// 若前面已经通过 delta 拼过,再拼一次就会重复,这里做去重
|
||||
if (p.type === 'answer' && p.content) {
|
||||
const isDup = eventName === 'conversation.message.completed' && answer.indexOf(p.content) >= 0;
|
||||
if (!isDup) {
|
||||
answer += p.content;
|
||||
}
|
||||
|
||||
// 每积累 200 字更新一次数据库(前端轮询能看到进度)
|
||||
if (answer.length - lastUpdateLen >= 200) {
|
||||
lastUpdateLen = answer.length;
|
||||
db.collection('jjx-review-records').doc(record_id).update({
|
||||
result: answer,
|
||||
status: 'reviewing',
|
||||
update_time: new Date()
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具响应(报告下载链接)
|
||||
if (p.type === 'tool_response' && p.content) {
|
||||
try {
|
||||
const toolData = JSON.parse(p.content);
|
||||
if (toolData.url) answer += '\n\n📎 报告下载:' + toolData.url;
|
||||
} catch (e) {
|
||||
const urlMatch = p.content.match(/https?:\/\/[^\s\)\]\"\<]+\.pdf[^\s\)\]\"\<]*/i);
|
||||
if (urlMatch) answer += '\n\n📎 报告下载:' + urlMatch[0];
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('end', async () => {
|
||||
if (!answer) {
|
||||
answer = '审查未能获取结果,请稍后重试';
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算消耗积分
|
||||
const pointsUsed = Math.max(150, Math.ceil((file_size || 0) / (100 * 1024)) * 150);
|
||||
|
||||
// 扣积分
|
||||
const userPoints = await db.collection('jjx-user-points').doc(user_id).get();
|
||||
if (userPoints.data.length && userPoints.data[0].balance >= pointsUsed) {
|
||||
await db.collection('jjx-user-points').doc(user_id).update({
|
||||
balance: _.inc(-pointsUsed),
|
||||
total_spent: _.inc(pointsUsed),
|
||||
update_time: new Date()
|
||||
});
|
||||
|
||||
const updatedPoints = await db.collection('jjx-user-points').doc(user_id).get();
|
||||
await db.collection('jjx-points-log').add({
|
||||
user_id: user_id,
|
||||
type: 'spend',
|
||||
amount: -pointsUsed,
|
||||
balance_after: updatedPoints.data[0] ? updatedPoints.data[0].balance : 0,
|
||||
reason: '合同审查 — ' + (file_name || ''),
|
||||
biz_type: 'contract_review',
|
||||
biz_id: record_id,
|
||||
_add_time: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// 保存 HTML 报告到云存储
|
||||
let htmlUrl = '';
|
||||
try {
|
||||
// 如果回复本身就是 HTML(包含 <!DOCTYPE html> 或 <html>)
|
||||
let htmlContent = answer;
|
||||
const htmlMatch = answer.match(/```html\s*([\s\S]*?)```/);
|
||||
if (htmlMatch && htmlMatch[1]) {
|
||||
htmlContent = htmlMatch[1].trim();
|
||||
}
|
||||
|
||||
if (htmlContent.includes('<!DOCTYPE') || htmlContent.includes('<html')) {
|
||||
const cloudPath = 'review-reports/' + Date.now() + '_' + (file_name || 'report').replace(/\.[^.]+$/, '') + '.html';
|
||||
const uploadRes = await uniCloud.uploadFile({
|
||||
cloudPath: cloudPath,
|
||||
fileContent: Buffer.from(htmlContent, 'utf-8')
|
||||
});
|
||||
if (uploadRes.fileID) {
|
||||
const urlRes = await uniCloud.getTempFileURL({ fileList: [uploadRes.fileID] });
|
||||
if (urlRes.fileList && urlRes.fileList[0]) {
|
||||
htmlUrl = urlRes.fileList[0].tempFileURL;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('HTML报告上传失败:', e.message);
|
||||
}
|
||||
|
||||
// 解析 JSON 结构化数据(如果有的话)
|
||||
let contractInfo = {};
|
||||
try {
|
||||
const jsonMatch = answer.match(/```json\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) contractInfo = JSON.parse(jsonMatch[1].trim());
|
||||
} catch (e) {}
|
||||
|
||||
// 生成 Word 报告:优先用智能体插件生成的链接,否则本地结构化重建
|
||||
let docxUrl = extractDocxLink ? extractDocxLink(answer) : '';
|
||||
let docxFileId = '';
|
||||
if (docxUrl) {
|
||||
console.log('[Docx] 采用智能体插件生成的 Word 链接:', docxUrl);
|
||||
} else if (isDocxConvertible && !isDocxConvertible(answer)) {
|
||||
// 智能体没返回完整报告(道歉/澄清文本,或 HTML 被截断),不生成残缺 Word
|
||||
console.log('[Docx] 无完整 HTML 报告,跳过 Word 生成');
|
||||
} else if (buildDocx && extractReportData) {
|
||||
try {
|
||||
const reportData = extractReportData(answer);
|
||||
if (reportData) {
|
||||
const docxBuffer = await buildDocx({
|
||||
data: reportData,
|
||||
fileName: file_name || '合同审查报告'
|
||||
});
|
||||
const baseName = (file_name || 'report').replace(/\.[^.]+$/, '');
|
||||
const docxPath = 'review-reports/' + Date.now() + '_' + baseName + '_审查报告.docx';
|
||||
const docxRes = await uniCloud.uploadFile({
|
||||
cloudPath: docxPath,
|
||||
fileContent: docxBuffer
|
||||
});
|
||||
if (docxRes.fileID) {
|
||||
docxFileId = docxRes.fileID;
|
||||
const docxUrlRes = await uniCloud.getTempFileURL({ fileList: [docxRes.fileID] });
|
||||
if (docxUrlRes.fileList && docxUrlRes.fileList[0]) {
|
||||
docxUrl = docxUrlRes.fileList[0].tempFileURL;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[Docx] 既无插件链接也无结构化数据,跳过 Word 生成');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Docx] Word 报告生成失败(不影响审查结果):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新审查记录为完成
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'completed',
|
||||
result: answer,
|
||||
html_url: htmlUrl,
|
||||
docx_url: docxUrl,
|
||||
docx_file_id: docxFileId,
|
||||
contract_info: contractInfo,
|
||||
points_used: pointsUsed,
|
||||
conversation_id: conversationId || '',
|
||||
update_time: new Date()
|
||||
});
|
||||
|
||||
resolve({ code: 0, msg: '审查完成' });
|
||||
|
||||
} catch (e) {
|
||||
// 更新为失败
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'failed',
|
||||
result: '审查失败: ' + e.message,
|
||||
error: e.message,
|
||||
update_time: new Date()
|
||||
});
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', async (err) => {
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'failed',
|
||||
result: '审查失败: ' + err.message,
|
||||
error: err.message,
|
||||
update_time: new Date()
|
||||
});
|
||||
reject(err);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
// 审查失败,更新记录
|
||||
await db.collection('jjx-review-records').doc(record_id).update({
|
||||
status: 'failed',
|
||||
result: '审查失败: ' + e.message,
|
||||
error: e.message,
|
||||
update_time: new Date()
|
||||
});
|
||||
return { code: -1, msg: '审查失败: ' + e.message };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,131 @@
|
||||
// client/ai/pub/review.js
|
||||
// Markdown 转 HTML
|
||||
function mdToHtml(md) {
|
||||
if (!md) return '';
|
||||
let html = md;
|
||||
// 标题
|
||||
html = html.replace(/^####\s+(.+)$/gm, '<h5>$1</h5>');
|
||||
html = html.replace(/^###\s+(.+)$/gm, '<h4>$1</h4>');
|
||||
html = html.replace(/^##\s+(.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^#\s+(.+)$/gm, '<h2>$1</h2>');
|
||||
// 加粗
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
// 链接
|
||||
html = html.replace(/(https?:\/\/[^\s\)\]\"\<]+)/g, '<a href="$1" target="_blank">$1</a>');
|
||||
// 代码块
|
||||
html = html.replace(/```[\w]*\n([\s\S]*?)```/g, '<pre class="code-block">$1</pre>');
|
||||
// 行内代码
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
// 列表
|
||||
html = html.replace(/^[\-\•]\s+(.+)$/gm, '<li>$1</li>');
|
||||
html = html.replace(/^(\d+)\.\s+(.+)$/gm, '<li>$1. $2</li>');
|
||||
// 换行
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
html = '<p>' + html + '</p>';
|
||||
// 清理空段落
|
||||
html = html.replace(/<p>\s*<\/p>/g, '');
|
||||
return html;
|
||||
}
|
||||
|
||||
// 生成精美 HTML 报告
|
||||
function generateHtmlReport(markdown, fileName, reportDate, info) {
|
||||
const riskLevel = info.risk_level || '待评估';
|
||||
const riskSummary = info.risk_summary || '';
|
||||
const riskCount = info.risk_count || { high: 0, medium: 0, low: 0 };
|
||||
const contractName = info.contract_name || fileName || '合同审查报告';
|
||||
const contractType = info.contract_type || '';
|
||||
|
||||
// 风险等级颜色
|
||||
let riskColor = '#67c23a';
|
||||
if (riskLevel.includes('高')) riskColor = '#f56c6c';
|
||||
else if (riskLevel.includes('中高')) riskColor = '#e6a23c';
|
||||
else if (riskLevel.includes('中')) riskColor = '#e6a23c';
|
||||
else if (riskLevel.includes('中低')) riskColor = '#409eff';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${contractName} - 审查报告</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f7fa; color: #333; line-height: 1.8; }
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #a92924, #d4574e); color: #fff; padding: 40px; border-radius: 12px 12px 0 0; }
|
||||
.header h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
.header .subtitle { opacity: 0.85; font-size: 14px; }
|
||||
.summary-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 24px 40px; background: #fff; border-bottom: 1px solid #eee; }
|
||||
.summary-card { text-align: center; padding: 16px; border-radius: 8px; background: #f9f9f9; }
|
||||
.summary-card .label { font-size: 12px; color: #999; margin-bottom: 4px; }
|
||||
.summary-card .value { font-size: 20px; font-weight: 700; }
|
||||
.body { background: #fff; padding: 32px 40px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
|
||||
.body h2 { font-size: 18px; color: #222; margin: 28px 0 12px; padding-bottom: 8px; border-bottom: 2px solid #a92924; }
|
||||
.body h3 { font-size: 16px; color: #333; margin: 20px 0 8px; }
|
||||
.body h4 { font-size: 14px; color: #444; margin: 16px 0 6px; }
|
||||
.body p { margin: 8px 0; font-size: 14px; }
|
||||
.body li { margin: 4px 0 4px 20px; font-size: 14px; }
|
||||
.body strong { color: #a92924; }
|
||||
.body a { color: #a92924; text-decoration: underline; }
|
||||
.risk-badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 600; color: #fff; }
|
||||
.risk-high { background: #f56c6c; }
|
||||
.risk-medium { background: #e6a23c; }
|
||||
.risk-low { background: #67c23a; }
|
||||
.code-block { background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px 16px; font-size: 13px; overflow-x: auto; margin: 8px 0; font-family: "SFMono-Regular", Consolas, monospace; }
|
||||
.disclaimer { margin-top: 32px; padding: 16px 20px; background: #fff8e1; border-left: 4px solid #e6a23c; border-radius: 4px; font-size: 12px; color: #b8860b; }
|
||||
.footer { text-align: center; padding: 24px; font-size: 12px; color: #999; }
|
||||
@media print { body { background: #fff; } .container { padding: 0; } .header { border-radius: 0; } .body { box-shadow: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📋 合同审查报告</h1>
|
||||
<div class="subtitle">${contractName} | ${reportDate}</div>
|
||||
</div>
|
||||
<div class="summary-cards">
|
||||
<div class="summary-card">
|
||||
<div class="label">综合风险等级</div>
|
||||
<div class="value" style="color:${riskColor}">${riskLevel}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">风险统计</div>
|
||||
<div class="value"><span style="color:#f56c6c">${riskCount.high||0}</span> / <span style="color:#e6a23c">${riskCount.medium||0}</span> / <span style="color:#67c23a">${riskCount.low||0}</span></div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="label">合同类型</div>
|
||||
<div class="value" style="font-size:14px">${contractType || '待识别'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>总体评估</h2>
|
||||
<p>${riskSummary || '暂无总体评估'}</p>
|
||||
<h2>详细审查意见</h2>
|
||||
${mdToHtml(markdown.replace(/```json[\s\S]*?```/, ''))}
|
||||
<div class="disclaimer">
|
||||
⚠️ <strong>免责声明</strong>:本报告由 AI 系统自动生成,仅供参考,不构成正式法律意见。涉及重大金额或复杂交易的合同,建议咨询执业律师。
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
金久信签约宝 · AI 合同审查服务 | 报告生成时间:${new Date().toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
main: async (event) => {
|
||||
let { data = {}, userInfo } = event;
|
||||
const COZE_TOKEN = 'sat_OiXlI4Da5Q99fxmul6pNfExmA6hR898UbxBDwrvjZ638APxRSlFnXbz6zyFedKNG';
|
||||
const BOT_ID = '7689821072762011698';
|
||||
const db = uniCloud.database();
|
||||
const _ = db.command;
|
||||
|
||||
let fileId = data.file_id;
|
||||
|
||||
// 模式1:只上传文件(选文件时调用)
|
||||
// ===== 模式1:上传文件 + 创建待审查记录 =====
|
||||
if (data.upload_only && data.file_base64) {
|
||||
const fileBuffer = Buffer.from(data.file_base64, 'base64');
|
||||
const boundary = '----FormBoundary' + Date.now();
|
||||
@@ -28,58 +147,106 @@ module.exports = {
|
||||
'Content-Length': uploadBody.length.toString()
|
||||
},
|
||||
content: uploadBody,
|
||||
timeout: 60000
|
||||
timeout: 600000
|
||||
});
|
||||
if (uploadRes.data.code !== 0) {
|
||||
return { code: -1, msg: '文件上传失败: ' + uploadRes.data.msg };
|
||||
}
|
||||
fileId = uploadRes.data.data.id;
|
||||
return { code: 0, msg: 'ok', data: { file_id: fileId } };
|
||||
|
||||
// 同时上传到 uniCloud 云存储(用于查看/下载)
|
||||
let fileUrl = '';
|
||||
try {
|
||||
const cloudPath = 'review-files/' + Date.now() + '_' + (data.file_name || 'file');
|
||||
const uploadFileRes = await uniCloud.uploadFile({
|
||||
cloudPath: cloudPath,
|
||||
fileContent: fileBuffer
|
||||
});
|
||||
if (uploadFileRes.fileID) {
|
||||
const urlRes = await uniCloud.getTempFileURL({
|
||||
fileList: [uploadFileRes.fileID]
|
||||
});
|
||||
if (urlRes.fileList && urlRes.fileList[0]) {
|
||||
fileUrl = urlRes.fileList[0].tempFileURL;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('云存储上传失败(不影响审查):', e.message);
|
||||
}
|
||||
|
||||
// 创建待审查记录
|
||||
const recordRes = await db.collection('jjx-review-records').add({
|
||||
user_id: (data && data.uid) || 'anonymous',
|
||||
file_name: data.file_name || '',
|
||||
file_size: data.file_size || 0,
|
||||
file_id: fileId,
|
||||
file_url: fileUrl,
|
||||
chat_id: '',
|
||||
conversation_id: '',
|
||||
status: 'uploaded',
|
||||
result: '',
|
||||
points_used: 0,
|
||||
_add_time: new Date(),
|
||||
update_time: new Date()
|
||||
});
|
||||
|
||||
return { code: 0, msg: 'ok', data: { file_id: fileId, record_id: recordRes.id, file_url: fileUrl } };
|
||||
} catch (e) {
|
||||
return { code: -1, msg: '上传异常: ' + e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 模式2:发起审查(不等结果,立即返回 chat_id)
|
||||
if (!fileId) {
|
||||
return { code: -1, msg: '缺少 file_id' };
|
||||
// ===== 模式2:发起审查(只更新状态,立即返回)=====
|
||||
if (!fileId) return { code: -1, msg: '缺少 file_id' };
|
||||
|
||||
const recordId = data.record_id;
|
||||
if (!recordId) return { code: -1, msg: '缺少 record_id' };
|
||||
|
||||
// 检查当前状态
|
||||
const record = await db.collection('jjx-review-records').doc(recordId).get();
|
||||
if (record.data.length) {
|
||||
const rec = record.data[0];
|
||||
|
||||
// 已完成,直接返回结果
|
||||
if (rec.status === 'completed') {
|
||||
return {
|
||||
code: 0, msg: 'ok',
|
||||
data: {
|
||||
record_id: recordId,
|
||||
status: 'completed',
|
||||
result: rec.result,
|
||||
points_used: rec.points_used,
|
||||
html_url: rec.html_url || '',
|
||||
contract_info: rec.contract_info || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 正在审查中,直接返回
|
||||
if (rec.status === 'reviewing') {
|
||||
return { code: 0, msg: 'ok', data: { record_id: recordId, status: 'reviewing' } };
|
||||
}
|
||||
|
||||
// 失败,返回错误
|
||||
if (rec.status === 'failed') {
|
||||
return { code: 0, msg: 'ok', data: { record_id: recordId, status: 'failed', result: rec.result || '审查失败' } };
|
||||
}
|
||||
}
|
||||
|
||||
const objectString = JSON.stringify([
|
||||
{ type: 'file', file_id: fileId },
|
||||
{ type: 'text', text: '请审查这份合同' }
|
||||
]);
|
||||
// 更新状态为审查中
|
||||
await db.collection('jjx-review-records').doc(recordId).update({
|
||||
file_id: fileId,
|
||||
file_name: data.file_name || '',
|
||||
file_size: data.file_size || 0,
|
||||
status: 'reviewing',
|
||||
review_started_at: new Date(),
|
||||
update_time: new Date()
|
||||
});
|
||||
|
||||
try {
|
||||
const chatRes = await uniCloud.httpclient.request('https://api.coze.cn/v3/chat', {
|
||||
method: 'POST',
|
||||
dataType: 'json',
|
||||
contentType: 'json',
|
||||
timeout: 30000,
|
||||
headers: { 'Authorization': 'Bearer ' + COZE_TOKEN },
|
||||
data: {
|
||||
bot_id: BOT_ID,
|
||||
user_id: (userInfo && userInfo.uid) || 'anonymous',
|
||||
stream: false,
|
||||
auto_save_history: true,
|
||||
additional_messages: [{
|
||||
role: 'user',
|
||||
content: objectString,
|
||||
content_type: 'object_string'
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
const chatData = chatRes.data && chatRes.data.data;
|
||||
return {
|
||||
code: 0, msg: 'ok',
|
||||
data: {
|
||||
chat_id: chatData && chatData.id,
|
||||
conversation_id: chatData && chatData.conversation_id
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return { code: -1, msg: '审查发起失败: ' + e.message };
|
||||
}
|
||||
// ===== 立即返回,审查由定时任务触发 =====
|
||||
return {
|
||||
code: 0, msg: 'ok',
|
||||
data: { record_id: recordId, status: 'reviewing' }
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -26,6 +26,7 @@ const orderTypeList = [
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'AI积分充值'
|
||||
];
|
||||
// 涉及的表名
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
module.exports = {
|
||||
main: async (event) => {
|
||||
let { data = {}, userInfo, util } = event;
|
||||
let { db, _ } = util;
|
||||
const { pageIndex = 1, pageSize = 20, type } = data;
|
||||
let { vk, db, _ } = util;
|
||||
const { pageIndex = 1, pageSize = 50, biz_type } = data;
|
||||
|
||||
console.log('getLog uid:', userInfo.uid);
|
||||
|
||||
// 先查全部看看有没有数据
|
||||
const allRes = await db.collection('jjx-points-log').limit(5).get();
|
||||
console.log('getLog all data count:', allRes.data.length);
|
||||
if (allRes.data.length > 0) {
|
||||
console.log('getLog first record:', JSON.stringify(allRes.data[0]));
|
||||
}
|
||||
|
||||
const where = { user_id: userInfo.uid };
|
||||
if (type) where.type = type; // 可选筛选 earn/spend
|
||||
if (biz_type) where.biz_type = biz_type;
|
||||
|
||||
const countRes = await db.collection('jjx-points-log').where(where).count();
|
||||
console.log('getLog filtered count:', countRes.total);
|
||||
|
||||
const listRes = await db.collection('jjx-points-log')
|
||||
.where(where)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -1,24 +1 @@
|
||||
// 文档教程: https://uniapp.dcloud.net.cn/uniCloud/schema
|
||||
{
|
||||
"bsonType": "object",
|
||||
"required": [],
|
||||
"permission": {
|
||||
"read": false,
|
||||
"create": false,
|
||||
"update": false,
|
||||
"delete": false
|
||||
},
|
||||
"properties": {
|
||||
"_id": {
|
||||
"description": "ID,系统自动生成"
|
||||
},
|
||||
"status": {
|
||||
"bsonType": "int",
|
||||
"description": "状态 0待创建 1待签署 2已签署 -1已作废"
|
||||
},
|
||||
"partyList": {
|
||||
"bsonType": "array",
|
||||
"description": "当事人"
|
||||
}
|
||||
}
|
||||
}
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -1,20 +1 @@
|
||||
// 文档教程: https://uniapp.dcloud.net.cn/uniCloud/schema
|
||||
{
|
||||
"bsonType": "object",
|
||||
"required": [],
|
||||
"permission": {
|
||||
"read": false,
|
||||
"create": false,
|
||||
"update": false,
|
||||
"delete": false
|
||||
},
|
||||
"properties": {
|
||||
"_id": {
|
||||
"description": "ID,系统自动生成"
|
||||
},
|
||||
"status": {
|
||||
"bsonType": "int",
|
||||
"description": "状态 0默认 1已被选择"
|
||||
}
|
||||
}
|
||||
}
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -1,63 +1 @@
|
||||
{
|
||||
"bsonType": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"_id": {
|
||||
"description": "记录ID,系统自动生成",
|
||||
"bsonType": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"bsonType": "string",
|
||||
"title": "用户ID",
|
||||
"description": "关联 uni-id-users._id",
|
||||
"foreignKey": "uni-id-users._id"
|
||||
},
|
||||
"type": {
|
||||
"bsonType": "string",
|
||||
"title": "变动类型",
|
||||
"description": "earn=获得 spend=消耗",
|
||||
"enum": [
|
||||
{ "text": "获得", "value": "earn" },
|
||||
{ "text": "消耗", "value": "spend" }
|
||||
]
|
||||
},
|
||||
"amount": {
|
||||
"bsonType": "number",
|
||||
"title": "变动数量",
|
||||
"description": "正数为获得,负数为消耗"
|
||||
},
|
||||
"balance_after": {
|
||||
"bsonType": "number",
|
||||
"title": "变动后余额",
|
||||
"description": "本次变动后的积分余额"
|
||||
},
|
||||
"reason": {
|
||||
"bsonType": "string",
|
||||
"title": "变动原因",
|
||||
"description": "如:新用户赠送、积分充值、合同审查消耗",
|
||||
"trim": "both"
|
||||
},
|
||||
"biz_type": {
|
||||
"bsonType": "string",
|
||||
"title": "业务类型",
|
||||
"description": "关联的业务类型",
|
||||
"enum": [
|
||||
{ "text": "赠送", "value": "gift" },
|
||||
{ "text": "充值", "value": "recharge" },
|
||||
{ "text": "合同审查", "value": "contract_review" },
|
||||
{ "text": "签到", "value": "checkin" },
|
||||
{ "text": "其他", "value": "other" }
|
||||
]
|
||||
},
|
||||
"biz_id": {
|
||||
"bsonType": "string",
|
||||
"title": "业务ID",
|
||||
"description": "关联的业务记录ID(如订单ID)"
|
||||
},
|
||||
"_add_time": {
|
||||
"bsonType": "date",
|
||||
"title": "创建时间",
|
||||
"description": "记录创建时间,系统自动填充"
|
||||
}
|
||||
}
|
||||
}
|
||||
{"bsonType":"object","required":[],"properties":{"_id":{"description":"记录ID,系统自动生成","bsonType":"string"},"user_id":{"bsonType":"string","title":"用户ID","description":"关联 uni-id-users._id","foreignKey":"uni-id-users._id"},"type":{"bsonType":"string","title":"变动类型","description":"earn=获得 spend=消耗","enum":[{"text":"获得","value":"earn"},{"text":"消耗","value":"spend"}]},"amount":{"bsonType":"number","title":"变动数量","description":"正数为获得,负数为消耗"},"balance_after":{"bsonType":"number","title":"变动后余额","description":"本次变动后的积分余额"},"reason":{"bsonType":"string","title":"变动原因","description":"如:新用户赠送、积分充值、合同审查消耗","trim":"both"},"biz_type":{"bsonType":"string","title":"业务类型","description":"关联的业务类型","enum":[{"text":"赠送","value":"gift"},{"text":"充值","value":"recharge"},{"text":"合同审查","value":"contract_review"},{"text":"签到","value":"checkin"},{"text":"其他","value":"other"}]},"biz_id":{"bsonType":"string","title":"业务ID","description":"关联的业务记录ID(如订单ID)"},"_add_time":{"bsonType":"date","title":"创建时间","description":"记录创建时间,系统自动填充"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"bsonType": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"_id": {
|
||||
"description": "记录ID,系统自动生成",
|
||||
"bsonType": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"bsonType": "string",
|
||||
"title": "用户ID",
|
||||
"description": "关联 uni-id-users._id",
|
||||
"foreignKey": "uni-id-users._id"
|
||||
},
|
||||
"file_name": {
|
||||
"bsonType": "string",
|
||||
"title": "文件名",
|
||||
"description": "上传的合同文件名",
|
||||
"trim": "both"
|
||||
},
|
||||
"file_size": {
|
||||
"bsonType": "number",
|
||||
"title": "文件大小",
|
||||
"description": "文件大小(字节)"
|
||||
},
|
||||
"file_id": {
|
||||
"bsonType": "string",
|
||||
"title": "扣子文件ID",
|
||||
"description": "上传到扣子后的 file_id"
|
||||
},
|
||||
"file_url": {
|
||||
"bsonType": "string",
|
||||
"title": "文件预览地址",
|
||||
"description": "uniCloud 云存储临时链接,用于查看/下载原文件"
|
||||
},
|
||||
"html_url": {
|
||||
"bsonType": "string",
|
||||
"title": "HTML报告地址",
|
||||
"description": "精美 HTML 审查报告链接"
|
||||
},
|
||||
"pdf_url": {
|
||||
"bsonType": "string",
|
||||
"title": "PDF报告地址",
|
||||
"description": "PDF 审查报告下载链接"
|
||||
},
|
||||
"contract_info": {
|
||||
"bsonType": "object",
|
||||
"title": "合同结构化信息",
|
||||
"description": "AI 返回的合同名称、风险等级、风险统计等"
|
||||
},
|
||||
"chat_id": {
|
||||
"bsonType": "string",
|
||||
"title": "对话ID",
|
||||
"description": "扣子对话 chat_id"
|
||||
},
|
||||
"conversation_id": {
|
||||
"bsonType": "string",
|
||||
"title": "会话ID",
|
||||
"description": "扣子会话 conversation_id"
|
||||
},
|
||||
"status": {
|
||||
"bsonType": "string",
|
||||
"title": "审查状态",
|
||||
"description": "uploaded=待审查 reviewing=等待处理 processing=审查中 completed=已完成 failed=失败",
|
||||
"enum": [
|
||||
{ "text": "待审查", "value": "uploaded" },
|
||||
{ "text": "等待处理", "value": "reviewing" },
|
||||
{ "text": "审查中", "value": "processing" },
|
||||
{ "text": "已完成", "value": "completed" },
|
||||
{ "text": "失败", "value": "failed" }
|
||||
]
|
||||
},
|
||||
"result": {
|
||||
"bsonType": "string",
|
||||
"title": "审查结果",
|
||||
"description": "AI 返回的审查结果内容"
|
||||
},
|
||||
"points_used": {
|
||||
"bsonType": "number",
|
||||
"title": "消耗积分",
|
||||
"description": "本次审查消耗的积分数量(token_count / 100)"
|
||||
},
|
||||
"token_count": {
|
||||
"bsonType": "number",
|
||||
"title": "Token消耗",
|
||||
"description": "扣子返回的 token 总消耗"
|
||||
},
|
||||
"_add_time": {
|
||||
"bsonType": "date",
|
||||
"title": "创建时间",
|
||||
"description": "记录创建时间"
|
||||
},
|
||||
"update_time": {
|
||||
"bsonType": "date",
|
||||
"title": "更新时间",
|
||||
"description": "最后更新时间"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -1,40 +1 @@
|
||||
{
|
||||
"bsonType": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"_id": {
|
||||
"description": "用户ID(与 uni-id-users._id 对应)",
|
||||
"bsonType": "string"
|
||||
},
|
||||
"balance": {
|
||||
"bsonType": "number",
|
||||
"title": "积分余额",
|
||||
"description": "当前可用积分",
|
||||
"defaultValue": 0,
|
||||
"minimum": 0
|
||||
},
|
||||
"total_earned": {
|
||||
"bsonType": "number",
|
||||
"title": "累计获得",
|
||||
"description": "累计获得的积分总数",
|
||||
"defaultValue": 0
|
||||
},
|
||||
"total_spent": {
|
||||
"bsonType": "number",
|
||||
"title": "累计消耗",
|
||||
"description": "累计消耗的积分总数",
|
||||
"defaultValue": 0
|
||||
},
|
||||
"gift": {
|
||||
"bsonType": "number",
|
||||
"title": "赠送积分",
|
||||
"description": "新用户赠送的积分数量",
|
||||
"defaultValue": 500
|
||||
},
|
||||
"update_time": {
|
||||
"bsonType": "date",
|
||||
"title": "更新时间",
|
||||
"description": "最后更新时间"
|
||||
}
|
||||
}
|
||||
}
|
||||
{"bsonType":"object","required":[],"properties":{"_id":{"description":"用户ID(与 uni-id-users._id 对应)","bsonType":"string"},"balance":{"bsonType":"number","title":"积分余额","description":"当前可用积分","defaultValue":0,"minimum":0},"total_earned":{"bsonType":"number","title":"累计获得","description":"累计获得的积分总数","defaultValue":0},"total_spent":{"bsonType":"number","title":"累计消耗","description":"累计消耗的积分总数","defaultValue":0},"gift":{"bsonType":"number","title":"赠送积分","description":"新用户赠送的积分数量","defaultValue":500},"update_time":{"bsonType":"date","title":"更新时间","description":"最后更新时间"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"bsonType":"object","required":[],"permission":{"read":false,"create":false,"update":false,"delete":false},"properties":{"_id":{"description":"ID,系统自动生成"}}}
|
||||
Reference in New Issue
Block a user