新增 AI 合同/简历审核功能(小米 MiMo 多模态大模型)

核心诉求:合同审核站在我方(德睿福)立场,找出不利条款并给出利好我方的
修改/补充建议;简历审核评估候选人与目标岗位的匹配度。

后端(ruoyi-oa):
- 接入小米 MiMo(OpenAI 兼容 /chat/completions),mimo-v2.5 多模态模型
  · MiMoProperties 绑定 application.yml mimo: 配置(base-url/api-key/model/...)
  · MiMoClient:text + multimodal(image_url base64) 两种调用,独立长超时
    RestTemplate;mimo-v2.5 是推理模型,max-tokens 配 8192 留足思考额度
- DocumentParseUtil:PDF 文字(PDFBox)、Word(POI: docx XWPF / doc HWPF),
  扫描版 PDF(提取文字过短)用 PDFRenderer 转 PNG 走多模态
- OaAiReview 实体 + BO/VO/Mapper/Service/Controller(/oa/aiReview)
  · analyze 上传解析→构建提示词→调用大模型→留存原件(OSS)→落库
  · 合同/简历两套提示词;正则解析风险评级:高/中/低与匹配度评分:NN入库
  · 提供 list/detail/delete
- ruoyi-oa/pom.xml 增加 poi-ooxml、poi-scratchpad(Word 解析)
- application.yml 增加 mimo: 配置块

前端(ruoyi-ui):
- views/oa/aiReview/index.vue:类型切换(合同/简历)、拖拽上传(pdf/word)、
  简历目标岗位输入、审核(loading)、Markdown 结果渲染、历史记录列表
- api/oa/aiReview.js:analyze 用 FormData,超时放宽到 5 分钟

SQL(已应用到生产库):
- oa_ai_review 表;菜单挂信息下(menu_id 2063910000000000001),授权10个角色

已用真实接口端到端验证:合同审核输出利好我方意见、风险评级可正确解析。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 10:00:09 +08:00
parent 6f64c3d4af
commit faca2f85eb
15 changed files with 1068 additions and 0 deletions

View File

@@ -83,6 +83,17 @@
<version>2.0.29</version>
</dependency>
<!-- 解析 Word(.docx/.doc) 简历/合同 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>${poi.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,92 @@
package com.ruoyi.oa.aireview;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.poi.hwpf.extractor.WordExtractor;
import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* 文档解析:从 PDF / Word 提取文字;扫描版 PDF 渲染为图片走多模态。
*
* @author wangyu
*/
@Slf4j
public final class DocumentParseUtil {
private DocumentParseUtil() {}
/** 提取文字内容PDF / docx / doc。提取不到返回空串不抛异常。 */
public static String extractText(String fileName, byte[] bytes) {
String lower = fileName == null ? "" : fileName.toLowerCase();
try {
if (lower.endsWith(".pdf")) {
return extractPdfText(bytes);
} else if (lower.endsWith(".docx")) {
try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes));
XWPFWordExtractor ex = new XWPFWordExtractor(doc)) {
return StringUtils.trimToEmpty(ex.getText());
}
} else if (lower.endsWith(".doc")) {
try (WordExtractor ex = new WordExtractor(new ByteArrayInputStream(bytes))) {
return StringUtils.trimToEmpty(ex.getText());
}
}
throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件");
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("解析文档失败:{}", fileName, e);
return "";
}
}
private static String extractPdfText(byte[] bytes) throws Exception {
try (PDDocument doc = PDDocument.load(bytes)) {
PDFTextStripper stripper = new PDFTextStripper();
return StringUtils.trimToEmpty(stripper.getText(doc));
}
}
/** 是否为 PDF */
public static boolean isPdf(String fileName) {
return fileName != null && fileName.toLowerCase().endsWith(".pdf");
}
/**
* 将 PDF 渲染为 PNG 图片的 data URI 列表(用于扫描件走多模态)。
*
* @param maxPages 最多渲染页数
*/
public static List<String> renderPdfImages(byte[] bytes, int maxPages) {
List<String> images = new ArrayList<>();
try (PDDocument doc = PDDocument.load(bytes)) {
PDFRenderer renderer = new PDFRenderer(doc);
int pages = Math.min(doc.getNumberOfPages(), Math.max(1, maxPages));
for (int i = 0; i < pages; i++) {
BufferedImage img = renderer.renderImageWithDPI(i, 120, ImageType.RGB);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, "png", baos);
String b64 = Base64.getEncoder().encodeToString(baos.toByteArray());
images.add("data:image/png;base64," + b64);
}
} catch (Exception e) {
log.error("PDF 转图片失败", e);
throw new ServiceException("PDF 转图片失败:" + e.getMessage());
}
return images;
}
}

