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:
+12
-9
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+29
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 三角色对练 seed API。
|
||||
* 三角色对练 API。
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+4
-3
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
-72
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+225
@@ -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) {
|
||||
}
|
||||
}
|
||||
-139
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+333
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user