From faca2f85eb7b91a12a91d50cd9ef83ece58ee49b Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Fri, 12 Jun 2026 10:00:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AI=20=E5=90=88=E5=90=8C/?= =?UTF-8?q?=E7=AE=80=E5=8E=86=E5=AE=A1=E6=A0=B8=E5=8A=9F=E8=83=BD=EF=BC=88?= =?UTF-8?q?=E5=B0=8F=E7=B1=B3=20MiMo=20=E5=A4=9A=E6=A8=A1=E6=80=81?= =?UTF-8?q?=E5=A4=A7=E6=A8=A1=E5=9E=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心诉求:合同审核站在我方(德睿福)立场,找出不利条款并给出利好我方的 修改/补充建议;简历审核评估候选人与目标岗位的匹配度。 后端(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 --- .../src/main/resources/application.yml | 16 + ruoyi-oa/pom.xml | 11 + .../ruoyi/oa/aireview/DocumentParseUtil.java | 92 ++++++ .../com/ruoyi/oa/aireview/MiMoClient.java | 131 ++++++++ .../com/ruoyi/oa/aireview/MiMoProperties.java | 37 +++ .../oa/controller/OaAiReviewController.java | 58 ++++ .../java/com/ruoyi/oa/domain/OaAiReview.java | 55 ++++ .../com/ruoyi/oa/domain/bo/OaAiReviewBo.java | 21 ++ .../com/ruoyi/oa/domain/vo/OaAiReviewVo.java | 32 ++ .../com/ruoyi/oa/mapper/OaAiReviewMapper.java | 8 + .../ruoyi/oa/service/IOaAiReviewService.java | 27 ++ .../service/impl/OaAiReviewServiceImpl.java | 211 +++++++++++++ ruoyi-ui/src/api/oa/aiReview.js | 28 ++ ruoyi-ui/src/views/oa/aiReview/index.vue | 284 ++++++++++++++++++ sql/oa_ai_review.sql | 57 ++++ 15 files changed, 1068 insertions(+) create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java create mode 100644 ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java create mode 100644 ruoyi-ui/src/api/oa/aiReview.js create mode 100644 ruoyi-ui/src/views/oa/aiReview/index.vue create mode 100644 sql/oa_ai_review.sql diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index bd87293..7a76333 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -328,3 +328,19 @@ fad: webKey: 34bf20d1db5b183558b9bb85d6eed783 securityKey: 6f9171724396deb5f8c42ef256b3cbc5 +--- # 小米 MiMo 大模型(AI 合同/简历审核) +mimo: + # OpenAI 兼容接口地址(不含 /chat/completions) + base-url: https://api.xiaomimimo.com/v1 + # API Key + api-key: sk-cgdkhgch2w1cg37dl12scuckyzbnrkj37ih3b6f0k13dcgwp + # 多模态模型 + model: mimo-v2.5 + # 最大生成 token(mimo-v2.5 为推理模型,会先消耗 token 思考,需留足额度) + max-tokens: 8192 + temperature: 0.3 + # 单次请求读超时(秒)。推理+长文档审核较慢,给足时间 + timeout: 180 + # PDF 无法提取文字时(扫描件)转图片走多模态,最多渲染页数 + max-image-pages: 8 + diff --git a/ruoyi-oa/pom.xml b/ruoyi-oa/pom.xml index 6b7611c..6985e8a 100644 --- a/ruoyi-oa/pom.xml +++ b/ruoyi-oa/pom.xml @@ -83,6 +83,17 @@ 2.0.29 + + + org.apache.poi + poi-ooxml + + + org.apache.poi + poi-scratchpad + ${poi.version} + + diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java new file mode 100644 index 0000000..f38bddf --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/DocumentParseUtil.java @@ -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 renderPdfImages(byte[] bytes, int maxPages) { + List 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; + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java new file mode 100644 index 0000000..d5bf6cc --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java @@ -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 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 Key(application.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 entity = new HttpEntity<>(json.writeValueAsString(body), headers); + ResponseEntity 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()); + } + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java new file mode 100644 index 0000000..92de805 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoProperties.java @@ -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"; + + /** 最大生成 token(mimo-v2.5 为推理模型,需留足额度给思考+输出) */ + private Integer maxTokens = 8192; + + /** 采样温度 */ + private Double temperature = 0.3; + + /** 单次请求读超时(秒) */ + private Integer timeout = 180; + + /** PDF 转图片走多模态时最多渲染页数 */ + private Integer maxImagePages = 8; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java new file mode 100644 index 0000000..bb501d6 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/controller/OaAiReviewController.java @@ -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 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 list(OaAiReviewBo bo, PageQuery pageQuery) { + return service.queryPageList(bo, pageQuery); + } + + @GetMapping("/{id}") + public R getInfo(@NotNull @PathVariable Long id) { + return R.ok(service.queryById(id)); + } + + @Log(title = "AI审核", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty @PathVariable Long[] ids) { + return toAjax(service.deleteByIds(Arrays.asList(ids))); + } +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java new file mode 100644 index 0000000..ef071fd --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java @@ -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; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java new file mode 100644 index 0000000..f28da36 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/bo/OaAiReviewBo.java @@ -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; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java new file mode 100644 index 0000000..d0b8e6f --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/domain/vo/OaAiReviewVo.java @@ -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; +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java new file mode 100644 index 0000000..03dd99a --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/mapper/OaAiReviewMapper.java @@ -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 { +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java new file mode 100644 index 0000000..cd31fde --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/IOaAiReviewService.java @@ -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 queryPageList(OaAiReviewBo bo, PageQuery pageQuery); + + OaAiReviewVo queryById(Long id); + + Boolean deleteByIds(Collection ids); +} diff --git a/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java new file mode 100644 index 0000000..fa9d229 --- /dev/null +++ b/ruoyi-oa/src/main/java/com/ruoyi/oa/service/impl/OaAiReviewServiceImpl.java @@ -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 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 queryPageList(OaAiReviewBo bo, PageQuery pageQuery) { + LambdaQueryWrapper 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 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 ids) { + return baseMapper.deleteBatchIds(ids) > 0; + } +} diff --git a/ruoyi-ui/src/api/oa/aiReview.js b/ruoyi-ui/src/api/oa/aiReview.js new file mode 100644 index 0000000..f3cf709 --- /dev/null +++ b/ruoyi-ui/src/api/oa/aiReview.js @@ -0,0 +1,28 @@ +import request from '@/utils/request' + +/** + * 上传合同/简历进行 AI 审核 + * @param {FormData} data 包含 file, reviewType(contract|resume), position(可选) + */ +export function analyzeAiReview (data) { + return request({ + url: '/oa/aiReview/analyze', + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data' }, + // 推理模型 + 长文档,单次可能较慢,放宽到 5 分钟 + timeout: 300000 + }) +} + +export function listAiReview (query) { + return request({ url: '/oa/aiReview/list', method: 'get', params: query }) +} + +export function getAiReview (id) { + return request({ url: '/oa/aiReview/' + id, method: 'get' }) +} + +export function delAiReview (ids) { + return request({ url: '/oa/aiReview/' + ids, method: 'delete' }) +} diff --git a/ruoyi-ui/src/views/oa/aiReview/index.vue b/ruoyi-ui/src/views/oa/aiReview/index.vue new file mode 100644 index 0000000..45bd9be --- /dev/null +++ b/ruoyi-ui/src/views/oa/aiReview/index.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/sql/oa_ai_review.sql b/sql/oa_ai_review.sql new file mode 100644 index 0000000..8e2c747 --- /dev/null +++ b/sql/oa_ai_review.sql @@ -0,0 +1,57 @@ +-- ===================================================== +-- AI 智能审核(合同 / 简历) +-- - 使用小米 MiMo 多模态大模型(mimo-v2.5) +-- - 合同:站在“我方”立场审查,找出不利条款 + 利好我方的修改建议 +-- - 简历:评估候选人与目标岗位匹配度 +-- 本脚本可重复执行(幂等)。 +-- 注意:sys_menu 主键为雪花ID(非自增),必须显式指定。 +-- ===================================================== + +-- ---------------- 审核记录表 ---------------- +CREATE TABLE IF NOT EXISTS `oa_ai_review` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `review_type` varchar(20) NOT NULL COMMENT '类型 contract合同 / resume简历', + `file_name` varchar(255) DEFAULT NULL COMMENT '原始文件名', + `oss_id` bigint(20) DEFAULT NULL COMMENT 'OSS文件ID(原件留存)', + `file_url` varchar(500) DEFAULT NULL COMMENT 'OSS文件地址', + `position` varchar(255) DEFAULT NULL COMMENT '简历目标岗位', + `match_score` int(11) DEFAULT NULL COMMENT '简历匹配度评分 0-100', + `risk_level` varchar(10) DEFAULT NULL COMMENT '合同风险评级 高/中/低', + `result_md` longtext COMMENT 'AI 审核结果(Markdown)', + `model` varchar(50) DEFAULT NULL COMMENT '使用的模型', + `tokens` int(11) DEFAULT NULL COMMENT '消耗 token', + `create_by` varchar(64) DEFAULT NULL, + `create_time` datetime DEFAULT CURRENT_TIMESTAMP, + `update_by` varchar(64) DEFAULT NULL, + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `del_flag` char(1) DEFAULT '0' COMMENT '删除标志:0正常 2删除(mybatis-plus logicDeleteValue=2)', + PRIMARY KEY (`id`), + KEY `idx_type` (`review_type`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 审核记录(合同/简历)'; + +-- ---------------- 菜单:信息 > AI审核 ---------------- +-- 父菜单 1774989374680858626 = 「信息」 +INSERT IGNORE INTO `sys_menu` + (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`) +VALUES + (2063910000000000001, 'AI审核', 1774989374680858626, 4, + 'aiReview', 'oa/aiReview/index', 'C', '0', '0', + NULL, 'eye-open', 'admin', NOW()); + +-- ---------------- 角色授权(与「信息」下兄弟菜单一致的角色集) ---------------- +INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) +VALUES + (1743186990678077442, 2063910000000000001), -- 总经理 + (1743204526291349506, 2063910000000000001), -- 技术总监 + (1743205028123045890, 2063910000000000001), -- 信息化部 + (1852970465740505090, 2063910000000000001), -- 普通员工 + (1859257980152692738, 2063910000000000001), -- 职工 + (1859548445766717441, 2063910000000000001), -- 后勤 + (1893987128812761089, 2063910000000000001), -- 新员工临时身份 + (1914212623781187585, 2063910000000000001), -- 技术总工 + (1914213026883162113, 2063910000000000001), -- 设计主任 + (1925062159919448065, 2063910000000000001); -- 外贸专责 + +-- ---------------- 校验 ---------------- +SELECT menu_id, menu_name, path, component, icon FROM sys_menu WHERE menu_id = 2063910000000000001;