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
) {
}
}
+6 -6
View File
@@ -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,演示不中断。
+3 -1
View File
@@ -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<ApiResult<CaseUploadResponse>> {
export function uploadCaseAudio(data: FormData): Promise<ApiResult<CaseUploadResponse>> {
return request({
url: '/api/knowledge/case/upload',
method: 'post',
+3
View File
@@ -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<string, string>;
};
export type InterviewFinishResponse = {
@@ -65,6 +67,7 @@ export type InterviewFinishResponse = {
result: string;
summary: string;
}>;
source: string;
};
export function startInterview(data: InterviewStartRequest): Promise<ApiResult<InterviewStartResponse>> {
+65 -86
View File
@@ -1,15 +1,16 @@
<template>
<div class="cases-page">
<input ref="audioInput" class="hidden-input" type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" @change="handleAudioPicked" />
<section class="page-head">
<div>
<h1>案例语音整理</h1>
<p>语音上传或选择样例录音,完成 ASR 转写、AI 结构化整理、送审入库。</p>
<p>上传真实语音,完成 ASR 转写、AI 结构化整理、送审入库。</p>
</div>
<div class="head-actions">
<span class="status-pill" :class="apiPill.type">{{ apiPill.label }}</span>
<span class="status-pill" :class="flowStatus.type">{{ flowStatus.label }}</span>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" @click="selectSample">选择样例录音</el-button>
<el-button type="danger" @click="audioInput?.click()">上传语音</el-button>
</div>
</section>
@@ -19,18 +20,18 @@
<el-icon><Microphone /></el-icon>
<h2>上传与转写</h2>
</div>
<div class="upload-box" @click="selectSample">
<div class="upload-box" @click="audioInput?.click()">
<el-icon><FolderChecked /></el-icon>
<strong>{{ sampleSelected ? displayFileName : '拖入语音 / 选择样例录音' }}</strong>
<span>{{ sampleSelected ? '已加载样例录音并完成 ASR 转写。' : 'Demo 暂用样例转写文本,ASR 接入后替换。' }}</span>
<strong>{{ audioUploaded ? displayFileName : '选择语音文件' }}</strong>
<span>{{ audioUploaded ? '真实语音已完成 ASR 转写。' : '支持 mp3/wav/m4a/webm/ogg,需先配置 ASR 模型。' }}</span>
</div>
<div v-if="sampleSelected" class="transcript-card">
<div v-if="audioUploaded" class="transcript-card">
<span>ASR 转写结果</span>
<p>{{ displayTranscript }}</p>
</div>
<div v-else class="empty-state">先选择样例录音,页面会展示转写结果和后续整理动作。</div>
<div v-else class="empty-state">先上传真实语音,页面会展示转写结果和后续整理动作。</div>
<div class="action-row">
<el-button type="danger" @click="organizeCase">AI 整理</el-button>
<el-button type="danger" :disabled="!audioUploaded" @click="organizeCase">AI 整理</el-button>
</div>
</article>
@@ -105,74 +106,59 @@ import { curateKnowledgeCase, organizeKnowledgeCase, uploadCaseAudio, type CaseR
const router = useRouter();
const seedCase = {
fileName: '车库积水投诉_20260702.wav',
transcript: '业主反映地下车库长期积水,担心车辆受损。管家先确认车位号与积水时间,安排工程人员排查排水沟,并在业主群同步处理进度。',
tags: [
{ text: '投诉处理', type: 'pill-success' },
{ text: '车库', type: 'pill-primary' },
{ text: '高情绪', type: 'pill-warning' }
],
summary: [
{ label: '背景', text: '雨后车库积水,业主担心车辆与安全问题。' },
{ label: '处理', text: '确认位置、派工排查、群内同步、次日复盘。' },
{ label: '结果', text: '排水沟堵塞已清理,业主确认问题解决。' },
{ label: '亮点', text: '响应快、过程透明、主动同步进度。' }
]
} as const;
const sampleSelected = ref(false);
const audioInput = ref<HTMLInputElement>();
const audioUploaded = ref(false);
const organized = ref(false);
const reviewed = ref(false);
const stored = ref(false);
const sampleVisible = ref(false);
const caseId = ref('');
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiMode = ref<'idle' | 'api' | 'error'>('idle');
const apiFileName = ref('');
const apiTranscript = ref('');
const apiSummary = ref<CaseSummaryItem[] | null>(null);
const apiTags = ref<CaseTag[] | null>(null);
const apiSummary = ref<CaseSummaryItem[]>([]);
const apiTags = ref<CaseTag[]>([]);
const apiRecord = ref<CaseRecord | null>(null);
const apiSampleHint = ref('');
const displayFileName = computed(() => apiFileName.value || seedCase.fileName);
const displayTranscript = computed(() => apiTranscript.value || seedCase.transcript);
const displaySummary = computed(() => apiSummary.value ?? seedCase.summary);
const displayTags = computed(() => apiTags.value ?? seedCase.tags);
const displayFileName = computed(() => apiFileName.value || '未选择文件');
const displayTranscript = computed(() => apiTranscript.value || '暂无转写结果');
const displaySummary = computed(() => apiSummary.value);
const displayTags = computed(() => apiTags.value);
const summary = computed(() => (organized.value ? displaySummary.value : []));
const apiPill = computed(() => {
if (apiMode.value === 'api') return { label: '后端 API seed', type: 'pill-primary' };
if (apiMode.value === 'fallback') return { label: 'API 失败回退', type: 'pill-warning' };
return { label: '本地 seed 演示', type: 'pill-primary' };
if (apiMode.value === 'api') return { label: '真实 ASR/API', type: 'pill-primary' };
if (apiMode.value === 'error') return { label: '接口失败', type: 'pill-warning' };
return { label: '待上传语音', type: 'pill-primary' };
});
const flowStatus = computed(() => {
if (stored.value) return { label: '已完成闭环', type: 'pill-success' };
if (reviewed.value) return { label: '已送审待入库', type: 'pill-primary' };
if (organized.value) return { label: '已生成案例稿', type: 'pill-warning' };
if (sampleSelected.value) return { label: '已完成 ASR', type: 'pill-primary' };
return { label: '样片兜底', type: 'pill-warning' };
if (audioUploaded.value) return { label: '已完成 ASR', type: 'pill-primary' };
return { label: '等待语音', type: 'pill-warning' };
});
const pipeline = computed(() => [
{
name: '语音上传',
count: sampleSelected.value ? '1 条' : '0 条',
note: sampleSelected.value ? '样例录音已选择' : '等待上传',
state: sampleSelected.value ? 'done' : 'todo'
count: audioUploaded.value ? '1 条' : '0 条',
note: audioUploaded.value ? '真实音频已上传' : '等待上传',
state: audioUploaded.value ? 'done' : 'todo'
},
{
name: 'ASR 转写',
count: sampleSelected.value ? '1 条' : '0 条',
note: sampleSelected.value ? '本地 seed 转写完成' : '等待语音',
state: sampleSelected.value ? 'done' : 'todo'
count: audioUploaded.value ? '1 条' : '0 条',
note: audioUploaded.value ? '真实 ASR 已转写' : '等待语音',
state: audioUploaded.value ? 'done' : 'todo'
},
{
name: 'AI 整理',
count: organized.value ? '1 条' : '0 条',
note: organized.value ? '结构化案例稿已生成' : '等待整理',
state: organized.value ? 'done' : sampleSelected.value ? 'active' : 'todo'
state: organized.value ? 'done' : audioUploaded.value ? 'active' : 'todo'
},
{
name: '审核入库',
@@ -215,68 +201,64 @@ const baseRecords = [
}
];
const seedRecord = {
title: '车库积水投诉处理 · seed入库',
type: '投诉处理',
status: '已入库',
time: '07-02 10:50',
owner: '培训组',
summary: 'ASR 转写后自动整理为案例稿,完成送审与入库,可用于三角色对练复用。'
};
const records = ref([...baseRecords]);
async function selectSample() {
sampleSelected.value = true;
async function handleAudioPicked(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
await uploadAudio(file);
}
async function uploadAudio(file: File) {
audioUploaded.value = false;
organized.value = false;
reviewed.value = false;
stored.value = false;
sampleVisible.value = false;
apiSummary.value = null;
apiSummary.value = [];
apiRecord.value = null;
apiSampleHint.value = '';
apiTranscript.value = '';
apiTags.value = [];
try {
const { data } = await uploadCaseAudio({
fileName: seedCase.fileName,
projectExtOrgId: 'seed-project'
});
const form = new FormData();
form.append('file', file);
form.append('projectExtOrgId', 'mobile-upload');
const { data } = await uploadCaseAudio(form);
caseId.value = data.caseId;
apiMode.value = 'api';
apiFileName.value = data.fileName;
apiTranscript.value = data.transcript;
apiTags.value = data.tags;
ElMessage.success('已通过后端 API 加载样例录音并完成 ASR 转写');
audioUploaded.value = true;
ElMessage.success('真实语音已完成 ASR 转写');
} catch {
caseId.value = 'case-garage-water';
apiMode.value = 'fallback';
apiFileName.value = '';
apiTranscript.value = '';
apiTags.value = null;
ElMessage.warning('后端案例上传 API 不可用,已回退本地 seed 转写');
caseId.value = '';
apiMode.value = 'error';
apiFileName.value = file.name;
ElMessage.error('语音转写失败,请检查 ASR 模型配置或音频格式');
}
}
async function organizeCase() {
if (!sampleSelected.value) {
await selectSample();
}
if (!audioUploaded.value || !caseId.value) return;
if (apiMode.value === 'api') {
try {
const { data } = await organizeKnowledgeCase({ caseId: caseId.value });
apiSummary.value = data.summary;
apiTags.value = data.tags;
organized.value = true;
ElMessage.success('结构化案例稿已由后端 API 返回');
ElMessage.success(data.source === 'real-llm' ? '结构化案例稿已由真实模型返回' : '结构化案例稿已按真实转写生成');
return;
} catch {
apiMode.value = 'fallback';
apiSummary.value = null;
apiTags.value = null;
ElMessage.warning('后端案例整理 API 不可用,已回退本地 seed 案例稿');
apiMode.value = 'error';
apiSummary.value = [];
apiTags.value = [];
ElMessage.error('后端案例整理 API 不可用');
}
}
organized.value = true;
ElMessage.success('AI 已生成结构化案例稿');
}
function submitReview() {
@@ -295,7 +277,6 @@ async function storeCase() {
}
reviewed.value = true;
stored.value = true;
if (apiMode.value === 'api') {
try {
const { data } = await curateKnowledgeCase({
caseId: caseId.value,
@@ -308,19 +289,13 @@ async function storeCase() {
records.value.unshift(apiRecord.value);
}
ElMessage.success('案例已由后端 API 入库,可用于训练复用');
return;
} catch {
apiMode.value = 'fallback';
apiMode.value = 'error';
apiRecord.value = null;
apiSampleHint.value = '';
ElMessage.warning('后端案例入库 API 不可用,已回退本地 seed 入库');
ElMessage.error('后端案例入库 API 不可用');
}
}
if (!records.value.some((item) => item.title === seedRecord.title)) {
records.value.unshift(seedRecord);
}
ElMessage.success('案例已入库,可用于训练复用');
}
function viewSample() {
sampleVisible.value = true;
@@ -336,6 +311,10 @@ function viewSample() {
color: #162033;
}
.hidden-input {
display: none;
}
.page-head {
display: flex;
align-items: flex-end;
+36 -24
View File
@@ -3,7 +3,7 @@
<section class="page-head">
<div>
<h1>AI 面试出题</h1>
<p>岗位画像驱动出题、候选人作答、Rubric 评分、面试记录沉淀的本地 seed 演示闭环。</p>
<p>岗位画像驱动出题、候选人作答、Rubric 评分、面试记录沉淀。</p>
</div>
<div class="head-actions">
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
@@ -79,7 +79,7 @@
<div v-if="!generated" class="empty-state">
<strong>等待生成面试题</strong>
<p>点击左侧“生成题目”,系统会按当前岗位画像加载 3 道 seed 面试题。</p>
<p>点击左侧“生成题目”,系统会按当前岗位画像生成 3 道面试题。</p>
<el-button type="danger" :icon="DocumentChecked" @click="generateQuestions">生成题目</el-button>
</div>
@@ -105,21 +105,21 @@
<div class="answer-box">
<div class="answer-head">
<span>候选人回答 · {{ activeQuestion.scenario }}</span>
<el-button text type="danger" @click="fillSampleAnswer">填入样例回答</el-button>
<el-button text type="danger" @click="fillSampleAnswer">填入参考回答</el-button>
</div>
<el-input
v-model="answers[activeQuestion.id]"
type="textarea"
:rows="6"
resize="none"
placeholder="输入候选人回答,或点击“填入样例回答”快速推进演示"
placeholder="输入候选人真实回答,或点击“填入参考回答”快速推进测试"
/>
</div>
<div class="action-row question-actions">
<el-button @click="moveQuestion(-1)">上一题</el-button>
<el-button @click="moveQuestion(1)">下一题</el-button>
<el-button @click="fillAllSampleAnswers">填满 seed 回答</el-button>
<el-button @click="fillAllSampleAnswers">填满参考回答</el-button>
<el-button @click="saveDraft">保存草稿</el-button>
<el-button type="danger" :disabled="!canScore" @click="finishScore">完成评分</el-button>
</div>
@@ -225,7 +225,7 @@ const candidates: CandidateSeed[] = [
id: 'zhang-ming',
name: '张明',
project: '银城花园 / 住宅',
mode: '文本演示,语音待接入',
mode: '文本面试',
experience: '3 年客服与案场接待',
risk: '催缴冲突经验不足',
job: {
@@ -280,7 +280,7 @@ const candidates: CandidateSeed[] = [
id: 'li-na',
name: '李娜',
project: '铂悦府 / 高端住宅',
mode: '文本演示,语音待接入',
mode: '文本面试',
experience: '2 年呼叫中心经验',
risk: '现场协调经验偏弱',
job: {
@@ -362,13 +362,14 @@ const scored = ref(false);
const showRubric = ref(false);
const activeQuestionId = ref('');
const sessionId = ref('');
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiMode = ref<'idle' | 'api' | 'fallback'>('idle');
const apiQuestions = ref<InterviewQuestion[]>([]);
const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
const answers = reactive<Record<string, string>>({});
const records = ref([...baseRecords]);
const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]);
const questions = computed(() => selectedCandidate.value.questions);
const questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions));
const activeQuestion = computed(() => questions.value.find((question) => question.id === activeQuestionId.value) ?? questions.value[0]);
const scoreResult = computed(() => apiScoreResult.value ?? selectedCandidate.value.scoreResult);
@@ -385,12 +386,12 @@ const answeredCount = computed(() => questions.value.filter((question) => answer
const canScore = computed(() => generated.value && answeredCount.value === questions.value.length);
const apiPill = computed(() => {
if (apiMode.value === 'api') {
return { text: '后端 API seed', className: 'pill-info' };
return { text: '真实 API', className: 'pill-info' };
}
if (apiMode.value === 'fallback') {
return { text: 'API 失败回退', className: 'pill-warning' };
return { text: '本地 Rubric', className: 'pill-warning' };
}
return { text: '本地 seed 演示', className: 'pill-info' };
return { text: '待调用 API', className: 'pill-info' };
});
const flowPill = computed(() => {
if (!generated.value) {
@@ -425,7 +426,8 @@ const resetFlow = () => {
showRubric.value = false;
activeQuestionId.value = '';
sessionId.value = '';
apiMode.value = 'seed';
apiMode.value = 'idle';
apiQuestions.value = [];
apiScoreResult.value = null;
resetAnswers();
records.value = [...baseRecords];
@@ -434,23 +436,33 @@ const resetFlow = () => {
const generateQuestions = async () => {
resetAnswers();
scored.value = false;
apiQuestions.value = [];
apiScoreResult.value = null;
records.value = [...baseRecords];
try {
const { data } = await startInterview({
candidateId: selectedCandidate.value.id,
positionCode: selectedCandidate.value.job.title,
mode: 'seed'
mode: 'real'
});
sessionId.value = data.sessionId;
apiMode.value = 'api';
activeQuestionId.value = data.questions?.[0]?.questionId ?? questions.value[0].id;
ElMessage.success(`已通过后端 API 生成 ${questions.value.length} 道 seed 面试题`);
apiQuestions.value = (data.questions || []).map((question) => ({
id: question.questionId,
seq: question.seq,
text: question.questionText,
point: question.point,
scenario: question.scenario,
sampleAnswer: question.sampleAnswer
}));
activeQuestionId.value = apiQuestions.value[0]?.id ?? questions.value[0].id;
ElMessage.success(data.source === 'real-llm' ? '已由真实模型生成面试题' : '模型未配置,已使用本地 Rubric 题库');
} catch {
sessionId.value = '';
apiMode.value = 'fallback';
apiQuestions.value = [];
activeQuestionId.value = questions.value[0].id;
ElMessage.warning('后端 API 不可用,已回退本地 seed 题库');
ElMessage.warning('后端 API 不可用,已使用本地 Rubric 题库');
}
generated.value = true;
};
@@ -463,7 +475,7 @@ const fillAllSampleAnswers = () => {
questions.value.forEach((question) => {
answers[question.id] = question.sampleAnswer;
});
ElMessage.success('已填入全部 seed 样例回答');
ElMessage.success('已填入全部参考回答');
};
const moveQuestion = (offset: number) => {
@@ -485,11 +497,11 @@ const saveDraft = async () => {
questionId: activeQuestion.value.id,
answerText
});
ElMessage.success('回答已提交后端 seed API');
ElMessage.success('回答已提交后端 API');
return;
} catch {
apiMode.value = 'fallback';
ElMessage.warning('后端暂存 API 不可用,已回退本地草稿');
ElMessage.warning('后端暂存 API 不可用,已保留本地草稿');
}
}
ElMessage.success('草稿已暂存到本地演示状态');
@@ -519,14 +531,14 @@ const finishScore = async () => {
};
if (apiMode.value === 'api' && sessionId.value) {
try {
const { data } = await finishInterview({ sessionId: sessionId.value });
const { data } = await finishInterview({ sessionId: sessionId.value, answers: { ...answers } });
apiScoreResult.value = mapApiScoreResult(data);
nextRecord = data.records?.[0] ?? nextRecord;
ElMessage.success('AI 面试评分已由后端 API 返回');
ElMessage.success(data.source === 'real-llm' ? 'AI 面试评分已由真实模型返回' : 'AI 面试评分已由本地 Rubric 返回');
} catch {
apiMode.value = 'fallback';
apiScoreResult.value = null;
ElMessage.warning('后端评分 API 不可用,已回退本地 seed 评分');
ElMessage.warning('后端评分 API 不可用,已回退本地 Rubric 评分');
}
} else {
apiScoreResult.value = null;
@@ -534,7 +546,7 @@ const finishScore = async () => {
scored.value = true;
records.value = [nextRecord, ...baseRecords];
if (apiMode.value !== 'api') {
ElMessage.success('AI 面试评分已生成,并写入本地演示记录');
ElMessage.success('AI 面试评分已生成,并写入本地记录');
}
};
+13 -13
View File
@@ -87,7 +87,7 @@
<div class="reply-box">
<div class="reply-head">
<span>学员下一句</span>
<el-button text type="danger" :disabled="finished" @click="fillSeedReply">填入 seed 回复</el-button>
<el-button text type="danger" :disabled="finished" @click="fillReferenceReply">填入参考回复</el-button>
</div>
<el-input
v-model="draftReply"
@@ -95,7 +95,7 @@
:rows="5"
resize="none"
:disabled="finished"
placeholder="输入学员回复,或点击“填入 seed 回复”推进演示"
placeholder="输入学员回复,或点击“填入参考回复”推进测试"
/>
</div>
@@ -276,7 +276,7 @@ const showGoal = ref(false);
const activeRound = ref(0);
const draftReply = ref('');
const sessionId = ref('');
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiMode = ref<'local' | 'api' | 'fallback'>('local');
const apiScores = ref<ScenarioSeed['scores'] | null>(null);
const apiTotal = ref<number | null>(null);
const apiRewrite = ref('');
@@ -306,12 +306,12 @@ const trustScore = computed(() => {
});
const apiPill = computed(() => {
if (apiMode.value === 'api') {
return { text: '后端 API seed', className: 'pill-info' };
return { text: '真实 API', className: 'pill-info' };
}
if (apiMode.value === 'fallback') {
return { text: 'API 失败回退', className: 'pill-warning' };
return { text: '本地剧本', className: 'pill-warning' };
}
return { text: '本地 seed 演示', className: 'pill-info' };
return { text: '待调用 API', className: 'pill-info' };
});
const flowPill = computed(() => {
if (!started.value) {
@@ -354,7 +354,7 @@ const resetPractice = () => {
activeRound.value = 0;
draftReply.value = '';
sessionId.value = '';
apiMode.value = 'seed';
apiMode.value = 'local';
apiScores.value = null;
apiTotal.value = null;
apiRewrite.value = '';
@@ -371,23 +371,23 @@ const startPractice = async () => {
const { data } = await startPracticeSession({
extPartyId: selectedScenario.value.trainee,
scenarioId: selectedScenario.value.id,
mode: 'seed'
mode: 'real'
});
sessionId.value = data.sessionId;
apiMode.value = 'api';
apiTrust.value = data.trust;
appendTurn('customer', data.customerText || fallbackCustomer);
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} seed 对练`);
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
} catch {
sessionId.value = '';
apiMode.value = 'fallback';
appendTurn('customer', fallbackCustomer);
ElMessage.warning('后端对练 API 不可用,已回退本地 seed 对练');
ElMessage.warning('后端对练 API 不可用,已回退本地剧本');
}
started.value = true;
};
const fillSeedReply = () => {
const fillReferenceReply = () => {
draftReply.value = selectedScenario.value.rounds[activeRound.value].sampleReply;
};
@@ -420,7 +420,7 @@ const continueRound = async () => {
return;
} catch {
apiMode.value = 'fallback';
ElMessage.warning('后端回合 API 不可用,已回退本地 seed 推进');
ElMessage.warning('后端回合 API 不可用,已回退本地剧本推进');
}
}
@@ -460,7 +460,7 @@ const finishScore = async () => {
apiTotal.value = null;
apiRewrite.value = '';
apiSummary.value = '';
ElMessage.warning('后端评分 API 不可用,已回退本地 seed 评分');
ElMessage.warning('后端评分 API 不可用,已回退本地评分');
}
} else {
apiScores.value = null;
+721 -9
View File
@@ -159,7 +159,7 @@
</template>
<template v-else>
<Card title="今日任务" action="查看全部" @action="tap('查看全部')">
<Card title="今日任务" action="查看全部" @action="openUserAction('tasks')">
<div class="task-card">
<div class="task-copy">
<strong>{{ workerRole.primary.badge }}</strong>
@@ -174,7 +174,7 @@
<button type="button" @click="handlePrimaryAction">{{ workerRole.primary.cta }}</button>
</div>
<button class="review-card" type="button" @click="tap(workerRole.review.title)">
<button class="review-card" type="button" @click="openUserAction('sop')">
<IconBubble icon="document" tone="amber" />
<span>
<b>{{ workerRole.review.title }}</b>
@@ -223,10 +223,201 @@
</Card>
<Card :title="workerRole.toolTitle">
<ToolGrid :items="workerRole.tools" />
<ToolGrid :items="workerRole.tools" :handler="openUserTool" />
</Card>
<Card title="我的进度" :action="workerRole.recommend" compact-action @action="tap(workerRole.recommend)">
<Card v-if="roleKey === 'user' && activeUserPanel" :title="userPanelTitle">
<div class="user-panel">
<template v-if="activeUserPanel === 'tasks'">
<button class="panel-row" type="button" @click="startMobilePractice">
<IconBubble icon="headset" tone="teal" />
<span>
<b>{{ workerRole.primary.title }}</b>
<small>{{ workerRole.primary.desc }}</small>
</span>
<Icon name="chevron-right" size="24" />
</button>
<button class="panel-row" type="button" @click="openUserAction('sop')">
<IconBubble icon="document" tone="amber" />
<span>
<b>{{ workerRole.review.title }}</b>
<small>{{ workerRole.review.desc }}</small>
</span>
<Icon name="chevron-right" size="24" />
</button>
<button class="panel-row" type="button" @click="openUserAction('cases')">
<IconBubble icon="folder" tone="orange" />
<span>
<b>案例学习</b>
<small>查看优秀服务案例和可复用话术</small>
</span>
<Icon name="chevron-right" size="24" />
</button>
</template>
<template v-else-if="activeUserPanel === 'sop' || activeUserPanel === 'cases'">
<form class="knowledge-search" @submit.prevent="searchKnowledge()">
<input v-model.trim="knowledgeQuery" :placeholder="activeUserPanel === 'cases' ? '搜索案例关键词' : '搜索SOP关键词'" />
<button type="submit" :disabled="knowledgeLoading">{{ knowledgeLoading ? '查询中' : '查询' }}</button>
</form>
<em v-if="knowledgeMessage" class="panel-message">{{ knowledgeMessage }}</em>
<div v-if="knowledgeResult" class="knowledge-result">
<strong>{{ knowledgeResult.answer }}</strong>
<p v-if="knowledgeResult.reference">{{ knowledgeResult.reference }}</p>
<div v-if="knowledgeResult.training?.length" class="panel-tags">
<span v-for="item in knowledgeResult.training" :key="item">{{ item }}</span>
</div>
<div v-if="knowledgeResult.docs?.length" class="result-list">
<h3>命中文档</h3>
<p v-for="doc in knowledgeResult.docs" :key="`${doc.title}-${doc.hit}`">
<b>{{ doc.title }}</b>
<small>{{ doc.desc }} · {{ doc.hit }}</small>
</p>
</div>
<div v-if="knowledgeResult.snippets?.length" class="snippet-list">
<h3>引用片段</h3>
<blockquote v-for="item in knowledgeResult.snippets" :key="`${item.title}-${item.text}`">
<b>{{ item.title }}</b>
<span>{{ item.text }}</span>
</blockquote>
</div>
<div v-if="knowledgeResult.records?.length" class="result-list">
<h3>{{ activeUserPanel === 'cases' ? '可学习案例' : '知识记录' }}</h3>
<p v-for="record in knowledgeResult.records" :key="`${record.title}-${record.version}`">
<b>{{ record.title }}</b>
<small>{{ record.summary }} · {{ record.status }}</small>
</p>
</div>
</div>
</template>
<template v-else-if="activeUserPanel === 'submit'">
<label class="file-picker">
<span>{{ materialFile?.name || '选择语音素材' }}</span>
<small>支持 mp3 / wav / m4a / webm / ogg,上传后自动转写并整理案例</small>
<input type="file" accept="audio/*,.mp3,.wav,.m4a,.webm,.ogg" @change="handleMaterialFile" />
</label>
<button class="panel-primary" type="button" :disabled="materialUploading" @click="submitMaterial">
{{ materialUploading ? '提交中' : '上传并整理' }}
</button>
<em v-if="materialMessage" class="panel-message">{{ materialMessage }}</em>
<div v-if="materialResult" class="case-result">
<div v-if="materialResult.upload">
<h3>ASR转写</h3>
<p>{{ materialResult.upload.transcript }}</p>
<div class="panel-tags">
<span v-for="tag in materialResult.upload.tags" :key="tag.text">{{ tag.text }}</span>
</div>
</div>
<div v-if="materialResult.organize">
<h3>案例整理</h3>
<p v-if="materialResult.organize.aiSummary">{{ materialResult.organize.aiSummary }}</p>
<dl>
<template v-for="item in materialResult.organize.summary" :key="item.label">
<dt>{{ item.label }}</dt>
<dd>{{ item.text }}</dd>
</template>
</dl>
</div>
<div v-if="materialResult.curate?.records?.length">
<h3>入库记录</h3>
<p v-for="record in materialResult.curate.records" :key="record.title">
<b>{{ record.title }}</b>
<small>{{ record.summary }}</small>
</p>
</div>
</div>
</template>
<template v-else-if="activeUserPanel === 'profile'">
<div v-if="competencyProfile" class="profile-card panel-profile">
<span>
<small>综合能力</small>
<b>{{ competencyProfile.score }}</b>
</span>
<span>
<small>训练历史</small>
<b>{{ competencyProfile.completed }}</b>
</span>
<span>
<small>待复盘</small>
<b>{{ competencyProfile.pendingReview }}</b>
</span>
<div class="profile-dims">
<p v-for="item in competencyProfile.dimensions" :key="item.label">
<span>{{ item.label }}</span>
<b>{{ item.value }}</b>
<small>{{ item.note }}</small>
</p>
</div>
</div>
<div v-if="practiceHistory.length" class="record-list">
<h3>训练历史</h3>
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
<span>
<b>{{ item.scene }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.summary }}</small>
</span>
<em>{{ item.status }}</em>
</button>
</div>
</template>
<template v-else-if="activeUserPanel === 'history'">
<div v-if="practiceHistory.length && !userReviewDetail" class="record-list">
<h3>训练历史</h3>
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
<span>
<b>{{ item.scene }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.summary }}</small>
</span>
<em>{{ item.status }}</em>
</button>
</div>
<em v-if="userReviewMessage" class="panel-message">{{ userReviewMessage }}</em>
<div v-if="userReviewDetail" class="review-detail">
<div class="review-detail-head">
<span>
<b>{{ userReviewDetail.scene }} · {{ userReviewDetail.score }}分</b>
<small>{{ userReviewDetail.time }} · {{ userReviewDetail.status }}</small>
</span>
<em>{{ userReviewDetail.status }}</em>
</div>
<div class="review-score-grid">
<p v-for="item in userReviewDetail.scoreItems" :key="item.label">
<span>{{ item.label }}</span>
<b>{{ item.value }}</b>
<small>{{ item.note }}</small>
</p>
</div>
<div class="practice-dialogue review-dialogue">
<p v-for="(turn, index) in userReviewDetail.dialogue" :key="`${turn.role}-${index}`" :class="turn.role">
<span>{{ turn.label }}</span>
{{ turn.text }}
</p>
</div>
<div class="review-summary">
<b>导师改写</b>
<p>{{ userReviewDetail.mentorRewrite }}</p>
<b>AI点评</b>
<p>{{ userReviewDetail.aiComment }}</p>
</div>
</div>
</template>
<div class="panel-actions">
<button type="button" @click="activeUserPanel = ''">收起</button>
</div>
</div>
</Card>
<Card title="我的进度" :action="workerRole.recommend" compact-action @action="openUserAction('recommend')">
<div class="progress-grid">
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
</div>
@@ -255,7 +446,7 @@
<div v-if="practiceHistory.length" class="record-list">
<h3>训练历史</h3>
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="tap(item.scene)">
<button v-for="item in practiceHistory" :key="`${item.time}-${item.scene}`" type="button" @click="openUserHistory(item)">
<span>
<b>{{ item.scene }} · {{ item.score }}分</b>
<small>{{ item.time }} · {{ item.summary }}</small>
@@ -267,7 +458,7 @@
</template>
<nav class="tabbar" aria-label="底部导航">
<button v-for="tab in role.tabs" :key="tab.label" type="button" :class="{ active: tab.active }" @click="tap(tab.label)">
<button v-for="tab in role.tabs" :key="tab.label" type="button" :class="{ active: tab.active }" @click="openTab(tab.label)">
<Icon :name="tab.icon" size="28" />
<span>{{ tab.label }}</span>
</button>
@@ -282,6 +473,7 @@ import { computed, defineComponent, h, ref, watch } from 'vue';
type RoleKey = 'user' | 'candidate' | 'supervisor';
type Tone = 'teal' | 'blue' | 'orange' | 'purple' | 'amber';
type UserPanelKey = '' | 'tasks' | 'sop' | 'cases' | 'submit' | 'profile' | 'history' | 'recommend';
type IconName =
| 'bars'
| 'book'
@@ -453,6 +645,85 @@ interface CompetencyProfile {
dimensions: CompetencyDimension[];
}
interface KnowledgeDoc {
title: string;
desc: string;
hit: string;
type: string;
}
interface KnowledgeSnippet {
title: string;
text: string;
}
interface KnowledgeRecord {
title: string;
category: string;
version: string;
status: string;
owner: string;
summary: string;
}
interface KnowledgeSearchResponse {
queryText: string;
category: string;
answer: string;
reference: string;
docs: KnowledgeDoc[];
snippets: KnowledgeSnippet[];
training: string[];
records: KnowledgeRecord[];
}
interface CaseTag {
text: string;
type: string;
}
interface CaseSummaryItem {
label: string;
text: string;
}
interface CaseUploadResponse {
caseId: string;
fileName: string;
transcript: string;
tags: CaseTag[];
source: string;
}
interface CaseOrganizeResponse {
caseId: string;
summary: CaseSummaryItem[];
tags: CaseTag[];
aiSummary: string;
source: string;
}
interface CaseRecord {
title: string;
type: string;
status: string;
time: string;
owner: string;
summary: string;
}
interface CaseCurateResponse {
selectedCaseIds: string[];
records: CaseRecord[];
sampleHint: string;
}
interface MaterialResult {
upload: CaseUploadResponse;
organize: CaseOrganizeResponse;
curate: CaseCurateResponse;
}
const aliases: Record<string, RoleKey> = {
h5: 'user',
user: 'user',
@@ -499,9 +770,20 @@ const practiceMessage = ref('');
const practiceHistory = ref<PracticeRecord[]>([]);
const practiceReviews = ref<PracticeRecord[]>([]);
const selectedReview = ref<PracticeReviewDetail | null>(null);
const userReviewDetail = ref<PracticeReviewDetail | null>(null);
const userReviewMessage = ref('');
const reviewMessage = ref('');
const markingReviewed = ref(false);
const competencyProfile = ref<CompetencyProfile | null>(null);
const activeUserPanel = ref<UserPanelKey>('');
const knowledgeQuery = ref('住宅投诉处理流程');
const knowledgeResult = ref<KnowledgeSearchResponse | null>(null);
const knowledgeLoading = ref(false);
const knowledgeMessage = ref('');
const materialFile = ref<File | null>(null);
const materialUploading = ref(false);
const materialMessage = ref('');
const materialResult = ref<MaterialResult | null>(null);
const voiceEnabled = ref(true);
const recording = ref(false);
const asrBusy = ref(false);
@@ -519,6 +801,19 @@ const cycleRole = () => {
syncPath();
};
const userPanelTitle = computed(() => {
const titles: Record<Exclude<UserPanelKey, ''>, string> = {
tasks: '今日任务',
sop: 'SOP查询',
cases: '案例学习',
submit: '提交素材',
profile: '我的能力',
history: '训练复盘',
recommend: '专项推荐'
};
return activeUserPanel.value ? titles[activeUserPanel.value] : '员工工具';
});
interface ApiPayload<T> {
code?: number;
msg?: string;
@@ -562,7 +857,7 @@ const playCustomerVoice = async (text: string) => {
);
customerAudio?.pause();
customerAudio = new Audio(data.audioUrl);
void customerAudio.play();
void customerAudio.play().catch(() => undefined);
} catch {
// TTS 未配置或失败时静默降级为纯文本
}
@@ -622,6 +917,186 @@ const handlePrimaryAction = () => {
void startMobilePractice();
};
const searchKnowledge = async (query = knowledgeQuery.value, category = activeUserPanel.value === 'cases' ? 'case' : 'sop') => {
const cleaned = query.trim();
if (!cleaned) {
knowledgeMessage.value = '请输入关键词';
return;
}
knowledgeQuery.value = cleaned;
knowledgeLoading.value = true;
knowledgeMessage.value = '';
try {
knowledgeResult.value = await readApi<KnowledgeSearchResponse>(
await fetch('/dev-api/api/knowledge/search', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ queryText: cleaned, category, limit: 4 })
})
);
knowledgeMessage.value = '已从知识库检索';
} catch (error) {
knowledgeResult.value = null;
knowledgeMessage.value = error instanceof Error ? error.message : '知识库查询失败';
} finally {
knowledgeLoading.value = false;
}
};
const openUserAction = (panel: UserPanelKey) => {
if (roleKey.value !== 'user') {
tap(panel || roleKey.value);
return;
}
activeUserPanel.value = panel;
userReviewDetail.value = null;
userReviewMessage.value = '';
if (panel === 'sop') {
knowledgeQuery.value = '住宅投诉处理流程';
void searchKnowledge(knowledgeQuery.value, 'sop');
return;
}
if (panel === 'cases') {
knowledgeQuery.value = '投诉接待优秀案例';
void searchKnowledge(knowledgeQuery.value, 'case');
return;
}
if (panel === 'recommend') {
activeUserPanel.value = 'sop';
knowledgeQuery.value = '催费沟通专项';
void searchKnowledge(knowledgeQuery.value, 'sop');
return;
}
if (panel === 'profile' || panel === 'history') {
void fetchPracticeData();
}
};
const openUserTool = (item: ToolItem) => {
if (roleKey.value !== 'user') {
tap(item.title);
return;
}
if (item.title === '每日一练') {
void startMobilePractice();
return;
}
if (item.title === '查SOP') {
openUserAction('sop');
return;
}
if (item.title === '案例学习') {
openUserAction('cases');
return;
}
if (item.title === '提交素材') {
openUserAction('submit');
return;
}
tap(item.title);
};
const openTab = (label: string) => {
if (roleKey.value !== 'user') {
tap(label);
return;
}
if (label === '首页') {
activeUserPanel.value = '';
return;
}
if (label === '训练') {
void startMobilePractice();
return;
}
if (label === '知识') {
openUserAction('sop');
return;
}
if (label === '我的') {
openUserAction('profile');
return;
}
tap(label);
};
const openUserHistory = async (item: PracticeRecord) => {
if (!item.id) {
activeUserPanel.value = 'history';
userReviewMessage.value = '这条训练记录暂无复盘详情';
userReviewDetail.value = null;
return;
}
activeUserPanel.value = 'history';
userReviewDetail.value = null;
userReviewMessage.value = '';
try {
userReviewDetail.value = await readApi<PracticeReviewDetail>(
await fetch(`/dev-api/api/aihr/mobile/practice/reviews/${item.id}`, { headers: apiHeaders() })
);
} catch (error) {
userReviewMessage.value = error instanceof Error ? error.message : '训练复盘加载失败';
}
};
const handleMaterialFile = (event: Event) => {
const input = event.target as HTMLInputElement;
materialFile.value = input.files?.[0] || null;
materialResult.value = null;
materialMessage.value = materialFile.value ? '已选择语音素材' : '';
};
const submitMaterial = async () => {
if (!materialFile.value) {
materialMessage.value = '请先选择语音素材';
return;
}
materialUploading.value = true;
materialMessage.value = '';
materialResult.value = null;
try {
const form = new FormData();
form.append('file', materialFile.value);
form.append('projectExtOrgId', phone.value || 'mobile-user');
const upload = await readApi<CaseUploadResponse>(
await fetch('/dev-api/api/knowledge/case/upload', {
method: 'POST',
headers: authOnlyHeaders(),
body: form
})
);
const organize = await readApi<CaseOrganizeResponse>(
await fetch('/dev-api/api/knowledge/case/organize', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ caseId: upload.caseId })
})
);
const curate = await readApi<CaseCurateResponse>(
await fetch('/dev-api/api/knowledge/case/curate', {
method: 'POST',
headers: apiHeaders(),
body: JSON.stringify({ caseId: upload.caseId, criteria: '员工端提交素材', limit: 1 })
})
);
materialResult.value = { upload, organize, curate };
materialMessage.value = '素材已完成转写、整理和入库';
} catch (error) {
materialMessage.value = error instanceof Error ? error.message : '素材提交失败';
} finally {
materialUploading.value = false;
}
};
const appendPracticeTurn = (role: PracticeRole, text: string) => {
const labels: Record<PracticeRole, string> = {
customer: 'AI业主',
@@ -979,6 +1454,7 @@ const fetchPracticeData = async () => {
practiceHistory.value = [];
practiceReviews.value = [];
selectedReview.value = null;
userReviewDetail.value = null;
competencyProfile.value = null;
return;
}
@@ -1005,6 +1481,7 @@ const fetchPracticeData = async () => {
practiceHistory.value = [];
practiceReviews.value = [];
selectedReview.value = null;
userReviewDetail.value = null;
competencyProfile.value = null;
}
};
@@ -1014,6 +1491,11 @@ watch(
() => {
apiHome.value = null;
reviewMessage.value = '';
userReviewMessage.value = '';
if (roleKey.value !== 'user') {
activeUserPanel.value = '';
userReviewDetail.value = null;
}
void fetchMobileHome();
void fetchPracticeData();
},
@@ -1211,7 +1693,8 @@ const IconBubble = defineComponent({
const ToolGrid = defineComponent({
props: {
items: { type: Array as PropType<ToolItem[]>, required: true }
items: { type: Array as PropType<ToolItem[]>, required: true },
handler: { type: Function as PropType<(item: ToolItem) => void>, default: undefined }
},
setup(props) {
return () =>
@@ -1219,7 +1702,7 @@ const ToolGrid = defineComponent({
'div',
{ class: 'tool-grid' },
props.items.map((item) =>
h('button', { type: 'button', class: `tone-${item.tone}`, onClick: () => tap(item.title) }, [
h('button', { type: 'button', class: `tone-${item.tone}`, onClick: () => (props.handler ? props.handler(item) : tap(item.title)) }, [
h(IconBubble, { icon: item.icon, tone: item.tone }),
h('span', [h('b', item.title), h('small', item.desc)]),
h(Icon, { name: 'chevron-right', size: 24 })
@@ -1852,6 +2335,235 @@ p {
font-weight: 800;
}
.user-panel {
display: grid;
gap: 12px;
}
.panel-row {
width: 100%;
min-height: 66px;
padding: 10px 11px;
border: 1px solid rgba(213, 225, 233, 0.9);
border-radius: 12px;
background: #fff;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
text-align: left;
}
.panel-row b,
.knowledge-result strong,
.case-result h3,
.result-list h3,
.snippet-list h3 {
color: #101826;
font-weight: 900;
}
.panel-row b {
display: block;
font-size: 15px;
line-height: 1.25;
}
.panel-row small {
display: block;
margin-top: 4px;
color: #607086;
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.knowledge-search {
display: grid;
grid-template-columns: minmax(0, 1fr) 78px;
gap: 8px;
}
.knowledge-search input {
min-width: 0;
height: 42px;
border: 1px solid rgba(20, 177, 169, 0.24);
border-radius: 10px;
padding: 0 11px;
color: #111827;
background: #fff;
font: inherit;
font-size: 14px;
font-weight: 700;
outline: none;
}
.knowledge-search button,
.panel-primary,
.panel-actions button {
min-height: 42px;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #1cbdb4, #07948e);
box-shadow: 0 7px 15px rgba(9, 151, 144, 0.18);
font-size: 14px;
font-weight: 900;
}
.knowledge-search button:disabled,
.panel-primary:disabled {
opacity: 0.55;
}
.panel-message {
color: #0a9a94;
font-size: 13px;
font-style: normal;
font-weight: 800;
}
.knowledge-result,
.case-result {
display: grid;
gap: 10px;
}
.knowledge-result strong {
font-size: 16px;
line-height: 1.35;
}
.knowledge-result > p,
.case-result p,
.case-result dd {
margin: 0;
color: #506078;
font-size: 13px;
line-height: 1.45;
font-weight: 650;
}
.panel-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.panel-tags span {
max-width: 100%;
padding: 5px 8px;
border-radius: 999px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
font-size: 12px;
line-height: 1.2;
font-weight: 850;
}
.result-list,
.snippet-list {
display: grid;
gap: 8px;
}
.result-list h3,
.snippet-list h3,
.case-result h3 {
margin: 0;
font-size: 15px;
}
.result-list p,
.snippet-list blockquote,
.case-result > div {
margin: 0;
padding: 10px;
border: 1px solid rgba(213, 225, 233, 0.9);
border-radius: 10px;
background: #fff;
}
.result-list b,
.snippet-list b,
.case-result b {
display: block;
color: #101826;
font-size: 14px;
line-height: 1.25;
font-weight: 900;
}
.result-list small,
.snippet-list span {
display: block;
margin-top: 4px;
color: #607086;
font-size: 12px;
line-height: 1.4;
font-weight: 650;
}
.file-picker {
min-height: 86px;
padding: 13px;
border: 1px dashed rgba(20, 177, 169, 0.38);
border-radius: 12px;
background: #f8ffff;
display: grid;
align-content: center;
gap: 6px;
position: relative;
overflow: hidden;
}
.file-picker span {
color: #101826;
font-size: 15px;
font-weight: 900;
}
.file-picker small {
color: #607086;
font-size: 12px;
line-height: 1.35;
font-weight: 650;
}
.file-picker input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.case-result dl {
margin: 0;
display: grid;
gap: 6px;
}
.case-result dt {
color: #0a9a94;
font-size: 13px;
font-weight: 900;
}
.panel-profile {
margin-top: 0;
}
.panel-actions {
display: flex;
justify-content: flex-end;
}
.panel-actions button {
min-width: 76px;
color: #079a95;
background: rgba(15, 174, 166, 0.1);
box-shadow: none;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+2 -2
View File
@@ -66,7 +66,7 @@ contains mobile/src/App.vue "competencyProfile"
contains mobile/package.json "aihr-mobile"
contains frontend/src/views/recruit/interview.vue "建议复试"
contains frontend/src/views/train/practice.vue "导师改写"
contains frontend/src/views/knowledge/cases.vue "seed入库"
contains frontend/src/views/knowledge/cases.vue "真实语音"
contains frontend/src/views/knowledge/sop.vue "训练题已生成"
contains frontend/src/views/system/model/index.vue "调用优先级"
contains frontend/src/api/aihr/interview.ts "/api/recruit/interview/start"
@@ -110,7 +110,7 @@ contains backend/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaCon
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/api/aihr/mobile"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "candidateHome"
echo "OK: demo routes and seed markers"
echo "OK: demo routes and real-flow markers"
echo "OK: AI interview API markers"
echo "OK: AI practice API markers"
echo "OK: AI case API markers"