新增 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:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
131
ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java
Normal file
131
ruoyi-oa/src/main/java/com/ruoyi/oa/aireview/MiMoClient.java
Normal 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 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<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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
55
ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java
Normal file
55
ruoyi-oa/src/main/java/com/ruoyi/oa/domain/OaAiReview.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
28
ruoyi-ui/src/api/oa/aiReview.js
Normal file
28
ruoyi-ui/src/api/oa/aiReview.js
Normal file
@@ -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' })
|
||||
}
|
||||
284
ruoyi-ui/src/views/oa/aiReview/index.vue
Normal file
284
ruoyi-ui/src/views/oa/aiReview/index.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="app-container ai-review">
|
||||
<el-row :gutter="12">
|
||||
<!-- 左:上传 + 历史 -->
|
||||
<el-col :span="8" :xs="24">
|
||||
<el-card shadow="never" class="panel">
|
||||
<div slot="header" class="hd">
|
||||
<i class="el-icon-cpu" /> AI 智能审核
|
||||
</div>
|
||||
|
||||
<el-radio-group v-model="reviewType" size="small" class="type-switch" @change="onTypeChange">
|
||||
<el-radio-button label="contract">合同审核</el-radio-button>
|
||||
<el-radio-button label="resume">简历审核</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<div class="hint">
|
||||
{{ reviewType === 'contract'
|
||||
? '从“我方”利益角度审查合同,找出不利条款并给出利好我方的修改/补充建议。'
|
||||
: '评估候选人,分析与目标岗位的匹配度、优势短板与面试建议。' }}
|
||||
</div>
|
||||
|
||||
<el-input v-if="reviewType === 'resume'" v-model="position" size="small" class="pos-input"
|
||||
placeholder="目标岗位(选填,如:机械设计工程师)" clearable />
|
||||
|
||||
<el-upload ref="uploader" drag action="#" :auto-upload="false" :limit="1"
|
||||
:show-file-list="true" :on-change="onFileChange" :on-remove="onFileRemove"
|
||||
:file-list="fileList" accept=".pdf,.doc,.docx" class="uploader">
|
||||
<i class="el-icon-upload" />
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
<div slot="tip" class="el-upload__tip">支持 PDF / Word(.doc/.docx),单个文件 ≤ 20MB</div>
|
||||
</el-upload>
|
||||
|
||||
<el-button type="primary" icon="el-icon-magic-stick" :loading="analyzing"
|
||||
style="width:100%" @click="doAnalyze">
|
||||
{{ analyzing ? 'AI 审核中…' : '开始审核' }}
|
||||
</el-button>
|
||||
|
||||
<div class="hist-hd">
|
||||
<span><i class="el-icon-time" /> 历史记录</span>
|
||||
<el-input v-model="query.keyword" size="mini" placeholder="搜索文件名/岗位" clearable
|
||||
prefix-icon="el-icon-search" style="width:150px"
|
||||
@keyup.enter.native="searchHist" @clear="searchHist" />
|
||||
</div>
|
||||
<div v-loading="histLoading" class="hist-list">
|
||||
<div v-if="histList.length === 0 && !histLoading" class="hist-empty">暂无记录</div>
|
||||
<div v-for="r in histList" :key="r.id" class="hist-item"
|
||||
:class="{ sel: current && current.id === r.id }" @click="viewHistory(r.id)">
|
||||
<div class="hi-top">
|
||||
<el-tag size="mini" :type="r.reviewType === 'contract' ? 'warning' : 'success'" effect="plain">
|
||||
{{ r.reviewType === 'contract' ? '合同' : '简历' }}
|
||||
</el-tag>
|
||||
<span v-if="r.reviewType === 'resume' && r.matchScore != null" class="hi-badge score">
|
||||
{{ r.matchScore }}分
|
||||
</span>
|
||||
<span v-if="r.reviewType === 'contract' && r.riskLevel" class="hi-badge"
|
||||
:class="riskClass(r.riskLevel)">{{ r.riskLevel }}风险</span>
|
||||
<el-button type="text" icon="el-icon-delete" class="hi-del" @click.stop="delHistory(r)" />
|
||||
</div>
|
||||
<div class="hi-name">{{ r.fileName }}</div>
|
||||
<div class="hi-meta">{{ r.position || '—' }} · {{ r.createTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="query.pageNum"
|
||||
:limit.sync="query.pageSize" :page-sizes="[10,20,50]" small @pagination="loadHist" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右:结果 -->
|
||||
<el-col :span="16" :xs="24">
|
||||
<el-card shadow="never" class="panel result-panel">
|
||||
<div slot="header" class="hd result-hd">
|
||||
<span><i class="el-icon-document-checked" /> 审核结果</span>
|
||||
<div v-if="current" class="result-tags">
|
||||
<el-tag size="mini" :type="current.reviewType === 'contract' ? 'warning' : 'success'">
|
||||
{{ current.reviewType === 'contract' ? '合同' : '简历' }}
|
||||
</el-tag>
|
||||
<span class="r-file">{{ current.fileName }}</span>
|
||||
<el-tag v-if="current.reviewType === 'resume' && current.matchScore != null"
|
||||
size="mini" type="success" effect="dark">匹配度 {{ current.matchScore }}</el-tag>
|
||||
<el-tag v-if="current.reviewType === 'contract' && current.riskLevel"
|
||||
size="mini" :type="riskTagType(current.riskLevel)" effect="dark">
|
||||
{{ current.riskLevel }}风险</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="analyzing" :element-loading-text="loadingText" class="result-body">
|
||||
<div v-if="!current && !analyzing" class="result-empty">
|
||||
<i class="el-icon-cpu" />
|
||||
<div>上传合同或简历,点击“开始审核”获取 AI 分析结果</div>
|
||||
</div>
|
||||
<div v-else-if="current" class="md-body" v-html="renderedMd" />
|
||||
<div v-else class="result-empty"><div>{{ loadingText }}</div></div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { analyzeAiReview, listAiReview, getAiReview, delAiReview } from '@/api/oa/aiReview'
|
||||
const marked = require('marked')
|
||||
|
||||
export default {
|
||||
name: 'OaAiReview',
|
||||
data () {
|
||||
return {
|
||||
reviewType: 'contract',
|
||||
position: '',
|
||||
fileList: [],
|
||||
rawFile: null,
|
||||
|
||||
analyzing: false,
|
||||
loadingText: 'AI 审核中,长文档可能需要 30~90 秒,请稍候…',
|
||||
current: null,
|
||||
|
||||
histList: [],
|
||||
total: 0,
|
||||
histLoading: false,
|
||||
query: { pageNum: 1, pageSize: 10, keyword: '', reviewType: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderedMd () {
|
||||
if (!this.current || !this.current.resultMd) return ''
|
||||
try { return marked(this.current.resultMd) } catch (e) { return this.current.resultMd }
|
||||
}
|
||||
},
|
||||
created () {
|
||||
marked.setOptions({ breaks: true })
|
||||
this.loadHist()
|
||||
},
|
||||
methods: {
|
||||
onTypeChange () {
|
||||
this.query.reviewType = ''
|
||||
this.loadHist()
|
||||
},
|
||||
onFileChange (file, fileList) {
|
||||
// 仅保留最后一个文件
|
||||
if (fileList.length > 1) fileList.splice(0, fileList.length - 1)
|
||||
const isOk = /\.(pdf|doc|docx)$/i.test(file.name)
|
||||
if (!isOk) {
|
||||
this.$modal.msgError('仅支持 PDF / Word(.doc/.docx)')
|
||||
this.fileList = []
|
||||
this.rawFile = null
|
||||
return
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
this.$modal.msgError('文件不能超过 20MB')
|
||||
this.fileList = []
|
||||
this.rawFile = null
|
||||
return
|
||||
}
|
||||
this.rawFile = file.raw
|
||||
this.fileList = [file]
|
||||
},
|
||||
onFileRemove () {
|
||||
this.rawFile = null
|
||||
this.fileList = []
|
||||
},
|
||||
async doAnalyze () {
|
||||
if (!this.rawFile) return this.$modal.msgError('请先上传文件')
|
||||
const fd = new FormData()
|
||||
fd.append('file', this.rawFile)
|
||||
fd.append('reviewType', this.reviewType)
|
||||
if (this.reviewType === 'resume' && this.position) fd.append('position', this.position)
|
||||
this.analyzing = true
|
||||
this.current = null
|
||||
try {
|
||||
const res = await analyzeAiReview(fd)
|
||||
this.current = res.data
|
||||
this.$modal.msgSuccess('审核完成')
|
||||
this.loadHist()
|
||||
} catch (e) {
|
||||
// request.js 已弹错误提示
|
||||
} finally {
|
||||
this.analyzing = false
|
||||
}
|
||||
},
|
||||
|
||||
searchHist () {
|
||||
this.query.pageNum = 1
|
||||
this.loadHist()
|
||||
},
|
||||
loadHist () {
|
||||
this.histLoading = true
|
||||
this.query.reviewType = this.reviewType
|
||||
listAiReview(this.query).then(res => {
|
||||
this.histList = res.rows || []
|
||||
this.total = res.total || 0
|
||||
}).finally(() => { this.histLoading = false })
|
||||
},
|
||||
viewHistory (id) {
|
||||
getAiReview(id).then(res => { this.current = res.data })
|
||||
},
|
||||
delHistory (r) {
|
||||
this.$modal.confirm(`确认删除「${r.fileName}」的审核记录?`).then(() => {
|
||||
return delAiReview(r.id)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('已删除')
|
||||
if (this.current && this.current.id === r.id) this.current = null
|
||||
this.loadHist()
|
||||
}).catch(() => {})
|
||||
},
|
||||
|
||||
riskClass (r) {
|
||||
return r === '高' ? 'risk-high' : (r === '中' ? 'risk-mid' : 'risk-low')
|
||||
},
|
||||
riskTagType (r) {
|
||||
return r === '高' ? 'danger' : (r === '中' ? 'warning' : 'success')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ai-review { padding: 10px; }
|
||||
.panel { ::v-deep .el-card__header { padding: 10px 14px; } }
|
||||
.hd { font-weight: 600; font-size: 14px; color: #303133; i { color: #409eff; margin-right: 4px; } }
|
||||
|
||||
.type-switch { margin-bottom: 10px; width: 100%;
|
||||
::v-deep .el-radio-button { width: 50%; }
|
||||
::v-deep .el-radio-button__inner { width: 100%; }
|
||||
}
|
||||
.hint { font-size: 12px; color: #909399; line-height: 1.6; margin-bottom: 10px; }
|
||||
.pos-input { margin-bottom: 10px; }
|
||||
.uploader { margin-bottom: 10px;
|
||||
::v-deep .el-upload, ::v-deep .el-upload-dragger { width: 100%; }
|
||||
::v-deep .el-upload-dragger { height: 130px; }
|
||||
.el-icon-upload { font-size: 40px; color: #c0c4cc; margin: 18px 0 8px; line-height: 1; }
|
||||
}
|
||||
|
||||
.hist-hd { display: flex; justify-content: space-between; align-items: center;
|
||||
margin: 14px 0 8px; font-size: 13px; font-weight: 600; color: #303133;
|
||||
i { color: #409eff; margin-right: 4px; }
|
||||
}
|
||||
.hist-list { max-height: calc(100vh - 470px); overflow-y: auto; display: flex; flex-direction: column; gap: 6px; }
|
||||
.hist-empty { text-align: center; color: #c0c4cc; padding: 20px 0; font-size: 12px; }
|
||||
.hist-item { border: 1px solid #ebeef5; border-radius: 4px; padding: 6px 10px; cursor: pointer; transition: .15s;
|
||||
&:hover { border-color: #409eff; background: #fafdff; }
|
||||
&.sel { border-color: #409eff; background: #ecf5ff; }
|
||||
.hi-top { display: flex; align-items: center; gap: 6px; }
|
||||
.hi-badge { font-size: 11px; padding: 0 5px; border-radius: 8px; line-height: 16px;
|
||||
&.score { background: #f0f9eb; color: #67c23a; }
|
||||
&.risk-high { background: #fef0f0; color: #f56c6c; }
|
||||
&.risk-mid { background: #fdf6ec; color: #e6a23c; }
|
||||
&.risk-low { background: #f0f9eb; color: #67c23a; }
|
||||
}
|
||||
.hi-del { margin-left: auto; padding: 0; color: #c0c4cc; &:hover { color: #f56c6c; } }
|
||||
.hi-name { font-size: 13px; color: #303133; margin: 3px 0 1px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hi-meta { font-size: 11px; color: #909399; }
|
||||
}
|
||||
|
||||
.result-panel { min-height: calc(100vh - 140px); }
|
||||
.result-hd { display: flex; justify-content: space-between; align-items: center;
|
||||
.result-tags { display: flex; align-items: center; gap: 8px;
|
||||
.r-file { font-size: 12px; color: #606266; max-width: 320px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
}
|
||||
}
|
||||
.result-body { min-height: 400px; }
|
||||
.result-empty { text-align: center; color: #c0c4cc; padding: 100px 0;
|
||||
i { font-size: 56px; opacity: .4; display: block; margin-bottom: 14px; }
|
||||
}
|
||||
|
||||
.md-body {
|
||||
font-size: 14px; color: #2c3e50; line-height: 1.8; padding: 4px 6px;
|
||||
::v-deep {
|
||||
h1, h2, h3 { color: #303133; margin: 18px 0 10px; font-weight: 600; }
|
||||
h1 { font-size: 19px; } h2 { font-size: 16px; border-left: 3px solid #409eff; padding-left: 8px; }
|
||||
h3 { font-size: 14px; }
|
||||
p { margin: 8px 0; }
|
||||
ul, ol { padding-left: 22px; margin: 8px 0; }
|
||||
li { margin: 4px 0; }
|
||||
strong { color: #c0392b; }
|
||||
code { background: #f4f7fa; padding: 1px 5px; border-radius: 3px; font-size: 13px; color: #e6a23c; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
|
||||
th, td { border: 1px solid #ebeef5; padding: 6px 10px; font-size: 13px; }
|
||||
th { background: #f5f7fa; }
|
||||
blockquote { border-left: 3px solid #dcdfe6; padding-left: 10px; color: #909399; margin: 8px 0; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
sql/oa_ai_review.sql
Normal file
57
sql/oa_ai_review.sql
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user