AI审核支持微调/自定义审核重点

- 新增审核可选择审核重点(字典驱动,合同/简历各一套)并填写附加要求自由文本,
  两者合并为 requirements 随请求提交,后端追加进系统提示词,让模型按需聚焦
- 审核项存字典 oa_ai_review_item_contract / oa_ai_review_item_resume,
  用户可在系统管理→字典管理自行增删审核项(无需改代码),各预置10项
- oa_ai_review 增加 requirements 列(已应用到生产库),落库留痕;详情页展示
- 前后端贯通:analyze / analyzeStream 均新增 requirements 参数

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:35:27 +08:00
parent a4f479454f
commit 9e6ae1eca9
8 changed files with 125 additions and 17 deletions

View File

@@ -37,8 +37,9 @@ public class OaAiReviewController extends BaseController {
@PostMapping(value = "/analyze", consumes = "multipart/form-data")
public R<OaAiReviewVo> analyze(@RequestParam("file") MultipartFile file,
@RequestParam("reviewType") String reviewType,
@RequestParam(value = "position", required = false) String position) {
return R.ok("审核完成", service.analyze(file, reviewType, position));
@RequestParam(value = "position", required = false) String position,
@RequestParam(value = "requirements", required = false) String requirements) {
return R.ok("审核完成", service.analyze(file, reviewType, position, requirements));
}
/**
@@ -48,8 +49,9 @@ public class OaAiReviewController extends BaseController {
@PostMapping(value = "/analyzeStream", consumes = "multipart/form-data", produces = "text/event-stream;charset=UTF-8")
public SseEmitter analyzeStream(@RequestParam("file") MultipartFile file,
@RequestParam("reviewType") String reviewType,
@RequestParam(value = "position", required = false) String position) {
return service.analyzeStream(file, reviewType, position);
@RequestParam(value = "position", required = false) String position,
@RequestParam(value = "requirements", required = false) String requirements) {
return service.analyzeStream(file, reviewType, position, requirements);
}
@GetMapping("/list")
@@ -57,7 +59,8 @@ public class OaAiReviewController extends BaseController {
return service.queryPageList(bo, pageQuery);
}
@GetMapping("/{id}")
// 限定为数字,避免 /analyzeStream 等子路径被 {id} 误匹配(否则 POST 会得到 405
@GetMapping("/{id:\\d+}")
public R<OaAiReviewVo> getInfo(@NotNull @PathVariable Long id) {
return R.ok(service.queryById(id));
}

View File

@@ -35,6 +35,9 @@ public class OaAiReview extends BaseEntity {
/** 简历审核的目标岗位 */
private String position;
/** 本次审核的附加要求/审核重点 */
private String requirements;
/** 简历匹配度评分 0-100合同为空 */
private Integer matchScore;

View File

@@ -20,6 +20,7 @@ public class OaAiReviewVo implements Serializable {
private Long ossId;
private String fileUrl;
private String position;
private String requirements;
private Integer matchScore;
private String riskLevel;
private String summary;

View File

@@ -14,16 +14,17 @@ public interface IOaAiReviewService {
/**
* 上传合同/简历并进行 AI 审核,落库并返回结果
*
* @param file PDF / Word 文件
* @param reviewType contract / resume
* @param position 简历审核的目标岗位(合同可空)
* @param file PDF / Word 文件
* @param reviewType contract / resume
* @param position 简历审核的目标岗位(合同可空)
* @param requirements 附加审核要求/重点(可空)
*/
OaAiReviewVo analyze(MultipartFile file, String reviewType, String position);
OaAiReviewVo analyze(MultipartFile file, String reviewType, String position, String requirements);
/**
* 流式审核边生成边推送SSE结束后落库
*/
SseEmitter analyzeStream(MultipartFile file, String reviewType, String position);
SseEmitter analyzeStream(MultipartFile file, String reviewType, String position, String requirements);
TableDataInfo<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery);

View File

@@ -57,8 +57,8 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
private static final Pattern RISK_PATTERN = Pattern.compile("风险评级[:\\s]*([高中低])");
@Override
public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) {
Prepared p = prepareSync(file, reviewType, position);
public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position, String requirements) {
Prepared p = prepareSync(file, reviewType, position, requirements);
buildPrompt(p);
String result = p.images != null
? miMoClient.chatMultimodal(p.system, p.userText, p.images)
@@ -70,11 +70,11 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
}
@Override
public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position) {
public SseEmitter analyzeStream(MultipartFile file, String reviewType, String position, String requirements) {
// 只在同步阶段做轻量校验 + 读取字节multipart 必须在请求线程内消费);
// 文档解析、渲染、大模型调用全部放到异步线程,任何异常都以 SSE error 事件返回,
// 避免在返回 SseEmitter 之前抛异常被全局处理器包成 JSON前端按流读取会“静默失败”
Prepared p = prepareSync(file, reviewType, position);
Prepared p = prepareSync(file, reviewType, position, requirements);
String username = currentUsername();
long timeoutMs = ((miMoProps.getTimeout() == null ? 180 : miMoProps.getTimeout()) + 60) * 1000L;
SseEmitter emitter = new SseEmitter(timeoutMs);
@@ -129,7 +129,7 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
}
/** 同步阶段:校验 + 读取字节(不解析,快) */
private Prepared prepareSync(MultipartFile file, String reviewType, String position) {
private Prepared prepareSync(MultipartFile file, String reviewType, String position, String requirements) {
if (file == null || file.isEmpty()) {
throw new ServiceException("请上传文件");
}
@@ -153,6 +153,7 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
Prepared p = new Prepared();
p.reviewType = reviewType;
p.position = position;
p.requirements = StringUtils.trimToNull(requirements);
p.fileName = fileName;
p.bytes = bytes;
return p;
@@ -160,7 +161,12 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
/** 解析文档 + 构建提示词(耗时/可能抛异常的部分) */
private void buildPrompt(Prepared p) {
p.system = "contract".equals(p.reviewType) ? contractSystemPrompt() : resumeSystemPrompt(p.position);
String base = "contract".equals(p.reviewType) ? contractSystemPrompt() : resumeSystemPrompt(p.position);
if (StringUtils.isNotBlank(p.requirements)) {
base += "\n\n【本次额外审核要求 / 重点】请在上述固定结构基础上,务必额外重点关注并逐条回应以下要求:\n"
+ p.requirements;
}
p.system = base;
String text = truncate(DocumentParseUtil.extractText(p.fileName, p.bytes));
if (StringUtils.length(text) >= MIN_TEXT_LEN) {
p.userText = "contract".equals(p.reviewType)
@@ -189,6 +195,7 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
entity.setFileUrl(oss.getUrl());
}
entity.setPosition(p.position);
entity.setRequirements(p.requirements);
entity.setResultMd(result);
entity.setSummary(buildSummary(result));
entity.setModel(miMoProps.getModel());
@@ -242,6 +249,7 @@ public class OaAiReviewServiceImpl implements IOaAiReviewService {
private static class Prepared {
String reviewType;
String position;
String requirements;
String fileName;
byte[] bytes;
String system;

View File

@@ -30,6 +30,23 @@
: '评估候选人,分析与目标岗位的匹配度、优势、短板与面试建议。' }}
支持 PDF / Word(.doc/.docx) 20MB
</div>
<!-- 审核重点 / 附加要求 -->
<div class="req-area">
<div class="req-line">
<span class="req-label">审核重点</span>
<el-checkbox-group v-model="checkedItems" size="mini" :disabled="streaming" class="req-items">
<el-checkbox v-for="it in itemOptions" :key="it.value" :label="it.label" border>{{ it.label }}</el-checkbox>
</el-checkbox-group>
<span class="req-tip">可在系统管理字典管理增删审核项</span>
</div>
<div class="req-line">
<span class="req-label">附加要求</span>
<el-input v-model="extraText" type="textarea" :rows="2" :disabled="streaming"
class="req-extra" maxlength="500" show-word-limit
placeholder="可补充本次审核的特殊关注点,例如:重点核查付款比例是否对我方有利、是否有自动续约陷阱…" />
</div>
</div>
</el-card>
<el-row :gutter="12" class="body">
@@ -96,6 +113,7 @@
<script>
import { getToken } from '@/utils/auth'
import { getDicts } from '@/api/system/dict/data'
const marked = require('marked')
export default {
@@ -107,6 +125,11 @@ export default {
rawFile: null,
fileName: '',
// 审核重点(字典) + 附加要求(自由文本)
itemDicts: { contract: [], resume: [] },
checkedItems: [],
extraText: '',
streaming: false,
done: false,
reasoning: '',
@@ -124,15 +147,37 @@ export default {
computed: {
renderedMd () {
try { return marked(this.content) } catch (e) { return this.content }
},
itemOptions () {
return this.itemDicts[this.reviewType] || []
},
requirements () {
const parts = []
if (this.checkedItems.length) parts.push('重点审核项:' + this.checkedItems.join('、'))
if (this.extraText && this.extraText.trim()) parts.push('其他要求:' + this.extraText.trim())
return parts.join('\n')
}
},
watch: {
// 切换类型时清空已选审核项(两套字典不同)
reviewType () { this.checkedItems = [] }
},
created () {
marked.setOptions({ breaks: true })
this.loadItemDicts()
},
beforeDestroy () {
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
},
methods: {
loadItemDicts () {
getDicts('oa_ai_review_item_contract').then(res => {
this.itemDicts = { ...this.itemDicts, contract: (res.data || []).map(d => ({ label: d.dictLabel, value: d.dictValue })) }
})
getDicts('oa_ai_review_item_resume').then(res => {
this.itemDicts = { ...this.itemDicts, resume: (res.data || []).map(d => ({ label: d.dictLabel, value: d.dictValue })) }
})
},
goBack () { this.$router.push('/hint/aiReview') },
goDetail () { if (this.savedId) this.$router.push('/hint/aiReview/detail/' + this.savedId) },
riskTagType (r) { return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success') },
@@ -167,6 +212,7 @@ export default {
fd.append('file', this.rawFile)
fd.append('reviewType', this.reviewType)
if (this.reviewType === 'resume' && this.position) fd.append('position', this.position)
if (this.requirements) fd.append('requirements', this.requirements)
try {
const resp = await fetch(process.env.VUE_APP_BASE_API + '/oa/aiReview/analyzeStream', {
@@ -264,6 +310,18 @@ export default {
}
.bar-hint { font-size: 12px; color: #909399; margin-top: 8px; }
.req-area { margin-top: 10px; border-top: 1px dashed #ebeef5; padding-top: 8px; }
.req-line { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px;
&:last-child { margin-bottom: 0; }
}
.req-label { flex: 0 0 56px; font-size: 12px; color: #606266; padding-top: 5px; font-weight: 600; }
.req-items { flex: 1; display: flex; flex-wrap: wrap; gap: 6px 0;
::v-deep .el-checkbox { margin-right: 8px; margin-left: 0; }
::v-deep .el-checkbox.is-bordered { padding: 4px 10px 4px 8px; height: auto; }
}
.req-tip { flex: 0 0 auto; font-size: 11px; color: #c0c4cc; padding-top: 5px; }
.req-extra { flex: 1; }
.body { margin-top: 0; }
.panel { ::v-deep .el-card__header { padding: 9px 14px; } }
.hd { display: flex; justify-content: space-between; align-items: center;

View File

@@ -29,6 +29,9 @@
<el-descriptions-item label="模型">{{ info.model || '—' }}</el-descriptions-item>
<el-descriptions-item label="审核时间">{{ info.createTime }}</el-descriptions-item>
<el-descriptions-item label="审核人">{{ info.createBy || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="info.requirements" label="审核重点 / 附加要求" :span="3">
<span style="white-space: pre-wrap">{{ info.requirements }}</span>
</el-descriptions-item>
</el-descriptions>
<div v-if="info" class="md-body" v-html="renderedMd" />

View File

@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `oa_ai_review` (
`oss_id` bigint(20) DEFAULT NULL COMMENT 'OSS文件ID原件留存',
`file_url` varchar(500) DEFAULT NULL COMMENT 'OSS文件地址',
`position` varchar(255) DEFAULT NULL COMMENT '简历目标岗位',
`requirements` varchar(1000) DEFAULT NULL COMMENT '本次审核的附加要求/审核重点',
`match_score` int(11) DEFAULT NULL COMMENT '简历匹配度评分 0-100',
`risk_level` varchar(10) DEFAULT NULL COMMENT '合同风险评级 高/中/低',
`summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要列表展示',
@@ -31,8 +32,38 @@ CREATE TABLE IF NOT EXISTS `oa_ai_review` (
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 审核记录(合同/简历)';
-- 若表已存在(旧版本),补加 summary MySQL 不支持 ADD COLUMN IF NOT EXISTS重复执行报错可忽略):
-- 若表已存在旧版本补加列MySQL 不支持 ADD COLUMN IF NOT EXISTS已存在则报错可忽略):
-- ALTER TABLE `oa_ai_review` ADD COLUMN `summary` varchar(500) DEFAULT NULL COMMENT 'AI审核结论摘要列表展示' AFTER `risk_level`;
-- ALTER TABLE `oa_ai_review` ADD COLUMN `requirements` varchar(1000) DEFAULT NULL COMMENT '本次审核的附加要求/审核重点' AFTER `position`;
-- ---------------- 字典:可选审核重点(合同 / 简历),用户可在字典管理增删 ----------------
INSERT IGNORE INTO `sys_dict_type` (`dict_id`, `dict_name`, `dict_type`, `status`, `create_by`, `create_time`, `remark`)
VALUES (2063920000000000001, 'AI合同审核项', 'oa_ai_review_item_contract', '0', 'admin', NOW(), 'AI合同审核可选审核重点'),
(2063920000000000002, 'AI简历审核项', 'oa_ai_review_item_resume', '0', 'admin', NOW(), 'AI简历审核可选审核重点');
INSERT IGNORE INTO `sys_dict_data`
(`dict_code`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, `is_default`, `status`, `create_by`, `create_time`, `remark`)
VALUES
(2063920000000000010, 1, '付款条款与节点', '付款条款与节点', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000011, 2, '违约责任与赔偿', '违约责任与赔偿', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000012, 3, '知识产权归属', '知识产权归属', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000013, 4, '保密义务', '保密义务', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000014, 5, '交付与验收标准', '交付与验收标准', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000015, 6, '质保与售后', '质保与售后', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000016, 7, '价格与税费', '价格与税费', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000017, 8, '争议解决与管辖', '争议解决与管辖', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000018, 9, '不可抗力', '不可抗力', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000019, 10,'合同解除条件', '合同解除条件', 'oa_ai_review_item_contract', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000040, 1, '技术能力匹配', '技术能力匹配', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000041, 2, '项目经验深度', '项目经验深度', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000042, 3, '工作稳定性', '工作稳定性', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000043, 4, '团队协作与管理', '团队协作与管理', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000044, 5, '学历与专业背景', '学历与专业背景', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000045, 6, '行业相关经验', '行业相关经验', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000046, 7, '薪资期望匹配', '薪资期望匹配', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000047, 8, '成长潜力', '成长潜力', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000048, 9, '离职风险', '离职风险', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), ''),
(2063920000000000049, 10,'外语能力', '外语能力', 'oa_ai_review_item_resume', '', 'default', 'N', '0', 'admin', NOW(), '');
-- ---------------- 菜单:信息 > AI审核 ----------------
-- 父菜单 1774989374680858626 = 「信息」