feat(aihr): persist interview results and candidate material review
面试记录落 aihr_interview_result(GET /records 查询),候选人补充资料 走 OSS + aihr_candidate_material,管理端 AI 面试页可审核通过/驳回; 案例整理服务增强;两张新表纳入 reset 脚本。
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.service.AihrCandidateMaterialService;
|
||||
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialResponse;
|
||||
import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialReviewResponse;
|
||||
import org.dromara.aihr.service.AihrCandidateMaterialService.ReviewRequest;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class AihrCandidateMaterialController {
|
||||
|
||||
private final AihrCandidateMaterialService materialService;
|
||||
|
||||
@PostMapping(value = "/api/aihr/mobile/candidate/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<MaterialResponse> upload(@RequestPart("file") MultipartFile file,
|
||||
@RequestParam(value = "candidateId", required = false) String candidateId,
|
||||
@RequestParam(value = "candidateName", required = false) String candidateName,
|
||||
@RequestParam(value = "materialType", required = false) String materialType) {
|
||||
return R.ok(materialService.upload(file, candidateId, candidateName, materialType));
|
||||
}
|
||||
|
||||
@GetMapping("/api/aihr/mobile/candidate/materials")
|
||||
public R<List<MaterialResponse>> list(@RequestParam(value = "candidateId", required = false) String candidateId) {
|
||||
return R.ok(materialService.list(candidateId));
|
||||
}
|
||||
|
||||
@GetMapping("/api/aihr/hr/candidate/materials")
|
||||
public R<List<MaterialReviewResponse>> reviewList(@RequestParam(value = "status", required = false) String status) {
|
||||
return R.ok(materialService.reviewList(status));
|
||||
}
|
||||
|
||||
@PostMapping("/api/aihr/hr/candidate/materials/{id}/review")
|
||||
public R<MaterialReviewResponse> review(@PathVariable Long id, @RequestBody ReviewRequest request) {
|
||||
return R.ok(materialService.review(id, request));
|
||||
}
|
||||
}
|
||||
+10
@@ -5,15 +5,20 @@ import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.dromara.aihr.service.AihrInterviewService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 面试 API。
|
||||
*/
|
||||
@@ -38,4 +43,9 @@ public class AihrInterviewController {
|
||||
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
||||
return R.ok(interviewService.finish(request));
|
||||
}
|
||||
|
||||
@GetMapping("/records")
|
||||
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit) {
|
||||
return R.ok(interviewService.records(limit));
|
||||
}
|
||||
}
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AihrCandidateMaterialService {
|
||||
|
||||
private static final String TENANT_ID = "000000";
|
||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
|
||||
private static final long MAX_SIZE = 20L * 1024 * 1024;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public MaterialResponse upload(MultipartFile file, String candidateId, String candidateName, String materialType) {
|
||||
ensureTable();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("请先选择补充资料");
|
||||
}
|
||||
if (file.getSize() > MAX_SIZE) {
|
||||
throw new IllegalArgumentException("资料不能超过20MB");
|
||||
}
|
||||
String fileName = clean(file.getOriginalFilename(), "candidate-material");
|
||||
String type = clean(materialType, materialType(fileName));
|
||||
SysOssVo oss = ossService.upload(file);
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement ps = connection.prepareStatement("""
|
||||
insert into aihr_candidate_material
|
||||
(tenant_id, candidate_id, candidate_name, material_type, file_name, file_size, content_type, oss_id, status, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
""", Statement.RETURN_GENERATED_KEYS);
|
||||
ps.setString(1, TENANT_ID);
|
||||
ps.setString(2, clean(candidateId, "mobile-candidate"));
|
||||
ps.setString(3, clean(candidateName, "候选人"));
|
||||
ps.setString(4, type);
|
||||
ps.setString(5, fileName);
|
||||
ps.setLong(6, file.getSize());
|
||||
ps.setString(7, clean(file.getContentType(), "application/octet-stream"));
|
||||
ps.setLong(8, oss.getOssId());
|
||||
ps.setString(9, "待审核");
|
||||
return ps;
|
||||
}, keyHolder);
|
||||
Number id = keyHolder.getKey();
|
||||
return new MaterialResponse(id == null ? null : id.longValue(), oss.getOssId(), fileName, type, file.getSize(), "待审核", LocalDateTime.now().format(TIME_FORMAT));
|
||||
}
|
||||
|
||||
public List<MaterialResponse> list(String candidateId) {
|
||||
ensureTable();
|
||||
return jdbcTemplate.query("""
|
||||
select id, oss_id, file_name, material_type, file_size, status, create_time
|
||||
from aihr_candidate_material
|
||||
where tenant_id = ? and candidate_id = ?
|
||||
order by id desc
|
||||
limit 20
|
||||
""", (rs, rowNum) -> new MaterialResponse(
|
||||
rs.getLong("id"),
|
||||
rs.getLong("oss_id"),
|
||||
rs.getString("file_name"),
|
||||
rs.getString("material_type"),
|
||||
rs.getLong("file_size"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("create_time").toLocalDateTime().format(TIME_FORMAT)
|
||||
), TENANT_ID, clean(candidateId, "mobile-candidate"));
|
||||
}
|
||||
|
||||
public List<MaterialReviewResponse> reviewList(String status) {
|
||||
ensureTable();
|
||||
String normalizedStatus = clean(status, "");
|
||||
return jdbcTemplate.query("""
|
||||
select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time
|
||||
from aihr_candidate_material
|
||||
where tenant_id = ? and (? = '' or status = ?)
|
||||
order by id desc
|
||||
limit 50
|
||||
""", (rs, rowNum) -> reviewRow(
|
||||
rs.getLong("id"),
|
||||
rs.getString("candidate_id"),
|
||||
rs.getString("candidate_name"),
|
||||
rs.getLong("oss_id"),
|
||||
rs.getString("file_name"),
|
||||
rs.getString("material_type"),
|
||||
rs.getLong("file_size"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("create_time").toLocalDateTime()
|
||||
), TENANT_ID, normalizedStatus, normalizedStatus);
|
||||
}
|
||||
|
||||
public MaterialReviewResponse review(Long id, ReviewRequest request) {
|
||||
ensureTable();
|
||||
String status = normalizeReviewStatus(request == null ? null : request.status());
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_candidate_material
|
||||
set status = ?, update_time = now()
|
||||
where tenant_id = ? and id = ?
|
||||
""", status, TENANT_ID, id);
|
||||
if (updated == 0) {
|
||||
throw new IllegalArgumentException("候选资料不存在");
|
||||
}
|
||||
return findReview(id);
|
||||
}
|
||||
|
||||
private void ensureTable() {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_candidate_material` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`candidate_id` varchar(80) NOT NULL COMMENT '候选人外部ID',
|
||||
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||
`material_type` varchar(50) DEFAULT NULL COMMENT '资料类型',
|
||||
`file_name` varchar(255) NOT NULL COMMENT '文件名',
|
||||
`file_size` bigint DEFAULT 0 COMMENT '文件大小',
|
||||
`content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME',
|
||||
`oss_id` bigint NOT NULL COMMENT '对象存储ID',
|
||||
`status` varchar(20) DEFAULT '待审核' COMMENT '审核状态',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_candidate_material_candidate` (`tenant_id`, `candidate_id`, `id`),
|
||||
KEY `idx_aihr_candidate_material_review` (`tenant_id`, `status`, `id`),
|
||||
KEY `idx_aihr_candidate_material_oss` (`oss_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人补充资料';
|
||||
""");
|
||||
tableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private MaterialReviewResponse findReview(Long id) {
|
||||
return jdbcTemplate.queryForObject("""
|
||||
select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time
|
||||
from aihr_candidate_material
|
||||
where tenant_id = ? and id = ?
|
||||
""", (rs, rowNum) -> reviewRow(
|
||||
rs.getLong("id"),
|
||||
rs.getString("candidate_id"),
|
||||
rs.getString("candidate_name"),
|
||||
rs.getLong("oss_id"),
|
||||
rs.getString("file_name"),
|
||||
rs.getString("material_type"),
|
||||
rs.getLong("file_size"),
|
||||
rs.getString("status"),
|
||||
rs.getTimestamp("create_time").toLocalDateTime()
|
||||
), TENANT_ID, id);
|
||||
}
|
||||
|
||||
private static MaterialReviewResponse reviewRow(Long id, String candidateId, String candidateName, Long ossId,
|
||||
String fileName, String materialType, Long fileSize, String status,
|
||||
LocalDateTime createTime) {
|
||||
return new MaterialReviewResponse(id, candidateId, candidateName, ossId, fileName, materialType, fileSize, status, createTime.format(TIME_FORMAT));
|
||||
}
|
||||
|
||||
private static String materialType(String fileName) {
|
||||
String lower = fileName.toLowerCase();
|
||||
if (lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx")) {
|
||||
return "简历/证书";
|
||||
}
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp")) {
|
||||
return "图片资料";
|
||||
}
|
||||
return "补充资料";
|
||||
}
|
||||
|
||||
private static String clean(String value, String fallback) {
|
||||
String text = value == null ? "" : value.trim();
|
||||
return text.isEmpty() ? fallback : text;
|
||||
}
|
||||
|
||||
private static String normalizeReviewStatus(String status) {
|
||||
String text = clean(status, "待审核");
|
||||
if ("已通过".equals(text) || "已驳回".equals(text) || "待审核".equals(text)) {
|
||||
return text;
|
||||
}
|
||||
throw new IllegalArgumentException("审核状态只能是待审核、已通过或已驳回");
|
||||
}
|
||||
|
||||
public record MaterialResponse(Long id, Long ossId, String fileName, String materialType, Long fileSize, String status, String time) {
|
||||
}
|
||||
|
||||
public record MaterialReviewResponse(Long id, String candidateId, String candidateName, Long ossId, String fileName,
|
||||
String materialType, Long fileSize, String status, String time) {
|
||||
}
|
||||
|
||||
public record ReviewRequest(String status) {
|
||||
}
|
||||
}
|
||||
+65
@@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
|
||||
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -34,7 +35,9 @@ public class AihrCaseService {
|
||||
private final AihrSpeechService speechService;
|
||||
private final AihrModelSeedService modelService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public UploadResponse upload(MultipartFile file, String projectExtOrgId) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
@@ -48,6 +51,7 @@ public class AihrCaseService {
|
||||
String caseId = "case-" + UUID.randomUUID();
|
||||
CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null);
|
||||
cases.put(caseId, state);
|
||||
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
|
||||
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr");
|
||||
} catch (IllegalStateException e) {
|
||||
throw e;
|
||||
@@ -62,6 +66,7 @@ public class AihrCaseService {
|
||||
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
|
||||
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary());
|
||||
cases.put(state.id(), next);
|
||||
saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary());
|
||||
return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source());
|
||||
}
|
||||
|
||||
@@ -77,9 +82,69 @@ public class AihrCaseService {
|
||||
"培训组",
|
||||
summary.stream().map(item -> item.label() + ":" + item.text()).findFirst().orElse(state.transcript())
|
||||
);
|
||||
saveCase(state, "已入库", record.title(), record.summary());
|
||||
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。");
|
||||
}
|
||||
|
||||
private void saveCase(CaseState state, String status, String title, String summary) {
|
||||
ensureCaseTable();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_case_record
|
||||
(tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, owner, create_time, update_time)
|
||||
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
file_name = VALUES(file_name),
|
||||
project_ext_org_id = VALUES(project_ext_org_id),
|
||||
title = VALUES(title),
|
||||
type = VALUES(type),
|
||||
status = VALUES(status),
|
||||
transcript = VALUES(transcript),
|
||||
summary = VALUES(summary),
|
||||
update_time = now()
|
||||
""",
|
||||
state.id(),
|
||||
state.fileName(),
|
||||
firstNonBlank(state.projectExtOrgId(), ""),
|
||||
truncate(title, 120),
|
||||
primaryTag(state.tags()),
|
||||
status,
|
||||
truncate(state.transcript(), 4000),
|
||||
truncate(summary, 1000)
|
||||
);
|
||||
}
|
||||
|
||||
private void ensureCaseTable() {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_case_record` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`case_id` varchar(80) NOT NULL COMMENT '案例ID',
|
||||
`file_name` varchar(255) DEFAULT NULL COMMENT '来源文件',
|
||||
`project_ext_org_id` varchar(100) DEFAULT NULL COMMENT '项目外部组织ID',
|
||||
`title` varchar(120) DEFAULT NULL COMMENT '标题',
|
||||
`type` varchar(80) DEFAULT NULL COMMENT '案例类型',
|
||||
`status` varchar(30) DEFAULT '已转写' COMMENT '状态',
|
||||
`transcript` text DEFAULT NULL COMMENT 'ASR转写',
|
||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||
`owner` varchar(80) DEFAULT '培训组' COMMENT '负责人',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`),
|
||||
KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录';
|
||||
""");
|
||||
tableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<CaseSummary> organizeWithModel(CaseState state) {
|
||||
String system = """
|
||||
你是物业培训案例编辑。请把转写内容整理成可复用案例。
|
||||
|
||||
+118
-3
@@ -1,5 +1,6 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -13,8 +14,11 @@ import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@@ -34,8 +38,10 @@ public class AihrInterviewService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AihrModelSeedService modelService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, CandidateProfile> candidates = buildCandidates();
|
||||
private volatile boolean tableReady;
|
||||
|
||||
public StartResponse start(StartRequest request) {
|
||||
CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
|
||||
@@ -60,8 +66,9 @@ public class AihrInterviewService {
|
||||
}
|
||||
|
||||
public FinishResponse finish(FinishRequest request) {
|
||||
String sessionKey = request == null || isBlank(request.sessionId()) ? "iv-" + UUID.randomUUID() : clean(request.sessionId());
|
||||
InterviewSession session = sessions.computeIfAbsent(
|
||||
request == null ? "" : clean(request.sessionId()),
|
||||
sessionKey,
|
||||
key -> {
|
||||
CandidateProfile candidate = resolveCandidate(null, key);
|
||||
return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
|
||||
@@ -76,6 +83,8 @@ public class AihrInterviewService {
|
||||
}
|
||||
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
|
||||
String time = LocalDateTime.now().format(TIME_FORMAT);
|
||||
RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary());
|
||||
saveResult(sessionKey, session, score);
|
||||
return new FinishResponse(
|
||||
score.total(),
|
||||
score.suggestion(),
|
||||
@@ -83,11 +92,117 @@ public class AihrInterviewService {
|
||||
score.summary(),
|
||||
score.evidence(),
|
||||
score.dimensions(),
|
||||
List.of(new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary())),
|
||||
List.of(record),
|
||||
score.source()
|
||||
);
|
||||
}
|
||||
|
||||
public List<RecordResponse> records(int limit) {
|
||||
ensureResultTable();
|
||||
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
|
||||
return jdbcTemplate.query("""
|
||||
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary
|
||||
FROM aihr_interview_result
|
||||
WHERE tenant_id = '000000'
|
||||
ORDER BY finished_time DESC, id DESC
|
||||
LIMIT %d
|
||||
""".formatted(safeLimit), this::mapRecord);
|
||||
}
|
||||
|
||||
private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException {
|
||||
var finishedTime = rs.getTimestamp("finished_time");
|
||||
return new RecordResponse(
|
||||
finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMAT),
|
||||
rs.getString("candidate_name"),
|
||||
rs.getString("position_code"),
|
||||
rs.getInt("total_score"),
|
||||
rs.getString("result"),
|
||||
rs.getString("ai_summary")
|
||||
);
|
||||
}
|
||||
|
||||
private void saveResult(String sessionId, InterviewSession session, ScoreResult score) {
|
||||
ensureResultTable();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_interview_result
|
||||
(tenant_id, session_id, candidate_id, candidate_name, position_code, total_score, result,
|
||||
suggestion, ai_summary, evidence, source, answers_json, dimensions_json, finished_time, create_time, update_time)
|
||||
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
candidate_id = VALUES(candidate_id),
|
||||
candidate_name = VALUES(candidate_name),
|
||||
position_code = VALUES(position_code),
|
||||
total_score = VALUES(total_score),
|
||||
result = VALUES(result),
|
||||
suggestion = VALUES(suggestion),
|
||||
ai_summary = VALUES(ai_summary),
|
||||
evidence = VALUES(evidence),
|
||||
source = VALUES(source),
|
||||
answers_json = VALUES(answers_json),
|
||||
dimensions_json = VALUES(dimensions_json),
|
||||
finished_time = VALUES(finished_time),
|
||||
update_time = now()
|
||||
""",
|
||||
sessionId,
|
||||
session.candidate().id(),
|
||||
session.candidate().name(),
|
||||
session.candidate().position(),
|
||||
score.total(),
|
||||
score.result(),
|
||||
score.suggestion(),
|
||||
score.summary(),
|
||||
score.evidence(),
|
||||
score.source(),
|
||||
json(session.answers()),
|
||||
json(score.dimensions())
|
||||
);
|
||||
}
|
||||
|
||||
private void ensureResultTable() {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (tableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`session_id` varchar(100) NOT NULL COMMENT '面试会话ID',
|
||||
`candidate_id` varchar(80) DEFAULT NULL COMMENT '候选人ID',
|
||||
`candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名',
|
||||
`position_code` varchar(120) DEFAULT NULL COMMENT '岗位',
|
||||
`total_score` int DEFAULT NULL COMMENT '总分',
|
||||
`result` varchar(40) DEFAULT NULL COMMENT '结论',
|
||||
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议',
|
||||
`ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要',
|
||||
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
|
||||
`source` varchar(40) DEFAULT NULL COMMENT '评分来源',
|
||||
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
|
||||
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_interview_session` (`tenant_id`, `session_id`),
|
||||
KEY `idx_aihr_interview_candidate` (`tenant_id`, `candidate_id`, `finished_time`),
|
||||
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
|
||||
""");
|
||||
tableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<GeneratedQuestions> generateQuestions(CandidateProfile candidate) {
|
||||
String system = """
|
||||
你是物业 HR 面试官。请基于岗位画像生成 3 道现场面试题。
|
||||
@@ -166,7 +281,7 @@ public class AihrInterviewService {
|
||||
}
|
||||
return Optional.of(new ScoreResult(
|
||||
total,
|
||||
firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"),
|
||||
truncate(firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"), 240),
|
||||
firstNonBlank(root.path("result").asText(), total >= 85 ? "建议录用" : total >= 75 ? "建议复试" : "观察"),
|
||||
truncate(firstNonBlank(root.path("summary").asText(), "回答已完成,需结合人工复核。"), 180),
|
||||
truncate(firstNonBlank(root.path("evidence").asText(), "依据候选人回答中的 SOP 命中、情绪安抚和反馈时限评分。"), 240),
|
||||
|
||||
Reference in New Issue
Block a user