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.CurateResponse;
|
||||||
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
|
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
|
||||||
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
|
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.domain.AihrCaseDto.UploadResponse;
|
||||||
import org.dromara.aihr.service.AihrCaseSeedService;
|
import org.dromara.aihr.service.AihrCaseService;
|
||||||
import org.dromara.common.core.domain.R;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 案例语音整理 seed API。
|
* 案例语音整理 API。
|
||||||
*/
|
*/
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/knowledge/case")
|
@RequestMapping("/api/knowledge/case")
|
||||||
public class AihrCaseController {
|
public class AihrCaseController {
|
||||||
|
|
||||||
private final AihrCaseSeedService caseSeedService;
|
private final AihrCaseService caseService;
|
||||||
|
|
||||||
@PostMapping("/upload")
|
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public R<UploadResponse> upload(@RequestBody UploadRequest request) {
|
public R<UploadResponse> upload(@RequestPart("file") MultipartFile file, @RequestParam(value = "projectExtOrgId", required = false) String projectExtOrgId) {
|
||||||
return R.ok(caseSeedService.upload(request));
|
return R.ok(caseService.upload(file, projectExtOrgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/organize")
|
@PostMapping("/organize")
|
||||||
public R<OrganizeResponse> organize(@RequestBody OrganizeRequest request) {
|
public R<OrganizeResponse> organize(@RequestBody OrganizeRequest request) {
|
||||||
return R.ok(caseSeedService.organize(request));
|
return R.ok(caseService.organize(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/curate")
|
@PostMapping("/curate")
|
||||||
public R<CurateResponse> curate(@RequestBody CurateRequest request) {
|
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.FinishResponse;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
|
||||||
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
|
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.dromara.common.core.domain.R;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
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;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 面试 seed API。
|
* AI 面试 API。
|
||||||
*/
|
*/
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/recruit/interview")
|
@RequestMapping("/api/recruit/interview")
|
||||||
public class AihrInterviewController {
|
public class AihrInterviewController {
|
||||||
|
|
||||||
private final AihrInterviewSeedService interviewSeedService;
|
private final AihrInterviewService interviewService;
|
||||||
|
|
||||||
@PostMapping("/start")
|
@PostMapping("/start")
|
||||||
public R<StartResponse> start(@RequestBody StartRequest request) {
|
public R<StartResponse> start(@RequestBody StartRequest request) {
|
||||||
return R.ok(interviewSeedService.start(request));
|
return R.ok(interviewService.start(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/answer")
|
@PostMapping("/answer")
|
||||||
public R<AnswerResponse> answer(@RequestBody AnswerRequest request) {
|
public R<AnswerResponse> answer(@RequestBody AnswerRequest request) {
|
||||||
return R.ok(interviewSeedService.answer(request));
|
return R.ok(interviewService.answer(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/finish")
|
@PostMapping("/finish")
|
||||||
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile H5 home APIs.
|
* Mobile H5 home APIs.
|
||||||
@@ -44,9 +46,9 @@ public class AihrMobileController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/practice/reviews/{id}")
|
@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);
|
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")
|
@PostMapping("/practice/reviews/{id}/reviewed")
|
||||||
@@ -58,4 +60,29 @@ public class AihrMobileController {
|
|||||||
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
|
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
|
||||||
return R.ok(mobileSeedService.profile(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;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三角色对练 seed API。
|
* 三角色对练 API。
|
||||||
*/
|
*/
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RestController
|
@RestController
|
||||||
|
|||||||
+2
-2
@@ -10,13 +10,13 @@ public final class AihrCaseDto {
|
|||||||
public record UploadRequest(String fileName, String projectExtOrgId) {
|
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 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) {
|
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 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) {
|
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 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(
|
public record FinishResponse(
|
||||||
@@ -33,7 +33,8 @@ public final class AihrInterviewDto {
|
|||||||
String aiSummary,
|
String aiSummary,
|
||||||
String evidence,
|
String evidence,
|
||||||
List<DimensionResponse> dimensions,
|
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 现场演示入口:先跑预检,再按演示流逐条点击。当前证明一期 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
|
./scripts/demo-check.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
预检通过只代表本地前端、基础接口、路由和 seed 标记在位。真实演示仍以浏览器点击为准。
|
预检通过只代表本地前端、基础接口、路由和关键标记在位。真实演示仍以浏览器点击为准。
|
||||||
|
|
||||||
## 现场演示脚本
|
## 现场演示脚本
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” |
|
| 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`,底部导航停留在移动端工程内 |
|
| 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 回答 → 完成评分 | 已完成闭环、建议复试、新增面试记录 |
|
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 |
|
||||||
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入 seed 回复 → 继续一轮 → 填入 seed 回复 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时为 seed 固定值 |
|
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 |
|
||||||
| 5 | 案例沉淀 `/knowledge/cases` | 选择样例录音 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环、`seed入库`、样片兜底文案 |
|
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,最近案例新增真实转写生成的记录;视频仍用样片兜底 |
|
||||||
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
|
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
|
||||||
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 |
|
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 |
|
||||||
|
|
||||||
@@ -33,5 +33,5 @@
|
|||||||
## 不演示
|
## 不演示
|
||||||
|
|
||||||
- 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。
|
- 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。
|
||||||
- 不承诺管理端 AI 面试、案例沉淀的 seed 记录已写入业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。
|
- 不承诺管理端 AI 面试、案例沉淀已写入正式业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。
|
||||||
- 大模型不作为演示硬依赖:模型管理已启用 chat 模型时,三角色对练为真实 LLM 生成与评分(asr/tts 配置后语音输入/播报可用);未配置或现场调用失败时全链路自动回退 seed,演示不中断。
|
- 大模型不作为演示硬依赖:模型管理已启用 chat 模型时,三角色对练为真实 LLM 生成与评分(asr/tts 配置后语音输入/播报可用);未配置或现场调用失败时全链路自动回退 seed,演示不中断。
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type CaseUploadResponse = {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
transcript: string;
|
transcript: string;
|
||||||
tags: CaseTag[];
|
tags: CaseTag[];
|
||||||
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CaseOrganizeResponse = {
|
export type CaseOrganizeResponse = {
|
||||||
@@ -37,6 +38,7 @@ export type CaseOrganizeResponse = {
|
|||||||
summary: CaseSummaryItem[];
|
summary: CaseSummaryItem[];
|
||||||
tags: CaseTag[];
|
tags: CaseTag[];
|
||||||
aiSummary: string;
|
aiSummary: string;
|
||||||
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CaseCurateResponse = {
|
export type CaseCurateResponse = {
|
||||||
@@ -45,7 +47,7 @@ export type CaseCurateResponse = {
|
|||||||
sampleHint: string;
|
sampleHint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function uploadCaseAudio(data: { fileName: string; projectExtOrgId: string }): Promise<ApiResult<CaseUploadResponse>> {
|
export function uploadCaseAudio(data: FormData): Promise<ApiResult<CaseUploadResponse>> {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/knowledge/case/upload',
|
url: '/api/knowledge/case/upload',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type InterviewStartResponse = {
|
|||||||
candidateName: string;
|
candidateName: string;
|
||||||
positionCode: string;
|
positionCode: string;
|
||||||
questions: InterviewQuestionResponse[];
|
questions: InterviewQuestionResponse[];
|
||||||
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InterviewAnswerRequest = {
|
export type InterviewAnswerRequest = {
|
||||||
@@ -44,6 +45,7 @@ export type InterviewAnswerResponse = {
|
|||||||
|
|
||||||
export type InterviewFinishRequest = {
|
export type InterviewFinishRequest = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
answers: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InterviewFinishResponse = {
|
export type InterviewFinishResponse = {
|
||||||
@@ -65,6 +67,7 @@ export type InterviewFinishResponse = {
|
|||||||
result: string;
|
result: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
}>;
|
}>;
|
||||||
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function startInterview(data: InterviewStartRequest): Promise<ApiResult<InterviewStartResponse>> {
|
export function startInterview(data: InterviewStartRequest): Promise<ApiResult<InterviewStartResponse>> {
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="cases-page">
|
<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">
|
<section class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>案例语音整理</h1>
|
<h1>案例语音整理</h1>
|
||||||
<p>语音上传或选择样例录音,完成 ASR 转写、AI 结构化整理、送审入库。</p>
|
<p>上传真实语音,完成 ASR 转写、AI 结构化整理、送审入库。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span class="status-pill" :class="apiPill.type">{{ apiPill.label }}</span>
|
<span class="status-pill" :class="apiPill.type">{{ apiPill.label }}</span>
|
||||||
<span class="status-pill" :class="flowStatus.type">{{ flowStatus.label }}</span>
|
<span class="status-pill" :class="flowStatus.type">{{ flowStatus.label }}</span>
|
||||||
<el-button @click="router.push('/index')">返回首页</el-button>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -19,18 +20,18 @@
|
|||||||
<el-icon><Microphone /></el-icon>
|
<el-icon><Microphone /></el-icon>
|
||||||
<h2>上传与转写</h2>
|
<h2>上传与转写</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-box" @click="selectSample">
|
<div class="upload-box" @click="audioInput?.click()">
|
||||||
<el-icon><FolderChecked /></el-icon>
|
<el-icon><FolderChecked /></el-icon>
|
||||||
<strong>{{ sampleSelected ? displayFileName : '拖入语音 / 选择样例录音' }}</strong>
|
<strong>{{ audioUploaded ? displayFileName : '选择语音文件' }}</strong>
|
||||||
<span>{{ sampleSelected ? '已加载样例录音并完成 ASR 转写。' : 'Demo 暂用样例转写文本,ASR 接入后替换。' }}</span>
|
<span>{{ audioUploaded ? '真实语音已完成 ASR 转写。' : '支持 mp3/wav/m4a/webm/ogg,需先配置 ASR 模型。' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sampleSelected" class="transcript-card">
|
<div v-if="audioUploaded" class="transcript-card">
|
||||||
<span>ASR 转写结果</span>
|
<span>ASR 转写结果</span>
|
||||||
<p>{{ displayTranscript }}</p>
|
<p>{{ displayTranscript }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="empty-state">先选择样例录音,页面会展示转写结果和后续整理动作。</div>
|
<div v-else class="empty-state">先上传真实语音,页面会展示转写结果和后续整理动作。</div>
|
||||||
<div class="action-row">
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -105,74 +106,59 @@ import { curateKnowledgeCase, organizeKnowledgeCase, uploadCaseAudio, type CaseR
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const seedCase = {
|
const audioInput = ref<HTMLInputElement>();
|
||||||
fileName: '车库积水投诉_20260702.wav',
|
const audioUploaded = ref(false);
|
||||||
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 organized = ref(false);
|
const organized = ref(false);
|
||||||
const reviewed = ref(false);
|
const reviewed = ref(false);
|
||||||
const stored = ref(false);
|
const stored = ref(false);
|
||||||
const sampleVisible = ref(false);
|
const sampleVisible = ref(false);
|
||||||
const caseId = ref('');
|
const caseId = ref('');
|
||||||
const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
|
const apiMode = ref<'idle' | 'api' | 'error'>('idle');
|
||||||
const apiFileName = ref('');
|
const apiFileName = ref('');
|
||||||
const apiTranscript = ref('');
|
const apiTranscript = ref('');
|
||||||
const apiSummary = ref<CaseSummaryItem[] | null>(null);
|
const apiSummary = ref<CaseSummaryItem[]>([]);
|
||||||
const apiTags = ref<CaseTag[] | null>(null);
|
const apiTags = ref<CaseTag[]>([]);
|
||||||
const apiRecord = ref<CaseRecord | null>(null);
|
const apiRecord = ref<CaseRecord | null>(null);
|
||||||
const apiSampleHint = ref('');
|
const apiSampleHint = ref('');
|
||||||
|
|
||||||
const displayFileName = computed(() => apiFileName.value || seedCase.fileName);
|
const displayFileName = computed(() => apiFileName.value || '未选择文件');
|
||||||
const displayTranscript = computed(() => apiTranscript.value || seedCase.transcript);
|
const displayTranscript = computed(() => apiTranscript.value || '暂无转写结果');
|
||||||
const displaySummary = computed(() => apiSummary.value ?? seedCase.summary);
|
const displaySummary = computed(() => apiSummary.value);
|
||||||
const displayTags = computed(() => apiTags.value ?? seedCase.tags);
|
const displayTags = computed(() => apiTags.value);
|
||||||
const summary = computed(() => (organized.value ? displaySummary.value : []));
|
const summary = computed(() => (organized.value ? displaySummary.value : []));
|
||||||
|
|
||||||
const apiPill = computed(() => {
|
const apiPill = computed(() => {
|
||||||
if (apiMode.value === 'api') return { label: '后端 API seed', type: 'pill-primary' };
|
if (apiMode.value === 'api') return { label: '真实 ASR/API', type: 'pill-primary' };
|
||||||
if (apiMode.value === 'fallback') return { label: 'API 失败回退', type: 'pill-warning' };
|
if (apiMode.value === 'error') return { label: '接口失败', type: 'pill-warning' };
|
||||||
return { label: '本地 seed 演示', type: 'pill-primary' };
|
return { label: '待上传语音', type: 'pill-primary' };
|
||||||
});
|
});
|
||||||
|
|
||||||
const flowStatus = computed(() => {
|
const flowStatus = computed(() => {
|
||||||
if (stored.value) return { label: '已完成闭环', type: 'pill-success' };
|
if (stored.value) return { label: '已完成闭环', type: 'pill-success' };
|
||||||
if (reviewed.value) return { label: '已送审待入库', type: 'pill-primary' };
|
if (reviewed.value) return { label: '已送审待入库', type: 'pill-primary' };
|
||||||
if (organized.value) return { label: '已生成案例稿', type: 'pill-warning' };
|
if (organized.value) return { label: '已生成案例稿', type: 'pill-warning' };
|
||||||
if (sampleSelected.value) return { label: '已完成 ASR', type: 'pill-primary' };
|
if (audioUploaded.value) return { label: '已完成 ASR', type: 'pill-primary' };
|
||||||
return { label: '样片兜底', type: 'pill-warning' };
|
return { label: '等待语音', type: 'pill-warning' };
|
||||||
});
|
});
|
||||||
|
|
||||||
const pipeline = computed(() => [
|
const pipeline = computed(() => [
|
||||||
{
|
{
|
||||||
name: '语音上传',
|
name: '语音上传',
|
||||||
count: sampleSelected.value ? '1 条' : '0 条',
|
count: audioUploaded.value ? '1 条' : '0 条',
|
||||||
note: sampleSelected.value ? '样例录音已选择' : '等待上传',
|
note: audioUploaded.value ? '真实音频已上传' : '等待上传',
|
||||||
state: sampleSelected.value ? 'done' : 'todo'
|
state: audioUploaded.value ? 'done' : 'todo'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ASR 转写',
|
name: 'ASR 转写',
|
||||||
count: sampleSelected.value ? '1 条' : '0 条',
|
count: audioUploaded.value ? '1 条' : '0 条',
|
||||||
note: sampleSelected.value ? '本地 seed 转写完成' : '等待语音',
|
note: audioUploaded.value ? '真实 ASR 已转写' : '等待语音',
|
||||||
state: sampleSelected.value ? 'done' : 'todo'
|
state: audioUploaded.value ? 'done' : 'todo'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AI 整理',
|
name: 'AI 整理',
|
||||||
count: organized.value ? '1 条' : '0 条',
|
count: organized.value ? '1 条' : '0 条',
|
||||||
note: organized.value ? '结构化案例稿已生成' : '等待整理',
|
note: organized.value ? '结构化案例稿已生成' : '等待整理',
|
||||||
state: organized.value ? 'done' : sampleSelected.value ? 'active' : 'todo'
|
state: organized.value ? 'done' : audioUploaded.value ? 'active' : 'todo'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '审核入库',
|
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]);
|
const records = ref([...baseRecords]);
|
||||||
|
|
||||||
async function selectSample() {
|
async function handleAudioPicked(event: Event) {
|
||||||
sampleSelected.value = true;
|
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;
|
organized.value = false;
|
||||||
reviewed.value = false;
|
reviewed.value = false;
|
||||||
stored.value = false;
|
stored.value = false;
|
||||||
sampleVisible.value = false;
|
sampleVisible.value = false;
|
||||||
apiSummary.value = null;
|
apiSummary.value = [];
|
||||||
apiRecord.value = null;
|
apiRecord.value = null;
|
||||||
apiSampleHint.value = '';
|
apiSampleHint.value = '';
|
||||||
|
apiTranscript.value = '';
|
||||||
|
apiTags.value = [];
|
||||||
try {
|
try {
|
||||||
const { data } = await uploadCaseAudio({
|
const form = new FormData();
|
||||||
fileName: seedCase.fileName,
|
form.append('file', file);
|
||||||
projectExtOrgId: 'seed-project'
|
form.append('projectExtOrgId', 'mobile-upload');
|
||||||
});
|
const { data } = await uploadCaseAudio(form);
|
||||||
caseId.value = data.caseId;
|
caseId.value = data.caseId;
|
||||||
apiMode.value = 'api';
|
apiMode.value = 'api';
|
||||||
apiFileName.value = data.fileName;
|
apiFileName.value = data.fileName;
|
||||||
apiTranscript.value = data.transcript;
|
apiTranscript.value = data.transcript;
|
||||||
apiTags.value = data.tags;
|
apiTags.value = data.tags;
|
||||||
ElMessage.success('已通过后端 API 加载样例录音并完成 ASR 转写');
|
audioUploaded.value = true;
|
||||||
|
ElMessage.success('真实语音已完成 ASR 转写');
|
||||||
} catch {
|
} catch {
|
||||||
caseId.value = 'case-garage-water';
|
caseId.value = '';
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'error';
|
||||||
apiFileName.value = '';
|
apiFileName.value = file.name;
|
||||||
apiTranscript.value = '';
|
ElMessage.error('语音转写失败,请检查 ASR 模型配置或音频格式');
|
||||||
apiTags.value = null;
|
|
||||||
ElMessage.warning('后端案例上传 API 不可用,已回退本地 seed 转写');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function organizeCase() {
|
async function organizeCase() {
|
||||||
if (!sampleSelected.value) {
|
if (!audioUploaded.value || !caseId.value) return;
|
||||||
await selectSample();
|
|
||||||
}
|
|
||||||
if (apiMode.value === 'api') {
|
if (apiMode.value === 'api') {
|
||||||
try {
|
try {
|
||||||
const { data } = await organizeKnowledgeCase({ caseId: caseId.value });
|
const { data } = await organizeKnowledgeCase({ caseId: caseId.value });
|
||||||
apiSummary.value = data.summary;
|
apiSummary.value = data.summary;
|
||||||
apiTags.value = data.tags;
|
apiTags.value = data.tags;
|
||||||
organized.value = true;
|
organized.value = true;
|
||||||
ElMessage.success('结构化案例稿已由后端 API 返回');
|
ElMessage.success(data.source === 'real-llm' ? '结构化案例稿已由真实模型返回' : '结构化案例稿已按真实转写生成');
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'error';
|
||||||
apiSummary.value = null;
|
apiSummary.value = [];
|
||||||
apiTags.value = null;
|
apiTags.value = [];
|
||||||
ElMessage.warning('后端案例整理 API 不可用,已回退本地 seed 案例稿');
|
ElMessage.error('后端案例整理 API 不可用');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
organized.value = true;
|
|
||||||
ElMessage.success('AI 已生成结构化案例稿');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitReview() {
|
function submitReview() {
|
||||||
@@ -295,7 +277,6 @@ async function storeCase() {
|
|||||||
}
|
}
|
||||||
reviewed.value = true;
|
reviewed.value = true;
|
||||||
stored.value = true;
|
stored.value = true;
|
||||||
if (apiMode.value === 'api') {
|
|
||||||
try {
|
try {
|
||||||
const { data } = await curateKnowledgeCase({
|
const { data } = await curateKnowledgeCase({
|
||||||
caseId: caseId.value,
|
caseId: caseId.value,
|
||||||
@@ -308,18 +289,12 @@ async function storeCase() {
|
|||||||
records.value.unshift(apiRecord.value);
|
records.value.unshift(apiRecord.value);
|
||||||
}
|
}
|
||||||
ElMessage.success('案例已由后端 API 入库,可用于训练复用');
|
ElMessage.success('案例已由后端 API 入库,可用于训练复用');
|
||||||
return;
|
|
||||||
} catch {
|
} catch {
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'error';
|
||||||
apiRecord.value = null;
|
apiRecord.value = null;
|
||||||
apiSampleHint.value = '';
|
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() {
|
function viewSample() {
|
||||||
@@ -336,6 +311,10 @@ function viewSample() {
|
|||||||
color: #162033;
|
color: #162033;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.page-head {
|
.page-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<section class="page-head">
|
<section class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>AI 面试出题</h1>
|
<h1>AI 面试出题</h1>
|
||||||
<p>岗位画像驱动出题、候选人作答、Rubric 评分、面试记录沉淀的本地 seed 演示闭环。</p>
|
<p>岗位画像驱动出题、候选人作答、Rubric 评分、面试记录沉淀。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
|
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
|
|
||||||
<div v-if="!generated" class="empty-state">
|
<div v-if="!generated" class="empty-state">
|
||||||
<strong>等待生成面试题</strong>
|
<strong>等待生成面试题</strong>
|
||||||
<p>点击左侧“生成题目”,系统会按当前岗位画像加载 3 道 seed 面试题。</p>
|
<p>点击左侧“生成题目”,系统会按当前岗位画像生成 3 道面试题。</p>
|
||||||
<el-button type="danger" :icon="DocumentChecked" @click="generateQuestions">生成题目</el-button>
|
<el-button type="danger" :icon="DocumentChecked" @click="generateQuestions">生成题目</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,21 +105,21 @@
|
|||||||
<div class="answer-box">
|
<div class="answer-box">
|
||||||
<div class="answer-head">
|
<div class="answer-head">
|
||||||
<span>候选人回答 · {{ activeQuestion.scenario }}</span>
|
<span>候选人回答 · {{ activeQuestion.scenario }}</span>
|
||||||
<el-button text type="danger" @click="fillSampleAnswer">填入样例回答</el-button>
|
<el-button text type="danger" @click="fillSampleAnswer">填入参考回答</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="answers[activeQuestion.id]"
|
v-model="answers[activeQuestion.id]"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="6"
|
:rows="6"
|
||||||
resize="none"
|
resize="none"
|
||||||
placeholder="输入候选人回答,或点击“填入样例回答”快速推进演示"
|
placeholder="输入候选人真实回答,或点击“填入参考回答”快速推进测试"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-row question-actions">
|
<div class="action-row question-actions">
|
||||||
<el-button @click="moveQuestion(-1)">上一题</el-button>
|
<el-button @click="moveQuestion(-1)">上一题</el-button>
|
||||||
<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 @click="saveDraft">保存草稿</el-button>
|
||||||
<el-button type="danger" :disabled="!canScore" @click="finishScore">完成评分</el-button>
|
<el-button type="danger" :disabled="!canScore" @click="finishScore">完成评分</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,7 +225,7 @@ const candidates: CandidateSeed[] = [
|
|||||||
id: 'zhang-ming',
|
id: 'zhang-ming',
|
||||||
name: '张明',
|
name: '张明',
|
||||||
project: '银城花园 / 住宅',
|
project: '银城花园 / 住宅',
|
||||||
mode: '文本演示,语音待接入',
|
mode: '文本面试',
|
||||||
experience: '3 年客服与案场接待',
|
experience: '3 年客服与案场接待',
|
||||||
risk: '催缴冲突经验不足',
|
risk: '催缴冲突经验不足',
|
||||||
job: {
|
job: {
|
||||||
@@ -280,7 +280,7 @@ const candidates: CandidateSeed[] = [
|
|||||||
id: 'li-na',
|
id: 'li-na',
|
||||||
name: '李娜',
|
name: '李娜',
|
||||||
project: '铂悦府 / 高端住宅',
|
project: '铂悦府 / 高端住宅',
|
||||||
mode: '文本演示,语音待接入',
|
mode: '文本面试',
|
||||||
experience: '2 年呼叫中心经验',
|
experience: '2 年呼叫中心经验',
|
||||||
risk: '现场协调经验偏弱',
|
risk: '现场协调经验偏弱',
|
||||||
job: {
|
job: {
|
||||||
@@ -362,13 +362,14 @@ const scored = ref(false);
|
|||||||
const showRubric = ref(false);
|
const showRubric = ref(false);
|
||||||
const activeQuestionId = ref('');
|
const activeQuestionId = ref('');
|
||||||
const sessionId = 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 apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
|
||||||
const answers = reactive<Record<string, string>>({});
|
const answers = reactive<Record<string, string>>({});
|
||||||
const records = ref([...baseRecords]);
|
const records = ref([...baseRecords]);
|
||||||
|
|
||||||
const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]);
|
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 activeQuestion = computed(() => questions.value.find((question) => question.id === activeQuestionId.value) ?? questions.value[0]);
|
||||||
const scoreResult = computed(() => apiScoreResult.value ?? selectedCandidate.value.scoreResult);
|
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 canScore = computed(() => generated.value && answeredCount.value === questions.value.length);
|
||||||
const apiPill = computed(() => {
|
const apiPill = computed(() => {
|
||||||
if (apiMode.value === 'api') {
|
if (apiMode.value === 'api') {
|
||||||
return { text: '后端 API seed', className: 'pill-info' };
|
return { text: '真实 API', className: 'pill-info' };
|
||||||
}
|
}
|
||||||
if (apiMode.value === 'fallback') {
|
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(() => {
|
const flowPill = computed(() => {
|
||||||
if (!generated.value) {
|
if (!generated.value) {
|
||||||
@@ -425,7 +426,8 @@ const resetFlow = () => {
|
|||||||
showRubric.value = false;
|
showRubric.value = false;
|
||||||
activeQuestionId.value = '';
|
activeQuestionId.value = '';
|
||||||
sessionId.value = '';
|
sessionId.value = '';
|
||||||
apiMode.value = 'seed';
|
apiMode.value = 'idle';
|
||||||
|
apiQuestions.value = [];
|
||||||
apiScoreResult.value = null;
|
apiScoreResult.value = null;
|
||||||
resetAnswers();
|
resetAnswers();
|
||||||
records.value = [...baseRecords];
|
records.value = [...baseRecords];
|
||||||
@@ -434,23 +436,33 @@ const resetFlow = () => {
|
|||||||
const generateQuestions = async () => {
|
const generateQuestions = async () => {
|
||||||
resetAnswers();
|
resetAnswers();
|
||||||
scored.value = false;
|
scored.value = false;
|
||||||
|
apiQuestions.value = [];
|
||||||
apiScoreResult.value = null;
|
apiScoreResult.value = null;
|
||||||
records.value = [...baseRecords];
|
records.value = [...baseRecords];
|
||||||
try {
|
try {
|
||||||
const { data } = await startInterview({
|
const { data } = await startInterview({
|
||||||
candidateId: selectedCandidate.value.id,
|
candidateId: selectedCandidate.value.id,
|
||||||
positionCode: selectedCandidate.value.job.title,
|
positionCode: selectedCandidate.value.job.title,
|
||||||
mode: 'seed'
|
mode: 'real'
|
||||||
});
|
});
|
||||||
sessionId.value = data.sessionId;
|
sessionId.value = data.sessionId;
|
||||||
apiMode.value = 'api';
|
apiMode.value = 'api';
|
||||||
activeQuestionId.value = data.questions?.[0]?.questionId ?? questions.value[0].id;
|
apiQuestions.value = (data.questions || []).map((question) => ({
|
||||||
ElMessage.success(`已通过后端 API 生成 ${questions.value.length} 道 seed 面试题`);
|
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 {
|
} catch {
|
||||||
sessionId.value = '';
|
sessionId.value = '';
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'fallback';
|
||||||
|
apiQuestions.value = [];
|
||||||
activeQuestionId.value = questions.value[0].id;
|
activeQuestionId.value = questions.value[0].id;
|
||||||
ElMessage.warning('后端 API 不可用,已回退本地 seed 题库');
|
ElMessage.warning('后端 API 不可用,已使用本地 Rubric 题库');
|
||||||
}
|
}
|
||||||
generated.value = true;
|
generated.value = true;
|
||||||
};
|
};
|
||||||
@@ -463,7 +475,7 @@ const fillAllSampleAnswers = () => {
|
|||||||
questions.value.forEach((question) => {
|
questions.value.forEach((question) => {
|
||||||
answers[question.id] = question.sampleAnswer;
|
answers[question.id] = question.sampleAnswer;
|
||||||
});
|
});
|
||||||
ElMessage.success('已填入全部 seed 样例回答');
|
ElMessage.success('已填入全部参考回答');
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveQuestion = (offset: number) => {
|
const moveQuestion = (offset: number) => {
|
||||||
@@ -485,11 +497,11 @@ const saveDraft = async () => {
|
|||||||
questionId: activeQuestion.value.id,
|
questionId: activeQuestion.value.id,
|
||||||
answerText
|
answerText
|
||||||
});
|
});
|
||||||
ElMessage.success('回答已提交后端 seed API');
|
ElMessage.success('回答已提交后端 API');
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'fallback';
|
||||||
ElMessage.warning('后端暂存 API 不可用,已回退本地草稿');
|
ElMessage.warning('后端暂存 API 不可用,已保留本地草稿');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ElMessage.success('草稿已暂存到本地演示状态');
|
ElMessage.success('草稿已暂存到本地演示状态');
|
||||||
@@ -519,14 +531,14 @@ const finishScore = async () => {
|
|||||||
};
|
};
|
||||||
if (apiMode.value === 'api' && sessionId.value) {
|
if (apiMode.value === 'api' && sessionId.value) {
|
||||||
try {
|
try {
|
||||||
const { data } = await finishInterview({ sessionId: sessionId.value });
|
const { data } = await finishInterview({ sessionId: sessionId.value, answers: { ...answers } });
|
||||||
apiScoreResult.value = mapApiScoreResult(data);
|
apiScoreResult.value = mapApiScoreResult(data);
|
||||||
nextRecord = data.records?.[0] ?? nextRecord;
|
nextRecord = data.records?.[0] ?? nextRecord;
|
||||||
ElMessage.success('AI 面试评分已由后端 API 返回');
|
ElMessage.success(data.source === 'real-llm' ? 'AI 面试评分已由真实模型返回' : 'AI 面试评分已由本地 Rubric 返回');
|
||||||
} catch {
|
} catch {
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'fallback';
|
||||||
apiScoreResult.value = null;
|
apiScoreResult.value = null;
|
||||||
ElMessage.warning('后端评分 API 不可用,已回退本地 seed 评分');
|
ElMessage.warning('后端评分 API 不可用,已回退本地 Rubric 评分');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
apiScoreResult.value = null;
|
apiScoreResult.value = null;
|
||||||
@@ -534,7 +546,7 @@ const finishScore = async () => {
|
|||||||
scored.value = true;
|
scored.value = true;
|
||||||
records.value = [nextRecord, ...baseRecords];
|
records.value = [nextRecord, ...baseRecords];
|
||||||
if (apiMode.value !== 'api') {
|
if (apiMode.value !== 'api') {
|
||||||
ElMessage.success('AI 面试评分已生成,并写入本地演示记录');
|
ElMessage.success('AI 面试评分已生成,并写入本地记录');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
<div class="reply-box">
|
<div class="reply-box">
|
||||||
<div class="reply-head">
|
<div class="reply-head">
|
||||||
<span>学员下一句</span>
|
<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>
|
</div>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="draftReply"
|
v-model="draftReply"
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
:rows="5"
|
:rows="5"
|
||||||
resize="none"
|
resize="none"
|
||||||
:disabled="finished"
|
:disabled="finished"
|
||||||
placeholder="输入学员回复,或点击“填入 seed 回复”推进演示"
|
placeholder="输入学员回复,或点击“填入参考回复”推进测试"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ const showGoal = ref(false);
|
|||||||
const activeRound = ref(0);
|
const activeRound = ref(0);
|
||||||
const draftReply = ref('');
|
const draftReply = ref('');
|
||||||
const sessionId = 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 apiScores = ref<ScenarioSeed['scores'] | null>(null);
|
||||||
const apiTotal = ref<number | null>(null);
|
const apiTotal = ref<number | null>(null);
|
||||||
const apiRewrite = ref('');
|
const apiRewrite = ref('');
|
||||||
@@ -306,12 +306,12 @@ const trustScore = computed(() => {
|
|||||||
});
|
});
|
||||||
const apiPill = computed(() => {
|
const apiPill = computed(() => {
|
||||||
if (apiMode.value === 'api') {
|
if (apiMode.value === 'api') {
|
||||||
return { text: '后端 API seed', className: 'pill-info' };
|
return { text: '真实 API', className: 'pill-info' };
|
||||||
}
|
}
|
||||||
if (apiMode.value === 'fallback') {
|
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(() => {
|
const flowPill = computed(() => {
|
||||||
if (!started.value) {
|
if (!started.value) {
|
||||||
@@ -354,7 +354,7 @@ const resetPractice = () => {
|
|||||||
activeRound.value = 0;
|
activeRound.value = 0;
|
||||||
draftReply.value = '';
|
draftReply.value = '';
|
||||||
sessionId.value = '';
|
sessionId.value = '';
|
||||||
apiMode.value = 'seed';
|
apiMode.value = 'local';
|
||||||
apiScores.value = null;
|
apiScores.value = null;
|
||||||
apiTotal.value = null;
|
apiTotal.value = null;
|
||||||
apiRewrite.value = '';
|
apiRewrite.value = '';
|
||||||
@@ -371,23 +371,23 @@ const startPractice = async () => {
|
|||||||
const { data } = await startPracticeSession({
|
const { data } = await startPracticeSession({
|
||||||
extPartyId: selectedScenario.value.trainee,
|
extPartyId: selectedScenario.value.trainee,
|
||||||
scenarioId: selectedScenario.value.id,
|
scenarioId: selectedScenario.value.id,
|
||||||
mode: 'seed'
|
mode: 'real'
|
||||||
});
|
});
|
||||||
sessionId.value = data.sessionId;
|
sessionId.value = data.sessionId;
|
||||||
apiMode.value = 'api';
|
apiMode.value = 'api';
|
||||||
apiTrust.value = data.trust;
|
apiTrust.value = data.trust;
|
||||||
appendTurn('customer', data.customerText || fallbackCustomer);
|
appendTurn('customer', data.customerText || fallbackCustomer);
|
||||||
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} seed 对练`);
|
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
|
||||||
} catch {
|
} catch {
|
||||||
sessionId.value = '';
|
sessionId.value = '';
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'fallback';
|
||||||
appendTurn('customer', fallbackCustomer);
|
appendTurn('customer', fallbackCustomer);
|
||||||
ElMessage.warning('后端对练 API 不可用,已回退本地 seed 对练');
|
ElMessage.warning('后端对练 API 不可用,已回退本地剧本');
|
||||||
}
|
}
|
||||||
started.value = true;
|
started.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fillSeedReply = () => {
|
const fillReferenceReply = () => {
|
||||||
draftReply.value = selectedScenario.value.rounds[activeRound.value].sampleReply;
|
draftReply.value = selectedScenario.value.rounds[activeRound.value].sampleReply;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ const continueRound = async () => {
|
|||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
apiMode.value = 'fallback';
|
apiMode.value = 'fallback';
|
||||||
ElMessage.warning('后端回合 API 不可用,已回退本地 seed 推进');
|
ElMessage.warning('后端回合 API 不可用,已回退本地剧本推进');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ const finishScore = async () => {
|
|||||||
apiTotal.value = null;
|
apiTotal.value = null;
|
||||||
apiRewrite.value = '';
|
apiRewrite.value = '';
|
||||||
apiSummary.value = '';
|
apiSummary.value = '';
|
||||||
ElMessage.warning('后端评分 API 不可用,已回退本地 seed 评分');
|
ElMessage.warning('后端评分 API 不可用,已回退本地评分');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
apiScores.value = null;
|
apiScores.value = null;
|
||||||
|
|||||||
+721
-9
@@ -159,7 +159,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Card title="今日任务" action="查看全部" @action="tap('查看全部')">
|
<Card title="今日任务" action="查看全部" @action="openUserAction('tasks')">
|
||||||
<div class="task-card">
|
<div class="task-card">
|
||||||
<div class="task-copy">
|
<div class="task-copy">
|
||||||
<strong>{{ workerRole.primary.badge }}</strong>
|
<strong>{{ workerRole.primary.badge }}</strong>
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
<button type="button" @click="handlePrimaryAction">{{ workerRole.primary.cta }}</button>
|
<button type="button" @click="handlePrimaryAction">{{ workerRole.primary.cta }}</button>
|
||||||
</div>
|
</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" />
|
<IconBubble icon="document" tone="amber" />
|
||||||
<span>
|
<span>
|
||||||
<b>{{ workerRole.review.title }}</b>
|
<b>{{ workerRole.review.title }}</b>
|
||||||
@@ -223,10 +223,201 @@
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card :title="workerRole.toolTitle">
|
<Card :title="workerRole.toolTitle">
|
||||||
<ToolGrid :items="workerRole.tools" />
|
<ToolGrid :items="workerRole.tools" :handler="openUserTool" />
|
||||||
</Card>
|
</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">
|
<div class="progress-grid">
|
||||||
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
|
<ProgressTile v-for="item in workerRole.metrics" :key="item.title" :item="item" />
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +446,7 @@
|
|||||||
|
|
||||||
<div v-if="practiceHistory.length" class="record-list">
|
<div v-if="practiceHistory.length" class="record-list">
|
||||||
<h3>训练历史</h3>
|
<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>
|
<span>
|
||||||
<b>{{ item.scene }} · {{ item.score }}分</b>
|
<b>{{ item.scene }} · {{ item.score }}分</b>
|
||||||
<small>{{ item.time }} · {{ item.summary }}</small>
|
<small>{{ item.time }} · {{ item.summary }}</small>
|
||||||
@@ -267,7 +458,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<nav class="tabbar" aria-label="底部导航">
|
<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" />
|
<Icon :name="tab.icon" size="28" />
|
||||||
<span>{{ tab.label }}</span>
|
<span>{{ tab.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -282,6 +473,7 @@ import { computed, defineComponent, h, ref, watch } from 'vue';
|
|||||||
|
|
||||||
type RoleKey = 'user' | 'candidate' | 'supervisor';
|
type RoleKey = 'user' | 'candidate' | 'supervisor';
|
||||||
type Tone = 'teal' | 'blue' | 'orange' | 'purple' | 'amber';
|
type Tone = 'teal' | 'blue' | 'orange' | 'purple' | 'amber';
|
||||||
|
type UserPanelKey = '' | 'tasks' | 'sop' | 'cases' | 'submit' | 'profile' | 'history' | 'recommend';
|
||||||
type IconName =
|
type IconName =
|
||||||
| 'bars'
|
| 'bars'
|
||||||
| 'book'
|
| 'book'
|
||||||
@@ -453,6 +645,85 @@ interface CompetencyProfile {
|
|||||||
dimensions: CompetencyDimension[];
|
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> = {
|
const aliases: Record<string, RoleKey> = {
|
||||||
h5: 'user',
|
h5: 'user',
|
||||||
user: 'user',
|
user: 'user',
|
||||||
@@ -499,9 +770,20 @@ const practiceMessage = ref('');
|
|||||||
const practiceHistory = ref<PracticeRecord[]>([]);
|
const practiceHistory = ref<PracticeRecord[]>([]);
|
||||||
const practiceReviews = ref<PracticeRecord[]>([]);
|
const practiceReviews = ref<PracticeRecord[]>([]);
|
||||||
const selectedReview = ref<PracticeReviewDetail | null>(null);
|
const selectedReview = ref<PracticeReviewDetail | null>(null);
|
||||||
|
const userReviewDetail = ref<PracticeReviewDetail | null>(null);
|
||||||
|
const userReviewMessage = ref('');
|
||||||
const reviewMessage = ref('');
|
const reviewMessage = ref('');
|
||||||
const markingReviewed = ref(false);
|
const markingReviewed = ref(false);
|
||||||
const competencyProfile = ref<CompetencyProfile | null>(null);
|
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 voiceEnabled = ref(true);
|
||||||
const recording = ref(false);
|
const recording = ref(false);
|
||||||
const asrBusy = ref(false);
|
const asrBusy = ref(false);
|
||||||
@@ -519,6 +801,19 @@ const cycleRole = () => {
|
|||||||
syncPath();
|
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> {
|
interface ApiPayload<T> {
|
||||||
code?: number;
|
code?: number;
|
||||||
msg?: string;
|
msg?: string;
|
||||||
@@ -562,7 +857,7 @@ const playCustomerVoice = async (text: string) => {
|
|||||||
);
|
);
|
||||||
customerAudio?.pause();
|
customerAudio?.pause();
|
||||||
customerAudio = new Audio(data.audioUrl);
|
customerAudio = new Audio(data.audioUrl);
|
||||||
void customerAudio.play();
|
void customerAudio.play().catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
// TTS 未配置或失败时静默降级为纯文本
|
// TTS 未配置或失败时静默降级为纯文本
|
||||||
}
|
}
|
||||||
@@ -622,6 +917,186 @@ const handlePrimaryAction = () => {
|
|||||||
void startMobilePractice();
|
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 appendPracticeTurn = (role: PracticeRole, text: string) => {
|
||||||
const labels: Record<PracticeRole, string> = {
|
const labels: Record<PracticeRole, string> = {
|
||||||
customer: 'AI业主',
|
customer: 'AI业主',
|
||||||
@@ -979,6 +1454,7 @@ const fetchPracticeData = async () => {
|
|||||||
practiceHistory.value = [];
|
practiceHistory.value = [];
|
||||||
practiceReviews.value = [];
|
practiceReviews.value = [];
|
||||||
selectedReview.value = null;
|
selectedReview.value = null;
|
||||||
|
userReviewDetail.value = null;
|
||||||
competencyProfile.value = null;
|
competencyProfile.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1481,7 @@ const fetchPracticeData = async () => {
|
|||||||
practiceHistory.value = [];
|
practiceHistory.value = [];
|
||||||
practiceReviews.value = [];
|
practiceReviews.value = [];
|
||||||
selectedReview.value = null;
|
selectedReview.value = null;
|
||||||
|
userReviewDetail.value = null;
|
||||||
competencyProfile.value = null;
|
competencyProfile.value = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1014,6 +1491,11 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
apiHome.value = null;
|
apiHome.value = null;
|
||||||
reviewMessage.value = '';
|
reviewMessage.value = '';
|
||||||
|
userReviewMessage.value = '';
|
||||||
|
if (roleKey.value !== 'user') {
|
||||||
|
activeUserPanel.value = '';
|
||||||
|
userReviewDetail.value = null;
|
||||||
|
}
|
||||||
void fetchMobileHome();
|
void fetchMobileHome();
|
||||||
void fetchPracticeData();
|
void fetchPracticeData();
|
||||||
},
|
},
|
||||||
@@ -1211,7 +1693,8 @@ const IconBubble = defineComponent({
|
|||||||
|
|
||||||
const ToolGrid = defineComponent({
|
const ToolGrid = defineComponent({
|
||||||
props: {
|
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) {
|
setup(props) {
|
||||||
return () =>
|
return () =>
|
||||||
@@ -1219,7 +1702,7 @@ const ToolGrid = defineComponent({
|
|||||||
'div',
|
'div',
|
||||||
{ class: 'tool-grid' },
|
{ class: 'tool-grid' },
|
||||||
props.items.map((item) =>
|
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(IconBubble, { icon: item.icon, tone: item.tone }),
|
||||||
h('span', [h('b', item.title), h('small', item.desc)]),
|
h('span', [h('b', item.title), h('small', item.desc)]),
|
||||||
h(Icon, { name: 'chevron-right', size: 24 })
|
h(Icon, { name: 'chevron-right', size: 24 })
|
||||||
@@ -1852,6 +2335,235 @@ p {
|
|||||||
font-weight: 800;
|
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 {
|
.tool-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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 mobile/package.json "aihr-mobile"
|
||||||
contains frontend/src/views/recruit/interview.vue "建议复试"
|
contains frontend/src/views/recruit/interview.vue "建议复试"
|
||||||
contains frontend/src/views/train/practice.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/knowledge/sop.vue "训练题已生成"
|
||||||
contains frontend/src/views/system/model/index.vue "调用优先级"
|
contains frontend/src/views/system/model/index.vue "调用优先级"
|
||||||
contains frontend/src/api/aihr/interview.ts "/api/recruit/interview/start"
|
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/controller/AihrMobileController.java "/api/aihr/mobile"
|
||||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "candidateHome"
|
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 interview API markers"
|
||||||
echo "OK: AI practice API markers"
|
echo "OK: AI practice API markers"
|
||||||
echo "OK: AI case API markers"
|
echo "OK: AI case API markers"
|
||||||
|
|||||||
Reference in New Issue
Block a user