From 77aa992872664daee5f288e45eb7037294f67377 Mon Sep 17 00:00:00 2001 From: let5sne Date: Fri, 3 Jul 2026 23:45:20 +0800 Subject: [PATCH] feat(aihr): switch interview and case flows to real-model-first pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 面试 /start 动态出题、/finish 按真实回答结构化评分(未配模型用本地 Rubric 估分);案例沉淀 /upload 改 multipart 真实语音+ASR 转写, /organize 用真实 transcript 调 chat 模型整理;seed service 重构为 AihrInterviewService/AihrCaseService;前端面试/案例/对练页与移动端、 demo-check、验收文档同步真实链路口径。 --- .../aihr/controller/AihrCaseController.java | 21 +- .../controller/AihrInterviewController.java | 12 +- .../aihr/controller/AihrMobileController.java | 31 +- .../controller/AihrPracticeController.java | 2 +- .../org/dromara/aihr/domain/AihrCaseDto.java | 4 +- .../dromara/aihr/domain/AihrInterviewDto.java | 7 +- .../aihr/service/AihrCaseSeedService.java | 72 -- .../dromara/aihr/service/AihrCaseService.java | 225 ++++++ .../service/AihrInterviewSeedService.java | 139 ---- .../aihr/service/AihrInterviewService.java | 333 ++++++++ docs/DEMO_ACCEPTANCE.md | 12 +- frontend/src/api/aihr/case.ts | 4 +- frontend/src/api/aihr/interview.ts | 3 + frontend/src/views/knowledge/cases.vue | 179 ++--- frontend/src/views/recruit/interview.vue | 60 +- frontend/src/views/train/practice.vue | 26 +- mobile/src/App.vue | 730 +++++++++++++++++- scripts/demo-check.sh | 4 +- 18 files changed, 1475 insertions(+), 389 deletions(-) delete mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseSeedService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java delete mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewSeedService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java index 48203d72..3e72bac6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java @@ -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 upload(@RequestBody UploadRequest request) { - return R.ok(caseSeedService.upload(request)); + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "projectExtOrgId", required = false) String projectExtOrgId) { + return R.ok(caseService.upload(file, projectExtOrgId)); } @PostMapping("/organize") public R organize(@RequestBody OrganizeRequest request) { - return R.ok(caseSeedService.organize(request)); + return R.ok(caseService.organize(request)); } @PostMapping("/curate") public R curate(@RequestBody CurateRequest request) { - return R.ok(caseSeedService.curate(request)); + return R.ok(caseService.curate(request)); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java index bc3daab8..80bed811 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java @@ -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 start(@RequestBody StartRequest request) { - return R.ok(interviewSeedService.start(request)); + return R.ok(interviewService.start(request)); } @PostMapping("/answer") public R answer(@RequestBody AnswerRequest request) { - return R.ok(interviewSeedService.answer(request)); + return R.ok(interviewService.answer(request)); } @PostMapping("/finish") public R finish(@RequestBody FinishRequest request) { - return R.ok(interviewSeedService.finish(request)); + return R.ok(interviewService.finish(request)); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index 585adb86..52618a22 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -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 practiceReview(@PathVariable Long id) { + public R> 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 profile(@RequestParam(required = false) String extPartyId) { return R.ok(mobileSeedService.profile(extPartyId)); } + + private static Map reviewDetailPayload(ReviewDetailResponse detail) { + Map 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; + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index 9a932cd9..429dabbd 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -15,7 +15,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** - * 三角色对练 seed API。 + * 三角色对练 API。 */ @RequiredArgsConstructor @RestController diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java index be5ef3cc..b2796f0b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrCaseDto.java @@ -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 tags) { + public record UploadResponse(String caseId, String fileName, String transcript, List tags, String source) { } public record OrganizeRequest(String caseId) { } - public record OrganizeResponse(String caseId, List summary, List tags, String aiSummary) { + public record OrganizeResponse(String caseId, List summary, List tags, String aiSummary, String source) { } public record CurateRequest(String caseId, String criteria, Integer limit) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java index 35029d6d..f3654c84 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrInterviewDto.java @@ -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 questions) { + public record StartResponse(String sessionId, String candidateId, String candidateName, String positionCode, List 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 dimensions, String aiComment) { } - public record FinishRequest(String sessionId) { + public record FinishRequest(String sessionId, Map answers) { } public record FinishResponse( @@ -33,7 +33,8 @@ public final class AihrInterviewDto { String aiSummary, String evidence, List dimensions, - List records + List records, + String source ) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseSeedService.java deleted file mode 100644 index 4d3a7fa6..00000000 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseSeedService.java +++ /dev/null @@ -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 tags, - List summary, - RecordResponse record - ) { - } -} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java new file mode 100644 index 00000000..7d3554b9 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -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 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 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 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 parseSummary(String content) { + try { + JsonNode root = objectMapper.readTree(extractJsonObject(content)); + List 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 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 summary = List.of( + new SummaryResponse("背景", text), + new SummaryResponse("处理", "根据转写内容补充责任人、处理动作和反馈时限。"), + new SummaryResponse("结果", "等待审核人确认最终处理结果。"), + new SummaryResponse("亮点", "可沉淀为一线话术训练素材。") + ); + List 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 tagsFromText(String text) { + String source = clean(text); + List 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 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 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 tags, + List summary + ) { + } + + private record CaseSummary(List summary, List tags, String aiSummary, String source) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewSeedService.java deleted file mode 100644 index a381eea4..00000000 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewSeedService.java +++ /dev/null @@ -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 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 entry : candidates.entrySet()) { - if (sessionId.contains(entry.getKey())) { - return entry.getValue(); - } - } - } - return candidates.get("zhang-ming"); - } - - private static Map buildCandidates() { - Map 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 questions, ScoreSeed score) { - } - - private record ScoreSeed( - Integer total, - String suggestion, - String result, - String summary, - String evidence, - List dimensions - ) { - } -} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java new file mode 100644 index 00000000..f2e20098 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java @@ -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 sessions = new ConcurrentHashMap<>(); + private final Map 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 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 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 parseQuestions(String content) { + try { + JsonNode root = objectMapper.readTree(extractJsonArray(content)); + if (!root.isArray() || root.isEmpty()) { + return Optional.empty(); + } + List 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 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 buildCandidates() { + Map 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 capabilities, + List questions + ) { + } + + private record InterviewSession(CandidateProfile candidate, List questions, Map answers, String source) { + } + + private record GeneratedQuestions(List questions, String source) { + } + + private record ScoreResult( + int total, + String suggestion, + String result, + String summary, + String evidence, + List dimensions, + String source + ) { + } +} diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 61b4f7a6..db17e450 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -1,6 +1,6 @@ # 一期 MVP 演示与验收清单 -本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;移动端员工端、候选人端、主管端首页已拆到独立 `mobile/` 工程并按高保真原型实现,已接手机号短信登录,数据 API 优先请求 `GET /api/aihr/mobile/home/{role}` 并保留本地 fallback;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 seed 记录仍按演示态验收。 +本文件是 MVP 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 MVP 路径可讲、可点、可截图;移动端员工端、候选人端、主管端首页已拆到独立 `mobile/` 工程并按高保真原型实现,已接手机号短信登录,数据 API 优先请求 `GET /api/aihr/mobile/home/{role}` 并保留本地 fallback;员工端训练完成后写入 `aihr_practice_session`,用于训练历史、主管待复盘列表和能力画像;SOP 检索已可查本地 MySQL seed 片段,并支持 txt/md/PDF/Word/Excel/PPT 上传解析为本地 fragment,配置 vector 模型后会写入 embedding 并尽力同步 Qdrant,管理端 AI 面试和案例沉淀按真实模型/ASR 优先验收。 ## 演示前预检 @@ -8,7 +8,7 @@ ./scripts/demo-check.sh ``` -预检通过只代表本地前端、基础接口、路由和 seed 标记在位。真实演示仍以浏览器点击为准。 +预检通过只代表本地前端、基础接口、路由和关键标记在位。真实演示仍以浏览器点击为准。 ## 现场演示脚本 @@ -16,9 +16,9 @@ |---|---|---|---| | 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” | | 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端点“开始训练”完成两轮提交,再打开主管端 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;主管端完训人数、“待复盘对练”计数和待复盘列表增加;点进待复盘记录可看评分、话术、导师改写,并可标记已复盘;候选人面试、主管团队概况分别可见;接口不可用时走本地 fallback;视觉参考 `docs/prototypes/*端-首页.png`,底部导航停留在移动端工程内 | -| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满 seed 回答 → 完成评分 | 已完成闭环、建议复试、新增面试记录 | -| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入 seed 回复 → 继续一轮 → 填入 seed 回复 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时为 seed 固定值 | -| 5 | 案例沉淀 `/knowledge/cases` | 选择样例录音 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环、`seed入库`、样片兜底文案 | +| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 | +| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 | +| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,最近案例新增真实转写生成的记录;视频仍用样片兜底 | | 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 | | 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 | @@ -33,5 +33,5 @@ ## 不演示 - 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。 -- 不承诺管理端 AI 面试、案例沉淀的 seed 记录已写入业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。 +- 不承诺管理端 AI 面试、案例沉淀已写入正式业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。 - 大模型不作为演示硬依赖:模型管理已启用 chat 模型时,三角色对练为真实 LLM 生成与评分(asr/tts 配置后语音输入/播报可用);未配置或现场调用失败时全链路自动回退 seed,演示不中断。 diff --git a/frontend/src/api/aihr/case.ts b/frontend/src/api/aihr/case.ts index 4d31fbc1..28590b29 100644 --- a/frontend/src/api/aihr/case.ts +++ b/frontend/src/api/aihr/case.ts @@ -30,6 +30,7 @@ export type CaseUploadResponse = { fileName: string; transcript: string; tags: CaseTag[]; + source: string; }; export type CaseOrganizeResponse = { @@ -37,6 +38,7 @@ export type CaseOrganizeResponse = { summary: CaseSummaryItem[]; tags: CaseTag[]; aiSummary: string; + source: string; }; export type CaseCurateResponse = { @@ -45,7 +47,7 @@ export type CaseCurateResponse = { sampleHint: string; }; -export function uploadCaseAudio(data: { fileName: string; projectExtOrgId: string }): Promise> { +export function uploadCaseAudio(data: FormData): Promise> { return request({ url: '/api/knowledge/case/upload', method: 'post', diff --git a/frontend/src/api/aihr/interview.ts b/frontend/src/api/aihr/interview.ts index 4d22f6f1..ce5d6dab 100644 --- a/frontend/src/api/aihr/interview.ts +++ b/frontend/src/api/aihr/interview.ts @@ -27,6 +27,7 @@ export type InterviewStartResponse = { candidateName: string; positionCode: string; questions: InterviewQuestionResponse[]; + source: string; }; export type InterviewAnswerRequest = { @@ -44,6 +45,7 @@ export type InterviewAnswerResponse = { export type InterviewFinishRequest = { sessionId: string; + answers: Record; }; export type InterviewFinishResponse = { @@ -65,6 +67,7 @@ export type InterviewFinishResponse = { result: string; summary: string; }>; + source: string; }; export function startInterview(data: InterviewStartRequest): Promise> { diff --git a/frontend/src/views/knowledge/cases.vue b/frontend/src/views/knowledge/cases.vue index 143b1009..082fd1b4 100644 --- a/frontend/src/views/knowledge/cases.vue +++ b/frontend/src/views/knowledge/cases.vue @@ -1,15 +1,16 @@