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