View File

@@ -0,0 +1,131 @@
package com.ruoyi.oa.aireview;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.annotation.PostConstruct;
import java.util.List;
/**
* 小米 MiMo 大模型调用客户端OpenAI 兼容 /chat/completions
*
* 注意mimo-v2.5 是推理模型,响应里 message.content 是最终答案,
* message.reasoning_content 是思考过程max_completion_tokens 要给足,
* 否则 token 被思考耗尽会出现 content 为空、finish_reason=length。
*
* @author wangyu
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MiMoClient {
private final MiMoProperties props;
private final ObjectMapper json = new ObjectMapper();
private RestTemplate restTemplate;
@PostConstruct
public void init() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(10_000);
factory.setReadTimeout((props.getTimeout() == null ? 180 : props.getTimeout()) * 1000);
this.restTemplate = new RestTemplate(factory);
}
/**
* 纯文本对话
*/
public String chatText(String systemPrompt, String userText) {
ObjectNode body = baseBody();
ArrayNode messages = body.putArray("messages");
if (StringUtils.isNotBlank(systemPrompt)) {
messages.addObject().put("role", "system").put("content", systemPrompt);
}
messages.addObject().put("role", "user").put("content", userText);
return send(body);
}
/**
* 多模态对话:一段文字 + 若干图片data URI形如 data:image/png;base64,xxx
*/
public String chatMultimodal(String systemPrompt, String userText, List<String> imageDataUris) {
ObjectNode body = baseBody();
ArrayNode messages = body.putArray("messages");
if (StringUtils.isNotBlank(systemPrompt)) {
messages.addObject().put("role", "system").put("content", systemPrompt);
}
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
ArrayNode content = userMsg.putArray("content");
content.addObject().put("type", "text").put("text", userText);
if (imageDataUris != null) {
for (String uri : imageDataUris) {
if (StringUtils.isBlank(uri)) continue;
ObjectNode img = content.addObject();
img.put("type", "image_url");
img.putObject("image_url").put("url", uri);
}
}
return send(body);
}
private ObjectNode baseBody() {
ObjectNode body = json.createObjectNode();
body.put("model", props.getModel());
// OpenAI 兼容新参数名MiMo 同时也接受 max_tokens这里两者都给以防万一
body.put("max_completion_tokens", props.getMaxTokens());
body.put("max_tokens", props.getMaxTokens());
body.put("temperature", props.getTemperature());
body.put("stream", false);
return body;
}
private String send(ObjectNode body) {
if (StringUtils.isBlank(props.getApiKey())) {
throw new ServiceException("未配置 MiMo API Keyapplication.yml: mimo.api-key");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// 文档要求 api-key 头;同时带 Bearer 以兼容 OpenAI 风格
headers.set("api-key", props.getApiKey());
headers.set("Authorization", "Bearer " + props.getApiKey());
String url = props.getBaseUrl() + "/chat/completions";
try {
HttpEntity<String> entity = new HttpEntity<>(json.writeValueAsString(body), headers);
ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
if (resp.getStatusCode() != HttpStatus.OK || resp.getBody() == null) {
throw new ServiceException("AI 服务返回异常:" + resp.getStatusCode());
}
JsonNode root = json.readTree(resp.getBody());
JsonNode message = root.path("choices").path(0).path("message");
String content = message.path("content").asText("");
String finish = root.path("choices").path(0).path("finish_reason").asText("");
if (StringUtils.isBlank(content)) {
if ("length".equals(finish)) {
throw new ServiceException("文档过长AI 输出被截断,请精简文档或提高 mimo.max-tokens 后重试");
}
throw new ServiceException("AI 未返回有效内容");
}
int total = root.path("usage").path("total_tokens").asInt(0);
log.info("MiMo 审核完成model={}, 消耗 token={}", props.getModel(), total);
return content;
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("调用 MiMo 失败", e);
throw new ServiceException("AI 服务调用失败:" + e.getMessage());
}
}
}

View File

