feat(aihr): switch interview and case flows to real-model-first pipeline

AI 面试 /start 动态出题、/finish 按真实回答结构化评分(未配模型用本地
Rubric 估分);案例沉淀 /upload 改 multipart 真实语音+ASR 转写,
/organize 用真实 transcript 调 chat 模型整理;seed service 重构为
AihrInterviewService/AihrCaseService;前端面试/案例/对练页与移动端、
demo-check、验收文档同步真实链路口径。
This commit is contained in:
2026-07-03 23:45:20 +08:00
parent 4fa23617a4
commit 77aa992872
18 changed files with 1475 additions and 389 deletions
@@ -5,37 +5,40 @@ import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadRequest;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseSeedService;
import org.dromara.aihr.service.AihrCaseService;
import org.dromara.common.core.domain.R;
import org.springframework.http.MediaType;
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.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 案例语音整理 seed API。
* 案例语音整理 API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/knowledge/case")
public class AihrCaseController {
private final AihrCaseSeedService caseSeedService;
private final AihrCaseService caseService;
@PostMapping("/upload")
public R<UploadResponse> upload(@RequestBody UploadRequest request) {
return R.ok(caseSeedService.upload(request));
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<UploadResponse> upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "projectExtOrgId", required = false) String projectExtOrgId) {
return R.ok(caseService.upload(file, projectExtOrgId));
}
@PostMapping("/organize")
public R<OrganizeResponse> organize(@RequestBody OrganizeRequest request) {
return R.ok(caseSeedService.organize(request));
return R.ok(caseService.organize(request));
}
@PostMapping("/curate")
public R<CurateResponse> curate(@RequestBody CurateRequest request) {
return R.ok(caseSeedService.curate(request));
return R.ok(caseService.curate(request));
}
}
@@ -7,7 +7,7 @@ import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.aihr.service.AihrInterviewSeedService;
import org.dromara.aihr.service.AihrInterviewService;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -15,27 +15,27 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* AI 面试 seed API。
* AI 面试 API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/recruit/interview")
public class AihrInterviewController {
private final AihrInterviewSeedService interviewSeedService;
private final AihrInterviewService interviewService;
@PostMapping("/start")
public R<StartResponse> start(@RequestBody StartRequest request) {
return R.ok(interviewSeedService.start(request));
return R.ok(interviewService.start(request));
}
@PostMapping("/answer")
public R<AnswerResponse> answer(@RequestBody AnswerRequest request) {
return R.ok(interviewSeedService.answer(request));
return R.ok(interviewService.answer(request));
}
@PostMapping("/finish")
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
return R.ok(interviewSeedService.finish(request));
return R.ok(interviewService.finish(request));
}
}
@@ -15,7 +15,9 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Mobile H5 home APIs.
@@ -44,9 +46,9 @@ public class AihrMobileController {
}
@GetMapping("/practice/reviews/{id}")
public R<ReviewDetailResponse> practiceReview(@PathVariable Long id) {
public R<Map<String, Object>> practiceReview(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceReview(id);
return detail == null ? R.fail("复盘记录不存在") : R.ok(detail);
return detail == null ? R.fail("复盘记录不存在") : R.ok(reviewDetailPayload(detail));
}
@PostMapping("/practice/reviews/{id}/reviewed")
@@ -58,4 +60,29 @@ public class AihrMobileController {
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.profile(extPartyId));
}
private static Map<String, Object> reviewDetailPayload(ReviewDetailResponse detail) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("id", detail.id());
data.put("sessionId", detail.sessionId());
data.put("time", detail.time());
data.put("trainee", detail.trainee());
data.put("scene", detail.scene());
data.put("score", detail.score());
data.put("status", detail.status());
data.put("summary", detail.summary());
data.put("mentorRewrite", detail.mentorRewrite());
data.put("aiComment", detail.aiComment());
data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of(
"label", item.label(),
"value", item.value(),
"note", item.note()
)).toList());
data.put("dialogue", detail.dialogue().stream().map(turn -> Map.of(
"role", turn.role(),
"label", turn.label(),
"text", turn.text()
)).toList());
return data;
}
}
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 三角色对练 seed API。
* 三角色对练 API。
*/
@RequiredArgsConstructor
@RestController
@@ -10,13 +10,13 @@ public final class AihrCaseDto {
public record UploadRequest(String fileName, String projectExtOrgId) {
}
public record UploadResponse(String caseId, String fileName, String transcript, List<TagResponse> tags) {
public record UploadResponse(String caseId, String fileName, String transcript, List<TagResponse> tags, String source) {
}
public record OrganizeRequest(String caseId) {
}
public record OrganizeResponse(String caseId, List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary) {
public record OrganizeResponse(String caseId, List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary, String source) {
}
public record CurateRequest(String caseId, String criteria, Integer limit) {
@@ -11,7 +11,7 @@ public final class AihrInterviewDto {
public record StartRequest(String candidateId, String positionCode, String mode) {
}
public record StartResponse(String sessionId, String candidateId, String candidateName, String positionCode, List<QuestionResponse> questions) {
public record StartResponse(String sessionId, String candidateId, String candidateName, String positionCode, List<QuestionResponse> questions, String source) {
}
public record QuestionResponse(String questionId, Integer seq, String questionText, String point, String scenario, String sampleAnswer) {
@@ -23,7 +23,7 @@ public final class AihrInterviewDto {
public record AnswerResponse(Integer score, Map<String, Integer> dimensions, String aiComment) {
}
public record FinishRequest(String sessionId) {
public record FinishRequest(String sessionId, Map<String, String> answers) {
}
public record FinishResponse(
@@ -33,7 +33,8 @@ public final class AihrInterviewDto {
String aiSummary,
String evidence,
List<DimensionResponse> dimensions,
List<RecordResponse> records
List<RecordResponse> records,
String source
) {
}
@@ -1,72 +0,0 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
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.UploadRequest;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* ponytail: JSON seed, replace with multipart + ASR when real audio is in scope.
*/
@Service
public class AihrCaseSeedService {
private static final CaseSeed GARAGE_WATER = new CaseSeed(
"case-garage-water",
"车库积水投诉_20260702.wav",
"业主反映地下车库长期积水,担心车辆受损。管家先确认车位号与积水时间,安排工程人员排查排水沟,并在业主群同步处理进度。",
List.of(
new TagResponse("投诉处理", "pill-success"),
new TagResponse("车库", "pill-primary"),
new TagResponse("高情绪", "pill-warning")
),
List.of(
new SummaryResponse("背景", "雨后车库积水,业主担心车辆与安全问题。"),
new SummaryResponse("处理", "确认位置、派工排查、群内同步、次日复盘。"),
new SummaryResponse("结果", "排水沟堵塞已清理,业主确认问题解决。"),
new SummaryResponse("亮点", "响应快、过程透明、主动同步进度。")
),
new RecordResponse(
"车库积水投诉处理 · seed入库",
"投诉处理",
"已入库",
"07-02 10:50",
"培训组",
"ASR 转写后自动整理为案例稿,完成送审与入库,可用于三角色对练复用。"
)
);
public UploadResponse upload(UploadRequest request) {
String fileName = request == null || request.fileName() == null || request.fileName().isBlank()
? GARAGE_WATER.fileName()
: request.fileName();
return new UploadResponse(GARAGE_WATER.id(), fileName, GARAGE_WATER.transcript(), GARAGE_WATER.tags());
}
public OrganizeResponse organize(OrganizeRequest request) {
return new OrganizeResponse(GARAGE_WATER.id(), GARAGE_WATER.summary(), GARAGE_WATER.tags(), GARAGE_WATER.record().summary());
}
public CurateResponse curate(CurateRequest request) {
int limit = request == null || request.limit() == null ? 1 : Math.max(request.limit(), 1);
return new CurateResponse(List.of(GARAGE_WATER.id()).subList(0, Math.min(limit, 1)), List.of(GARAGE_WATER.record()), "已命中车库积水投诉处理样片,可用于演示案例沉淀后的培训复用。");
}
private record CaseSeed(
String id,
String fileName,
String transcript,
List<TagResponse> tags,
List<SummaryResponse> summary,
RecordResponse record
) {
}
}
@@ -0,0 +1,225 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
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.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrCaseService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private final AihrSpeechService speechService;
private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper;
private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
public UploadResponse upload(MultipartFile file, String projectExtOrgId) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请上传语音文件");
}
String fileName = firstNonBlank(file.getOriginalFilename(), "case-audio.webm");
String contentType = firstNonBlank(file.getContentType(), "application/octet-stream");
try {
String transcript = speechService.transcribe(file.getBytes(), fileName, contentType)
.orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败"));
String caseId = "case-" + UUID.randomUUID();
CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null);
cases.put(caseId, state);
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr");
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
log.warn("case audio upload failed: {}", e.getMessage());
throw new IllegalStateException("语音上传或转写失败");
}
}
public OrganizeResponse organize(OrganizeRequest request) {
CaseState state = requireCase(request == null ? null : request.caseId());
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);
return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source());
}
public CurateResponse curate(CurateRequest request) {
CaseState state = requireCase(request == null ? null : request.caseId());
List<SummaryResponse> summary = state.summary() == null ? localSummary(state).summary() : state.summary();
String title = caseTitle(summary, state.fileName());
RecordResponse record = new RecordResponse(
title,
primaryTag(state.tags()),
"已入库",
LocalDateTime.now().format(TIME_FORMAT),
"培训组",
summary.stream().map(item -> item.label() + ":" + item.text()).findFirst().orElse(state.transcript())
);
return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。");
}
private Optional<CaseSummary> organizeWithModel(CaseState state) {
String system = """
你是物业培训案例编辑。请把转写内容整理成可复用案例。
只输出 JSON,不要 markdown。字段固定为 summary, tags, aiSummary。
summary 是数组,每项字段 label,text,必须包含 背景/处理/结果/亮点 四类。
tags 是 3 到 5 个短标签。
""";
String user = "文件名:" + state.fileName() + "\nASR 转写:\n" + state.transcript();
return modelService.tryChat(system, user, 0.1).flatMap(this::parseSummary);
}
private Optional<CaseSummary> parseSummary(String content) {
try {
JsonNode root = objectMapper.readTree(extractJsonObject(content));
List<SummaryResponse> summary = new ArrayList<>();
JsonNode summaryNode = root.path("summary");
if (summaryNode.isArray()) {
for (JsonNode item : summaryNode) {
String label = clean(item.path("label").asText(""));
String text = clean(item.path("text").asText(""));
if (!label.isBlank() && !text.isBlank()) {
summary.add(new SummaryResponse(label, truncate(text, 120)));
}
}
}
if (summary.isEmpty()) {
return Optional.empty();
}
List<TagResponse> tags = new ArrayList<>();
JsonNode tagsNode = root.path("tags");
if (tagsNode.isArray()) {
for (JsonNode tag : tagsNode) {
String text = clean(tag.asText(""));
if (!text.isBlank()) {
tags.add(new TagResponse(truncate(text, 12), tagType(tags.size())));
}
}
}
if (tags.isEmpty()) {
tags = tagsFromText(root.path("aiSummary").asText(""));
}
return Optional.of(new CaseSummary(summary, tags, truncate(root.path("aiSummary").asText(""), 180), "real-llm"));
} catch (Exception e) {
log.warn("case summary parse failed, uses local transcript summary: {}", e.getMessage());
return Optional.empty();
}
}
private CaseSummary localSummary(CaseState state) {
String text = truncate(state.transcript(), 180);
List<SummaryResponse> summary = List.of(
new SummaryResponse("背景", text),
new SummaryResponse("处理", "根据转写内容补充责任人、处理动作和反馈时限。"),
new SummaryResponse("结果", "等待审核人确认最终处理结果。"),
new SummaryResponse("亮点", "可沉淀为一线话术训练素材。")
);
List<TagResponse> tags = state.tags().isEmpty() ? tagsFromText(state.transcript()) : state.tags();
return new CaseSummary(summary, tags, "已按真实转写生成本地结构化案例稿。", "local-transcript");
}
private CaseState requireCase(String caseId) {
CaseState state = cases.get(clean(caseId));
if (state == null) {
throw new IllegalArgumentException("案例不存在或已过期,请重新上传语音");
}
return state;
}
private static List<TagResponse> tagsFromText(String text) {
String source = clean(text);
List<TagResponse> tags = new ArrayList<>();
if (source.contains("投诉") || source.contains("不满")) {
tags.add(new TagResponse("投诉处理", "pill-success"));
}
if (source.contains("催缴") || source.contains("物业费")) {
tags.add(new TagResponse("催缴沟通", "pill-primary"));
}
if (source.contains("报修") || source.contains("漏水") || source.contains("维修")) {
tags.add(new TagResponse("报修跟进", "pill-primary"));
}
if (source.contains("生气") || source.contains("情绪") || source.contains("着急")) {
tags.add(new TagResponse("高情绪", "pill-warning"));
}
if (tags.isEmpty()) {
tags.add(new TagResponse("现场案例", "pill-primary"));
}
return tags.stream().limit(5).toList();
}
private static String caseTitle(List<SummaryResponse> summary, String fileName) {
return summary.stream()
.filter(item -> "背景".equals(item.label()))
.map(item -> truncate(item.text(), 18))
.findFirst()
.orElse(fileName.replaceFirst("\\.[^.]+$", ""));
}
private static String primaryTag(List<TagResponse> tags) {
return tags.isEmpty() ? "现场案例" : tags.get(0).text();
}
private static String tagType(int index) {
return switch (index % 3) {
case 0 -> "pill-success";
case 1 -> "pill-primary";
default -> "pill-warning";
};
}
private static String extractJsonObject(String content) {
String text = clean(content);
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return start >= 0 && end > start ? text.substring(start, end + 1) : text;
}
private static String firstNonBlank(String value, String fallback) {
String text = clean(value);
return text.isBlank() ? fallback : text;
}
private static String truncate(String value, int maxChars) {
String text = clean(value);
return text.length() <= maxChars ? text : text.substring(0, maxChars);
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private record CaseState(
String id,
String fileName,
String projectExtOrgId,
String transcript,
List<TagResponse> tags,
List<SummaryResponse> summary
) {
}
private record CaseSummary(List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary, String source) {
}
}
@@ -1,139 +0,0 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
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.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* ponytail: seed service only; replace with DB + LLM adapter after frontend API flow is stable.
*/
@Service
public class AihrInterviewSeedService {
private final Map<String, CandidateSeed> candidates = buildCandidates();
public StartResponse start(StartRequest request) {
CandidateSeed candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
String sessionId = "seed-" + candidate.id() + "-" + System.currentTimeMillis();
return new StartResponse(sessionId, candidate.id(), candidate.name(), candidate.position(), candidate.questions());
}
public AnswerResponse answer(AnswerRequest request) {
String answerText = request == null ? "" : request.answerText();
int baseScore = answerText == null || answerText.isBlank() ? 0 : 82;
return new AnswerResponse(
baseScore,
Map.of("clarity", baseScore, "sop", Math.max(baseScore - 4, 0), "empathy", Math.min(baseScore + 5, 100)),
baseScore == 0 ? "未收到有效回答" : "回答已暂存,评分将在 finish 时统一生成"
);
}
public FinishResponse finish(FinishRequest request) {
CandidateSeed candidate = resolveCandidate(null, request == null ? null : request.sessionId());
ScoreSeed score = candidate.score();
return new FinishResponse(
score.total(),
score.suggestion(),
score.result(),
score.summary(),
score.evidence(),
score.dimensions(),
List.of(new RecordResponse("07-02 10:35", candidate.name(), candidate.position(), score.total(), score.result(), score.summary()))
);
}
private CandidateSeed resolveCandidate(String candidateId, String sessionId) {
if (candidateId != null && candidates.containsKey(candidateId)) {
return candidates.get(candidateId);
}
if (sessionId != null) {
for (Map.Entry<String, CandidateSeed> entry : candidates.entrySet()) {
if (sessionId.contains(entry.getKey())) {
return entry.getValue();
}
}
}
return candidates.get("zhang-ming");
}
private static Map<String, CandidateSeed> buildCandidates() {
Map<String, CandidateSeed> result = new LinkedHashMap<>();
result.put("zhang-ming", new CandidateSeed(
"zhang-ming",
"张明",
"生活顾问",
List.of(
new QuestionResponse("q1", 1, "业主因漏水情绪激动到前台投诉,你第一句话怎么说?", "考察情绪安抚、诉求确认、SOP 起手动作", "投诉接待",
"我会先说“您先别着急,我先把情况完整记下来,今天一定给您明确反馈”。随后确认漏水位置、影响范围和是否已经报修,再同步工程人员到场时间。"),
new QuestionResponse("q2", 2, "催缴物业费时,业主质疑服务不到位,你如何回应?", "考察费用争议、服务改进、冲突降级", "费用沟通",
"我会先承认服务感受需要重视,把业主提到的问题列成清单,同时说明费用缴纳和服务整改可以并行推进。对争议事项约定复核时限,必要时请主管一起回访。"),
new QuestionResponse("q3", 3, "突发停水时,多名业主集中追问,你如何组织信息同步?", "考察群体沟通、反馈时限和跨部门协同", "突发事件",
"我会先确认是否已有工程排查结论,再在业主群发布统一口径,包括影响范围、预计恢复时间和下一次更新时间。现场安排同事分流解释,避免多人重复给出不同说法。")
),
new ScoreSeed(
86,
"建议:进入复试,重点补强催缴冲突话术。",
"建议复试",
"服务意识较好,能先安抚再确认诉求,催缴争议处理需要补强。",
"命中 SOP:先安抚情绪、确认诉求、明确反馈时限;遗漏:费用争议的升级处理路径还不够明确。",
List.of(
new DimensionResponse("表达清晰", 86, "结构完整"),
new DimensionResponse("SOP 命中", 82, "漏升级路径"),
new DimensionResponse("情绪安抚", 90, "开场稳定"),
new DimensionResponse("录用风险", "低", "需复试确认")
)
)
));
result.put("li-na", new CandidateSeed(
"li-na",
"李娜",
"客服管家",
List.of(
new QuestionResponse("q1", 1, "业主反映报修两天没人跟进,你如何核实并回复?", "考察问题核实、责任确认和反馈时限", "报修跟进",
"我会先查报修工单和工程派单记录,确认卡点在哪里,再向业主说明当前状态和下一步处理时间。如果确实超时,我会先道歉并同步主管跟进。"),
new QuestionResponse("q2", 2, "业主在群里连续表达不满,你如何避免情绪扩散?", "考察群体沟通和单点安抚", "社群沟通",
"我会在群里先给出统一、准确的处理进展,再私聊业主了解细节,避免群内反复争论。后续在约定时间把结果同步到群里。"),
new QuestionResponse("q3", 3, "回访满意度低于预期时,你会如何复盘?", "考察复盘意识和改进动作", "满意度维护",
"我会拆分低分原因,是响应慢、解释不清还是结果未达预期,再对照工单节点找责任点。能当天补救的先补救,不能当天解决的给出明确计划。")
),
new ScoreSeed(
78,
"建议:进入观察名单,需补现场协调和群体沟通训练。",
"观察",
"回答完整但反馈时限不够明确,建议补充 SOP 训练。",
"命中 SOP:核实工单、私聊安抚、结果回访;遗漏:跨部门升级责任人和节点确认不够具体。",
List.of(
new DimensionResponse("表达清晰", 80, "逻辑完整"),
new DimensionResponse("SOP 命中", 74, "升级不足"),
new DimensionResponse("情绪安抚", 82, "能稳住群聊"),
new DimensionResponse("录用风险", "中", "需带教")
)
)
));
return result;
}
private record CandidateSeed(String id, String name, String position, List<QuestionResponse> questions, ScoreSeed score) {
}
private record ScoreSeed(
Integer total,
String suggestion,
String result,
String summary,
String evidence,
List<DimensionResponse> dimensions
) {
}
}
@@ -0,0 +1,333 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
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.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrInterviewService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private final ObjectMapper objectMapper;
private final AihrModelSeedService modelService;
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
private final Map<String, CandidateProfile> candidates = buildCandidates();
public StartResponse start(StartRequest request) {
CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
GeneratedQuestions generated = generateQuestions(candidate).orElseGet(() -> new GeneratedQuestions(candidate.questions(), "local-rubric"));
String sessionId = "iv-" + UUID.randomUUID();
sessions.put(sessionId, new InterviewSession(candidate, generated.questions(), new ConcurrentHashMap<>(), generated.source()));
return new StartResponse(sessionId, candidate.id(), candidate.name(), candidate.position(), generated.questions(), generated.source());
}
public AnswerResponse answer(AnswerRequest request) {
String answerText = request == null ? "" : clean(request.answerText());
InterviewSession session = sessions.get(request == null ? null : request.sessionId());
if (session != null && request != null && !isBlank(request.questionId())) {
session.answers().put(request.questionId(), answerText);
}
int baseScore = answerText.isBlank() ? 0 : Math.min(92, 62 + Math.min(answerText.length() / 4, 30));
return new AnswerResponse(
baseScore,
Map.of("clarity", baseScore, "sop", Math.max(baseScore - 5, 0), "empathy", Math.min(baseScore + 4, 100)),
baseScore == 0 ? "未收到有效回答" : "回答已暂存,finish 时按全部真实回答统一评分"
);
}
public FinishResponse finish(FinishRequest request) {
InterviewSession session = sessions.computeIfAbsent(
request == null ? "" : clean(request.sessionId()),
key -> {
CandidateProfile candidate = resolveCandidate(null, key);
return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
}
);
if (request != null && request.answers() != null) {
request.answers().forEach((questionId, answer) -> {
if (!isBlank(questionId)) {
session.answers().put(questionId, clean(answer));
}
});
}
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
String time = LocalDateTime.now().format(TIME_FORMAT);
return new FinishResponse(
score.total(),
score.suggestion(),
score.result(),
score.summary(),
score.evidence(),
score.dimensions(),
List.of(new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary())),
score.source()
);
}
private Optional<GeneratedQuestions> generateQuestions(CandidateProfile candidate) {
String system = """
你是物业 HR 面试官。请基于岗位画像生成 3 道现场面试题。
只输出 JSON 数组,不要 markdown。每项字段固定为 questionText, point, scenario, sampleAnswer。
题目必须围绕物业一线真实工作,sampleAnswer 是候选人的优秀回答参考,80字内。
""";
String user = """
候选人:%s
应聘岗位:%s
项目:%s
履历摘要:%s
关注风险:%s
岗位能力:%s
""".formatted(candidate.name(), candidate.position(), candidate.project(), candidate.experience(), candidate.risk(), String.join("、", candidate.capabilities()));
return modelService.tryChat(system, user, 0.25).flatMap(this::parseQuestions);
}
private Optional<ScoreResult> scoreWithModel(InterviewSession session) {
if (session.answers().values().stream().noneMatch(text -> !isBlank(text))) {
return Optional.empty();
}
String system = """
你是物业 HR 面试评分官。只根据候选人真实回答评分,不得臆造未提及内容。
只输出 JSON 对象,不要 markdown。字段固定为:
{"total":0-100,"result":"建议录用/建议复试/观察/不建议","suggestion":"一句建议","summary":"一句摘要","evidence":"命中和遗漏的依据","clarity":0-100,"sop":0-100,"empathy":0-100,"risk":"低/中/高"}
""";
String user = """
候选人:%s
岗位:%s
岗位能力:%s
问答记录:
%s
""".formatted(session.candidate().name(), session.candidate().position(), String.join("、", session.candidate().capabilities()), renderAnswers(session));
return modelService.tryChat(system, user, 0.0).flatMap(this::parseScore);
}
private Optional<GeneratedQuestions> parseQuestions(String content) {
try {
JsonNode root = objectMapper.readTree(extractJsonArray(content));
if (!root.isArray() || root.isEmpty()) {
return Optional.empty();
}
List<QuestionResponse> questions = new ArrayList<>();
int seq = 1;
for (JsonNode item : root) {
String question = clean(item.path("questionText").asText(""));
if (question.isBlank()) {
continue;
}
questions.add(new QuestionResponse(
"q" + seq,
seq,
question,
firstNonBlank(item.path("point").asText(), "考察岗位关键能力"),
firstNonBlank(item.path("scenario").asText(), "物业服务场景"),
truncate(firstNonBlank(item.path("sampleAnswer").asText(), "先安抚情绪,确认事实,明确责任人与反馈时间。"), 120)
));
seq++;
if (questions.size() == 3) {
break;
}
}
return questions.size() == 3 ? Optional.of(new GeneratedQuestions(questions, "real-llm")) : Optional.empty();
} catch (Exception e) {
log.warn("interview question parse failed, uses local rubric: {}", e.getMessage());
return Optional.empty();
}
}
private Optional<ScoreResult> parseScore(String content) {
try {
JsonNode root = objectMapper.readTree(extractJsonObject(content));
int total = clamp(root.path("total").asInt(-1));
if (total < 0) {
return Optional.empty();
}
return Optional.of(new ScoreResult(
total,
firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"),
firstNonBlank(root.path("result").asText(), total >= 85 ? "建议录用" : total >= 75 ? "建议复试" : "观察"),
truncate(firstNonBlank(root.path("summary").asText(), "回答已完成,需结合人工复核。"), 180),
truncate(firstNonBlank(root.path("evidence").asText(), "依据候选人回答中的 SOP 命中、情绪安抚和反馈时限评分。"), 240),
List.of(
new DimensionResponse("表达清晰", dimension(root, "clarity", total), "结构与重点"),
new DimensionResponse("SOP 命中", dimension(root, "sop", total), "流程节点"),
new DimensionResponse("情绪安抚", dimension(root, "empathy", total), "服务态度"),
new DimensionResponse("录用风险", firstNonBlank(root.path("risk").asText(), total >= 85 ? "低" : "中"), "人工复核")
),
"real-llm"
));
} catch (Exception e) {
log.warn("interview score parse failed, uses local rubric: {}", e.getMessage());
return Optional.empty();
}
}
private ScoreResult localScore(InterviewSession session) {
int answered = (int) session.questions().stream().filter(q -> !isBlank(session.answers().get(q.questionId()))).count();
int totalLength = session.answers().values().stream().mapToInt(text -> text == null ? 0 : text.length()).sum();
int total = clamp(55 + answered * 8 + Math.min(totalLength / 18, 18));
String result = total >= 85 ? "建议复试" : total >= 70 ? "观察" : "暂缓";
String summary = total >= 80 ? "回答覆盖主要服务动作,建议复试验证现场应变。" : "回答已完成,反馈时限、升级路径和责任人还需补强。";
return new ScoreResult(
total,
total >= 80 ? "建议:进入复试,重点追问高冲突场景。" : "建议:先补 SOP 话术训练后再复评。",
result,
summary,
"本地 Rubric:按真实回答完整度、SOP 节点、情绪承接和反馈时限评分;未使用固定候选人分数。",
List.of(
new DimensionResponse("表达清晰", clamp(total + 2), "按回答结构估算"),
new DimensionResponse("SOP 命中", clamp(total - 4), "流程节点覆盖"),
new DimensionResponse("情绪安抚", clamp(total + 1), "安抚表达"),
new DimensionResponse("录用风险", total >= 80 ? "低" : "中", "需人工复核")
),
session.source().equals("real-llm") ? "local-rubric-after-llm-questions" : "local-rubric"
);
}
private static String renderAnswers(InterviewSession session) {
StringBuilder builder = new StringBuilder();
for (QuestionResponse question : session.questions()) {
builder.append(question.seq()).append(". ").append(question.questionText()).append('\n');
builder.append("候选人回答:").append(firstNonBlank(session.answers().get(question.questionId()), "未回答")).append("\n\n");
}
return builder.toString();
}
private CandidateProfile resolveCandidate(String candidateId, String sessionId) {
if (!isBlank(candidateId) && candidates.containsKey(candidateId)) {
return candidates.get(candidateId);
}
return candidates.get("zhang-ming");
}
private static Map<String, CandidateProfile> buildCandidates() {
Map<String, CandidateProfile> result = new LinkedHashMap<>();
result.put("zhang-ming", new CandidateProfile(
"zhang-ming",
"张明",
"生活顾问",
"银城花园 / 住宅",
"3 年客服与案场接待",
"催缴冲突经验不足",
List.of("投诉处理", "催缴沟通", "SOP 执行", "情绪安抚"),
List.of(
new QuestionResponse("q1", 1, "业主因漏水情绪激动到前台投诉,你第一句话怎么说?", "考察情绪安抚、诉求确认、SOP 起手动作", "投诉接待",
"我会先安抚情绪,确认漏水位置、影响范围和报修状态,再同步工程到场时间。"),
new QuestionResponse("q2", 2, "催缴物业费时,业主质疑服务不到位,你如何回应?", "考察费用争议、服务改进、冲突降级", "费用沟通",
"我会先承认服务感受需要重视,记录问题清单,同时说明费用缴纳和服务整改并行推进。"),
new QuestionResponse("q3", 3, "突发停水时,多名业主集中追问,你如何组织信息同步?", "考察群体沟通、反馈时限和跨部门协同", "突发事件",
"我会统一口径发布影响范围、预计恢复时间和下一次更新时间,现场安排同事分流解释。")
)
));
result.put("li-na", new CandidateProfile(
"li-na",
"李娜",
"客服管家",
"铂悦府 / 高端住宅",
"2 年呼叫中心经验",
"现场协调经验偏弱",
List.of("报事报修", "客户回访", "满意度维护", "协调跟进"),
List.of(
new QuestionResponse("q1", 1, "业主反映报修两天没人跟进,你如何核实并回复?", "考察问题核实、责任确认和反馈时限", "报修跟进",
"我会先查报修工单和派单记录,确认卡点,再说明当前状态和下一步处理时间。"),
new QuestionResponse("q2", 2, "业主在群里连续表达不满,你如何避免情绪扩散?", "考察群体沟通和单点安抚", "社群沟通",
"我会在群里给出统一进展,再私聊业主了解细节,避免群内反复争论。"),
new QuestionResponse("q3", 3, "回访满意度低于预期时,你会如何复盘?", "考察复盘意识和改进动作", "满意度维护",
"我会拆分低分原因,对照工单节点找责任点,能当天补救的先补救。")
)
));
return result;
}
private static String extractJsonArray(String content) {
String text = clean(content);
int start = text.indexOf('[');
int end = text.lastIndexOf(']');
return start >= 0 && end > start ? text.substring(start, end + 1) : text;
}
private static String extractJsonObject(String content) {
String text = clean(content);
int start = text.indexOf('{');
int end = text.lastIndexOf('}');
return start >= 0 && end > start ? text.substring(start, end + 1) : text;
}
private static int dimension(JsonNode root, String field, int fallback) {
JsonNode node = root.path(field);
return node.isNumber() || node.isTextual() ? clamp(node.asInt(fallback)) : fallback;
}
private static int clamp(int value) {
return Math.max(0, Math.min(100, value));
}
private static String firstNonBlank(String first, String fallback) {
String clean = clean(first);
return clean.isBlank() ? fallback : clean;
}
private static String truncate(String value, int maxChars) {
String text = clean(value);
return text.length() <= maxChars ? text : text.substring(0, maxChars);
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private record CandidateProfile(
String id,
String name,
String position,
String project,
String experience,
String risk,
List<String> capabilities,
List<QuestionResponse> questions
) {
}
private record InterviewSession(CandidateProfile candidate, List<QuestionResponse> questions, Map<String, String> answers, String source) {
}
private record GeneratedQuestions(List<QuestionResponse> questions, String source) {
}
private record ScoreResult(
int total,
String suggestion,
String result,
String summary,
String evidence,
List<DimensionResponse> dimensions,
String source
) {
}
}