@@ -0,0 +1,37 @@
package com.ruoyi.oa.aireview;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 小米 MiMo 大模型配置AI 合同/简历审核)
*
* @author wangyu
*/
@Data
@Component
@ConfigurationProperties(prefix = "mimo")
public class MiMoProperties {
/** OpenAI 兼容接口地址(不含 /chat/completions */
private String baseUrl = "https://api.xiaomimimo.com/v1";
/** API Key */
private String apiKey;
/** 多模态模型名 */
private String model = "mimo-v2.5";
/** 最大生成 tokenmimo-v2.5 为推理模型,需留足额度给思考+输出) */
private Integer maxTokens = 8192;
/** 采样温度 */
private Double temperature = 0.3;
/** 单次请求读超时(秒) */
private Integer timeout = 180;
/** PDF 转图片走多模态时最多渲染页数 */
private Integer maxImagePages = 8;
}

View File

@@ -0,0 +1,58 @@
package com.ruoyi.oa.controller;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
import com.ruoyi.oa.service.IOaAiReviewService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.Arrays;
/**
* AI 审核(合同 / 简历)
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/oa/aiReview")
public class OaAiReviewController extends BaseController {
private final IOaAiReviewService service;
/**
* 上传并审核
*/
@Log(title = "AI审核", businessType = BusinessType.OTHER)
@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));
}
@GetMapping("/list")
public TableDataInfo<OaAiReviewVo> list(OaAiReviewBo bo, PageQuery pageQuery) {
return service.queryPageList(bo, pageQuery);
}
@GetMapping("/{id}")
public R<OaAiReviewVo> getInfo(@NotNull @PathVariable Long id) {
return R.ok(service.queryById(id));
}
@Log(title = "AI审核", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@NotEmpty @PathVariable Long[] ids) {
return toAjax(service.deleteByIds(Arrays.asList(ids)));
}
}

View File

@@ -0,0 +1,55 @@
package com.ruoyi.oa.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* AI 审核记录(合同 / 简历)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("oa_ai_review")
public class OaAiReview extends BaseEntity {
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
/** 审核类型contract 合同 / resume 简历 */
private String reviewType;
/** 原始文件名 */
private String fileName;
/** OSS 文件ID原件留存可空 */
private Long ossId;
/** OSS 文件地址 */
private String fileUrl;
/** 简历审核的目标岗位 */
private String position;
/** 简历匹配度评分 0-100合同为空 */
private Integer matchScore;
/** 合同总体风险评级:高/中/低(简历为空) */
private String riskLevel;
/** AI 审核结果Markdown */
private String resultMd;
/** 使用的模型 */
private String model;
/** 消耗 token */
private Integer tokens;
@TableLogic
private String delFlag;
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.oa.domain.bo;
import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* AI 审核记录 查询 BO
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class OaAiReviewBo extends BaseEntity {
private Long id;
/** 审核类型contract / resume */
private String reviewType;
/** 关键字(文件名 / 岗位) */
private String keyword;
}

View File

@@ -0,0 +1,32 @@
package com.ruoyi.oa.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* AI 审核记录 VO
*/
@Data
public class OaAiReviewVo implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String reviewType;
private String fileName;
private Long ossId;
private String fileUrl;
private String position;
private Integer matchScore;
private String riskLevel;
private String resultMd;
private String model;
private Integer tokens;
private String createBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
}

View File

@@ -0,0 +1,8 @@
package com.ruoyi.oa.mapper;
import com.ruoyi.common.core.mapper.BaseMapperPlus;
import com.ruoyi.oa.domain.OaAiReview;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
public interface OaAiReviewMapper extends BaseMapperPlus<OaAiReviewMapper, OaAiReview, OaAiReviewVo> {
}

View File

@@ -0,0 +1,27 @@
package com.ruoyi.oa.service;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
import org.springframework.web.multipart.MultipartFile;
import java.util.Collection;
public interface IOaAiReviewService {
/**
* 上传合同/简历并进行 AI 审核,落库并返回结果
*
* @param file PDF / Word 文件
* @param reviewType contract / resume
* @param position 简历审核的目标岗位(合同可空)
*/
OaAiReviewVo analyze(MultipartFile file, String reviewType, String position);
TableDataInfo<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery);
OaAiReviewVo queryById(Long id);
Boolean deleteByIds(Collection<Long> ids);
}

View File

@@ -0,0 +1,211 @@
package com.ruoyi.oa.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.oa.aireview.DocumentParseUtil;
import com.ruoyi.oa.aireview.MiMoClient;
import com.ruoyi.oa.aireview.MiMoProperties;
import com.ruoyi.oa.domain.OaAiReview;
import com.ruoyi.oa.domain.bo.OaAiReviewBo;
import com.ruoyi.oa.domain.vo.OaAiReviewVo;
import com.ruoyi.oa.mapper.OaAiReviewMapper;
import com.ruoyi.oa.service.IOaAiReviewService;
import com.ruoyi.system.domain.vo.SysOssVo;
import com.ruoyi.system.service.ISysOssService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* AI 审核(合同 / 简历)
*
* @author wangyu
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OaAiReviewServiceImpl implements IOaAiReviewService {
private final OaAiReviewMapper baseMapper;
private final MiMoClient miMoClient;
private final MiMoProperties miMoProps;
private final ISysOssService ossService;
/** 文字内容少于该长度,认为是扫描件/图片版PDF 走多模态 */
private static final int MIN_TEXT_LEN = 30;
/** 文档过长时截断,避免 prompt 过大 */
private static final int MAX_TEXT_LEN = 24000;
private static final Pattern SCORE_PATTERN = Pattern.compile("匹配度评分[:\\s]*([0-9]{1,3})");
private static final Pattern RISK_PATTERN = Pattern.compile("风险评级[:\\s]*([高中低])");
@Override
public OaAiReviewVo analyze(MultipartFile file, String reviewType, String position) {
if (file == null || file.isEmpty()) {
throw new ServiceException("请上传文件");
}
if (!"contract".equals(reviewType) && !"resume".equals(reviewType)) {
throw new ServiceException("审核类型不正确");
}
String fileName = file.getOriginalFilename();
if (StringUtils.isBlank(fileName)) {
throw new ServiceException("文件名为空");
}
String lower = fileName.toLowerCase();
if (!(lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx"))) {
throw new ServiceException("仅支持 PDF / Word(.doc/.docx) 文件");
}
byte[] bytes;
try {
bytes = file.getBytes();
} catch (Exception e) {
throw new ServiceException("读取文件失败");
}
// 1. 提取文字
String text = DocumentParseUtil.extractText(fileName, bytes);
text = truncate(text);
// 2. 构建提示词
String system = "contract".equals(reviewType) ? contractSystemPrompt() : resumeSystemPrompt(position);
// 3. 调用大模型(文字优先;扫描版 PDF 走多模态)
String result;
if (StringUtils.length(text) >= MIN_TEXT_LEN) {
String userText = "contract".equals(reviewType)
? "以下是待审核的合同全文:\n\n" + text
: "以下是待评估的简历内容:"
+ (StringUtils.isNotBlank(position) ? "(目标岗位:" + position + "" : "") + "\n\n" + text;
result = miMoClient.chatText(system, userText);
} else if (DocumentParseUtil.isPdf(fileName)) {
List<String> images = DocumentParseUtil.renderPdfImages(bytes, miMoProps.getMaxImagePages());
String userText = "contract".equals(reviewType)
? "请审核以下图片中的合同(扫描件),逐页通读后给出审核意见。"
: "请评估以下图片中的简历(扫描件)。"
+ (StringUtils.isNotBlank(position) ? "目标岗位:" + position + "" : "");
result = miMoClient.chatMultimodal(system, userText, images);
} else {
throw new ServiceException("未能从该 Word 文件中提取到文字内容,请确认文件未加密或改用 PDF");
}
// 4. 留存原件(失败不阻塞主流程)
Long ossId = null;
String fileUrl = null;
try {
SysOssVo oss = ossService.upload(file, 0L);
if (oss != null) {
ossId = oss.getOssId();
fileUrl = oss.getUrl();
}
} catch (Exception e) {
log.warn("AI审核原件留存失败{}", fileName, e);
}
// 5. 落库
OaAiReview entity = new OaAiReview();
entity.setReviewType(reviewType);
entity.setFileName(fileName);
entity.setOssId(ossId);
entity.setFileUrl(fileUrl);
entity.setPosition(position);
entity.setResultMd(result);
entity.setModel(miMoProps.getModel());
if ("resume".equals(reviewType)) {
entity.setMatchScore(parseInt(SCORE_PATTERN, result, 100));
} else {
entity.setRiskLevel(parseStr(RISK_PATTERN, result));
}
baseMapper.insert(entity);
return baseMapper.selectVoById(entity.getId());
}
private String truncate(String text) {
if (text == null) return "";
return text.length() > MAX_TEXT_LEN ? text.substring(0, MAX_TEXT_LEN) : text;
}
private Integer parseInt(Pattern p, String text, int max) {
Matcher m = p.matcher(text == null ? "" : text);
if (m.find()) {
try {
int v = Integer.parseInt(m.group(1));
if (v >= 0 && v <= max) return v;
} catch (NumberFormatException ignored) {}
}
return null;
}
private String parseStr(Pattern p, String text) {
Matcher m = p.matcher(text == null ? "" : text);
return m.find() ? m.group(1) : null;
}
private String contractSystemPrompt() {
return "你是德睿福成套设备有限公司聘请的资深合同法务与商务谈判顾问。"
+ "你的立场是【最大化保护并争取“我方”(德睿福)的利益】——审查时始终站在我方角度,"
+ "识别对我方不利的安排,并提出利好我方的修改与补充建议。\n"
+ "请用简体中文、Markdown 格式输出审核报告,包含以下小节(用二级标题):\n"
+ "## 一、合同概要\n合同类型、双方主体、标的、金额、期限等关键信息\n"
+ "## 二、总体风险评级\n必须单独一行明确写出`风险评级:高` 或 `风险评级:中` 或 `风险评级:低`,再附简要理由)\n"
+ "## 三、对我方不利/存在风险的条款\n逐条列出① 原文摘录 ② 风险说明 ③ 利好我方的修改建议)\n"
+ "## 四、建议补充的利好我方条款\n如违约金、付款节点与比例、质保与验收、知识产权归属、保密、不可抗力、争议解决地与管辖等\n"
+ "## 五、关键条款审查\n付款、交付、验收、违约责任、责任限制/赔偿上限、解除权等)\n"
+ "## 六、谈判要点与优先级\n按重要性排序给出可直接用于谈判的要点\n"
+ "注意:若合同中我方实为乙方/供方,请据实判断我方身份后仍以争取我方利益为目标。";
}
private String resumeSystemPrompt(String position) {
String posLine = StringUtils.isNotBlank(position)
? "目标岗位为【" + position + "】,请重点评估候选人与该岗位的匹配度。\n"
: "未指定目标岗位,请综合判断其最适合的岗位方向并据此评估。\n";
return "你是德睿福成套设备有限公司的资深招聘官与技术面试官。" + posLine
+ "请用简体中文、Markdown 格式输出评估报告,包含以下小节(用二级标题):\n"
+ "## 一、候选人概要\n姓名、工作年限、学历、当前/最近岗位、期望等,能提取则填)\n"
+ "## 二、岗位匹配度\n必须单独一行明确写出`匹配度评分NN`NN 为 0-100 的整数;随后给出评分理由,"
+ "结合岗位所需的技能、经验、行业背景逐点对照)\n"
+ "## 三、核心优势\n"
+ "## 四、短板与风险点\n经历空窗、跳槽频繁、技能缺口等\n"
+ "## 五、建议重点考察的面试问题\n针对其经历与岗位要求给出 5 个左右有针对性的问题)\n"
+ "## 六、录用建议\n明确给出建议录用 / 谨慎考虑 / 不建议,并说明理由)";
}
@Override
public TableDataInfo<OaAiReviewVo> queryPageList(OaAiReviewBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<OaAiReview> lqw = Wrappers.lambdaQuery();
if (bo != null) {
lqw.eq(StringUtils.isNotBlank(bo.getReviewType()), OaAiReview::getReviewType, bo.getReviewType());
if (StringUtils.isNotBlank(bo.getKeyword())) {
String kw = bo.getKeyword().trim();
lqw.and(w -> w.like(OaAiReview::getFileName, kw).or().like(OaAiReview::getPosition, kw));
}
}
lqw.orderByDesc(OaAiReview::getCreateTime);
Page<OaAiReviewVo> page = baseMapper.selectVoPage(pageQuery.build(), lqw);
return TableDataInfo.build(page);
}
@Override
public OaAiReviewVo queryById(Long id) {
OaAiReviewVo vo = baseMapper.selectVoById(id);
if (vo == null) throw new ServiceException("记录不存在或已删除");
return vo;
}
@Override
public Boolean deleteByIds(Collection<Long> ids) {
return baseMapper.deleteBatchIds(ids) > 0;
}
}