feat(aihr): close phase two practice pilot gates
This commit is contained in:
@@ -55,7 +55,7 @@
|
||||
- 资料处理:`frontend/src/views/knowledge/processing.vue`,路由 `/knowledge/processing`。
|
||||
- 模型配置:`frontend/src/views/system/model/index.vue`,路由 `/system/model`。
|
||||
- MVP 阶段侧栏只保留“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置”。过滤逻辑在 `frontend/src/layout/components/Sidebar/index.vue`,不要在未明确要求时恢复若依默认全量菜单。
|
||||
- 移动端三端首页:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,分别对应用户/员工端、候选人端、主管端;兼容 `/h5/employee`、`/h5/admin`,但移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换(isHomeTab + activeUserPanel/activeSupervisorPanel 条件),**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 全屏层(sopPanel)调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
|
||||
- 移动端三端首页:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,分别对应用户/员工端、候选人端、主管端;兼容 `/h5/employee`、`/h5/admin`,但移动端不要把主管端等同后台系统管理员。员工端首页是「师傅区→今日安排→我的地图→我的成长摘要」(视觉基准 `docs/feedback/2026070423/design/` + REVIEW.md 修正项,修正项优先于图面);员工端训练列表页是 `/h5/user/training`,首屏必须先给「场景模拟/开始练习/最近练过」,每日三题后置;点击「开始练习」进入独立 `/h5/user/practice` 对话页,不能在训练列表里用锚点或页内展开。师傅四状态(idle/listening/thinking/speaking)只从 `sopRecording/sopLoading/sopSpeaking` 推导,**不要新造状态源**;员工端底部 tab 钉死「今日/练/问/我」(visibleTabs 强制,不跟随后端 home 旧文案),整页互斥切换(isHomeTab + activeUserPanel/activeSupervisorPanel 条件),**不要把面板改回插入首页长页面的叠加渲染**;未实现入口进「建设中」整页(openBuildingPage),不留无反应按钮。查 SOP 全屏层(sopPanel)调 `/api/knowledge/search`(category='sop' 全库),关闭后回到进入前 tab;「生成总结卡」按需调 `/api/knowledge/summary-card`,失败明确报错可重试,不兜假数据。
|
||||
- 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。
|
||||
- 移动端手机号登录:验证码发送走 `GET /resource/sms/code?phonenumber=...`,登录走 `POST /auth/mobile/sms-login`;短信发送复用 `sms.blends.config1` 阿里云配置。手机号不存在时自动注册 `app_user`,备注为“移动端短信自动注册”。模板 ID 用 `AIHR_SMS_LOGIN_TEMPLATE_ID`,AccessKey/Secret/签名用 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET`、`ALIYUN_SMS_SIGN_NAME`,本地放 `.env.local`。dev 兜底:`aihr.sms.dev-fixed-code` 非空则验证码固定(dev 默认 `123456`)、不真发短信;prod profile 代码级强制失效,不要移除该闸门。
|
||||
- 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。
|
||||
- 已验证:租户列表、验证码、`000000 / admin / admin123` 加密登录链路。
|
||||
- 管理端 MVP:首页与五个关键页面已落地,路由为 `/index`、`/recruit/interview`、`/train/practice`、`/knowledge/cases`、`/knowledge/sop`、`/knowledge/processing`。
|
||||
- 移动端 MVP:三端首页已拆到独立工程 `mobile/`;路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,并兼容 `/h5/employee`、`/h5/admin`;已接手机号短信登录、`GET /api/aihr/mobile/home/{role}` 首页 API。员工端首页为「数字师傅」形态(师傅头像四状态+按住说话/打字问+快捷chips→今日安排→我的地图四色块→成长摘要,视觉基准 `docs/feedback/2026070423/design/`),底部 tab 今日/练/问/我整页切换;“开始训练”可完成两轮三角色对练,记录落 `aihr_practice_session` 并刷新主管端待复盘列表、复盘详情、标记已复盘状态和员工能力画像;「查 SOP」文本/语音提问返回结构化概要+引用片段,可按需生成信息图总结卡(听一遍/开始跟读);候选人端“开始面试/面试练习”复用 AI 面试 API 拉题、作答、评分,“补充资料”走 OSS + `aihr_candidate_material` 入库,HR 可在 AI 面试页审核通过/驳回;前端保留本地 fallback。
|
||||
- 移动端 MVP:三端首页已拆到独立工程 `mobile/`;路由为 `/h5/user`、`/h5/candidate`、`/h5/supervisor`,并兼容 `/h5/employee`、`/h5/admin`;员工端另有 `/h5/user/training` 训练列表页和 `/h5/user/practice` 对练页;已接手机号短信登录、`GET /api/aihr/mobile/home/{role}` 首页 API。员工端首页为「数字师傅」形态(师傅头像四状态+按住说话/打字问+快捷chips→今日安排→我的地图四色块→成长摘要,视觉基准 `docs/feedback/2026070423/design/`),底部 tab 今日/练/问/我整页切换;“练”页首屏展示场景模拟、开始练习和最近练过,每日三题在下方;“开始练习”进入独立对练页并完成两轮三角色对练,记录落 `aihr_practice_session` 并刷新主管端待复盘列表、复盘详情、标记已复盘状态和员工能力画像;「查 SOP」文本/语音提问返回结构化概要+引用片段,可按需生成信息图总结卡(听一遍/开始跟读);候选人端“开始面试/面试练习”复用 AI 面试 API 拉题、作答、评分,“补充资料”走 OSS + `aihr_candidate_material` 入库,HR 可在 AI 面试页审核通过/驳回;前端保留本地 fallback。
|
||||
- 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述);模型配置页可查看向量库维度/点数并一键重建。
|
||||
- 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;页面支持多文件/浏览器目录选择,也支持从后端 `AIHR_IMPORT_ROOT` 下启动后台目录导入任务、轮询进度并取消运行中任务。
|
||||
- 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;配置开放组织同步系统后,通过 `POST /api/aihr/org/sync` 从 `/open/v1/sync/snapshot` 拉取公司、部门、员工快照并刷新本地组织人员口径。
|
||||
|
||||
+80
-6
@@ -2,15 +2,27 @@ package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.service.AihrMobileSeedService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -22,7 +34,6 @@ import java.util.Map;
|
||||
/**
|
||||
* Mobile H5 home APIs.
|
||||
*/
|
||||
@SaIgnore
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/aihr/mobile")
|
||||
@@ -30,6 +41,7 @@ public class AihrMobileController {
|
||||
|
||||
private final AihrMobileSeedService mobileSeedService;
|
||||
|
||||
@SaIgnore
|
||||
@GetMapping("/home/{role}")
|
||||
public R<HomeResponse> home(@PathVariable String role) {
|
||||
return R.ok(mobileSeedService.home(role));
|
||||
@@ -37,7 +49,7 @@ public class AihrMobileController {
|
||||
|
||||
@GetMapping("/practice/history")
|
||||
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.practiceHistory(extPartyId));
|
||||
return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@GetMapping("/practice/reviews")
|
||||
@@ -52,13 +64,61 @@ public class AihrMobileController {
|
||||
}
|
||||
|
||||
@PostMapping("/practice/reviews/{id}/reviewed")
|
||||
public R<Void> markReviewed(@PathVariable Long id) {
|
||||
return mobileSeedService.markReviewed(id) ? R.ok() : R.fail("复盘记录不存在");
|
||||
public R<Void> markReviewed(@PathVariable Long id, @RequestBody(required = false) ReviewAdviceRequest request) {
|
||||
return mobileSeedService.markReviewed(id, request) ? R.ok() : R.fail("复盘记录不存在");
|
||||
}
|
||||
|
||||
@GetMapping("/profile")
|
||||
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.profile(extPartyId));
|
||||
return R.ok(mobileSeedService.profile(ownMobileExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@GetMapping("/profile/promotion-evidence")
|
||||
public R<PromotionEvidenceResponse> promotionEvidence(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.promotionEvidence(ownMobileExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@GetMapping("/practice/alerts")
|
||||
public R<List<PracticeAlertResponse>> practiceAlerts() {
|
||||
return R.ok(mobileSeedService.practiceAlerts());
|
||||
}
|
||||
|
||||
@GetMapping("/practice/assignments")
|
||||
public R<List<PracticeAssignmentResponse>> practiceAssignments(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@PostMapping("/practice/assignments")
|
||||
public R<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
|
||||
return R.ok(mobileSeedService.createPracticeAssignment(request));
|
||||
}
|
||||
|
||||
@PostMapping("/practice/assignments/{id}/answer")
|
||||
public R<DailyDrillAnswerResponse> answerDailyDrill(@PathVariable Long id, @RequestBody(required = false) DailyDrillAnswerRequest request) {
|
||||
DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null));
|
||||
return response == null ? R.fail("每日三题不存在") : R.ok(response);
|
||||
}
|
||||
|
||||
private static String ownMobileExtPartyId(String requested) {
|
||||
String username = currentAppUsername();
|
||||
return username.isBlank() ? requested : username;
|
||||
}
|
||||
|
||||
private static String scopedAssignmentExtPartyId(String requested) {
|
||||
if (requested == null || requested.isBlank()) {
|
||||
// ponytail: supervisor H5 keeps team-scope listing until mobile roles exist.
|
||||
return requested;
|
||||
}
|
||||
return ownMobileExtPartyId(requested);
|
||||
}
|
||||
|
||||
private static String currentAppUsername() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return "";
|
||||
}
|
||||
String username = loginUser.getUsername();
|
||||
return username == null ? "" : username.trim();
|
||||
}
|
||||
|
||||
private static Map<String, Object> reviewDetailPayload(ReviewDetailResponse detail) {
|
||||
@@ -73,6 +133,8 @@ public class AihrMobileController {
|
||||
data.put("summary", detail.summary());
|
||||
data.put("mentorRewrite", detail.mentorRewrite());
|
||||
data.put("aiComment", detail.aiComment());
|
||||
data.put("reviewAdvice", detail.reviewAdvice());
|
||||
data.put("incentivePoint", detail.incentivePoint());
|
||||
data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of(
|
||||
"label", item.label(),
|
||||
"value", item.value(),
|
||||
@@ -81,8 +143,20 @@ public class AihrMobileController {
|
||||
data.put("dialogue", detail.dialogue().stream().map(turn -> Map.of(
|
||||
"role", turn.role(),
|
||||
"label", turn.label(),
|
||||
"text", turn.text()
|
||||
"text", turn.text(),
|
||||
"audioUrl", turn.audioUrl()
|
||||
)).toList());
|
||||
data.put("annotations", detail.annotations().stream().map(AihrMobileController::annotationPayload).toList());
|
||||
return data;
|
||||
}
|
||||
|
||||
private static Map<String, Object> annotationPayload(AnnotationResponse annotation) {
|
||||
return Map.of(
|
||||
"turnIndex", annotation.turnIndex(),
|
||||
"sourceText", annotation.sourceText(),
|
||||
"issue", annotation.issue(),
|
||||
"rewrite", annotation.rewrite(),
|
||||
"sopPoint", annotation.sopPoint()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+102
-1
@@ -1,19 +1,43 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
|
||||
import org.dromara.aihr.service.AihrPracticeSeedService;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 三角色对练 API。
|
||||
*/
|
||||
@@ -22,11 +46,55 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/api/train/practice")
|
||||
public class AihrPracticeController {
|
||||
|
||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||
|
||||
private final AihrPracticeSeedService practiceSeedService;
|
||||
|
||||
@GetMapping("/scenarios")
|
||||
public R<List<ScenarioResponse>> scenarios(@RequestParam(required = false) String position,
|
||||
@RequestParam(required = false) String scenarioType) {
|
||||
return R.ok(practiceSeedService.scenarios(position, scenarioType));
|
||||
}
|
||||
|
||||
@GetMapping("/scenarios/{id}")
|
||||
public R<ScenarioResponse> scenario(@PathVariable String id) {
|
||||
return R.ok(practiceSeedService.scenario(id));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping("/scenarios")
|
||||
public R<ScenarioResponse> saveScenario(@RequestBody ScenarioSaveRequest request) {
|
||||
return R.ok(practiceSeedService.saveScenario(request));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PatchMapping("/scenarios/{id}/enabled")
|
||||
public R<Void> updateScenarioEnabled(@PathVariable String id, @RequestBody ScenarioEnabledRequest request) {
|
||||
return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在");
|
||||
}
|
||||
|
||||
@GetMapping("/scenarios/{id}/prep-card")
|
||||
public R<PrepCardResponse> prepCard(@PathVariable String id) {
|
||||
return R.ok(practiceSeedService.prepCard(id));
|
||||
}
|
||||
|
||||
@GetMapping("/rubrics")
|
||||
public R<List<RubricResponse>> rubrics(@RequestParam(required = false) String scenarioId) {
|
||||
return R.ok(practiceSeedService.rubrics(scenarioId));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/export")
|
||||
public ResponseEntity<String> export() {
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=aihr-practice-pilot.csv")
|
||||
.contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
|
||||
.body(practiceSeedService.exportPilotCsv());
|
||||
}
|
||||
|
||||
@PostMapping("/start")
|
||||
public R<StartResponse> start(@RequestBody StartRequest request) {
|
||||
return R.ok(practiceSeedService.start(request));
|
||||
return R.ok(practiceSeedService.start(bindMobileStartIdentity(request)));
|
||||
}
|
||||
|
||||
@PostMapping("/turn")
|
||||
@@ -38,4 +106,37 @@ public class AihrPracticeController {
|
||||
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
||||
return R.ok(practiceSeedService.finish(request));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/annotations")
|
||||
public R<List<AnnotationResponse>> annotations(@PathVariable String sessionId, @RequestBody(required = false) AnnotationSaveRequest request) {
|
||||
List<AnnotationResponse> annotations = practiceSeedService.saveAnnotations(sessionId, request);
|
||||
return annotations.isEmpty() ? R.fail("训练会话不存在") : R.ok(annotations);
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping("/sessions/{sessionId}/calibration")
|
||||
public R<CalibrationResponse> calibration(@PathVariable String sessionId, @RequestBody(required = false) CalibrationRequest request) {
|
||||
CalibrationResponse response = practiceSeedService.calibrate(sessionId, request);
|
||||
return response == null ? R.fail("训练会话不存在") : R.ok(response);
|
||||
}
|
||||
|
||||
private static StartRequest bindMobileStartIdentity(StartRequest request) {
|
||||
if (request == null || !"mobile".equalsIgnoreCase(request.mode())) {
|
||||
return request;
|
||||
}
|
||||
String username = currentAppUsername();
|
||||
if (username.isBlank()) {
|
||||
return request;
|
||||
}
|
||||
return new StartRequest(username, request.scenarioId(), request.mode());
|
||||
}
|
||||
|
||||
private static String currentAppUsername() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return "";
|
||||
}
|
||||
String username = loginUser.getUsername();
|
||||
return username == null ? "" : username.trim();
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
|
||||
import org.dromara.aihr.service.AihrPracticeSeedService;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI HR 提示词模板 API。
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/aihr/prompt-templates")
|
||||
public class AihrPromptTemplateController {
|
||||
|
||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||
|
||||
private final AihrPracticeSeedService practiceSeedService;
|
||||
|
||||
@GetMapping
|
||||
public R<List<PromptTemplateResponse>> list(@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String code,
|
||||
@RequestParam(required = false) Boolean enabled) {
|
||||
return R.ok(practiceSeedService.promptTemplates(category, code, enabled));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping
|
||||
public R<PromptTemplateResponse> create(@RequestBody PromptTemplateRequest request) {
|
||||
return R.ok(practiceSeedService.savePromptTemplate(request));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PutMapping("/{id}")
|
||||
public R<PromptTemplateResponse> update(@PathVariable Long id, @RequestBody PromptTemplateRequest request) {
|
||||
PromptTemplateResponse response = practiceSeedService.updatePromptTemplate(id, request);
|
||||
return response == null ? R.fail("提示词模板不存在") : R.ok(response);
|
||||
}
|
||||
}
|
||||
+8
@@ -7,6 +7,8 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
|
||||
@@ -63,6 +65,12 @@ public class AihrSopController {
|
||||
return R.ok(sopSeedService.search(request));
|
||||
}
|
||||
|
||||
@PostMapping("/reviews/{id}")
|
||||
public R<SopReviewResponse> review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) {
|
||||
SopReviewResponse response = sopSeedService.reviewSearch(id, request);
|
||||
return response == null ? R.fail("SOP问答评审记录不存在") : R.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/summary-card")
|
||||
public R<SummaryCardResponse> summaryCard(@RequestBody SummaryCardRequest request) {
|
||||
return R.ok(sopSeedService.summaryCard(request));
|
||||
|
||||
+36
-2
@@ -1,11 +1,14 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.AsrResponse;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
|
||||
import org.dromara.aihr.service.AihrSpeechService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -14,12 +17,16 @@ import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/ai")
|
||||
@@ -30,6 +37,7 @@ public class AihrSpeechController {
|
||||
private static final int MAX_TTS_CHARS = 300;
|
||||
|
||||
private final AihrSpeechService speechService;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@PostMapping(value = "/asr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<AsrResponse> asr(@RequestPart("file") MultipartFile file) throws IOException {
|
||||
@@ -43,7 +51,10 @@ public class AihrSpeechController {
|
||||
return R.fail("音频过大(上限 5MB),请缩短录音时长");
|
||||
}
|
||||
return speechService.transcribe(file.getBytes(), file.getOriginalFilename(), file.getContentType())
|
||||
.map(text -> R.ok(new AsrResponse(text, "openai-compatible")))
|
||||
.map(text -> {
|
||||
SysOssVo oss = ossService.upload(file);
|
||||
return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl()));
|
||||
})
|
||||
.orElseGet(() -> R.fail("语音识别失败,请改用文字输入"));
|
||||
}
|
||||
|
||||
@@ -60,7 +71,30 @@ public class AihrSpeechController {
|
||||
text = text.substring(0, MAX_TTS_CHARS);
|
||||
}
|
||||
return speechService.synthesize(text, request == null ? null : request.voice())
|
||||
.map(audio -> R.ok(new TtsResponse("data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio), "openai-compatible")))
|
||||
.map(this::storeTtsAudio)
|
||||
.orElseGet(() -> R.fail("语音合成失败,已降级为文本展示"));
|
||||
}
|
||||
|
||||
private R<TtsResponse> storeTtsAudio(byte[] audio) {
|
||||
String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio);
|
||||
Path tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("aihr-tts-", ".mp3");
|
||||
Files.write(tempFile, audio);
|
||||
File file = tempFile.toFile();
|
||||
SysOssVo oss = ossService.upload(file);
|
||||
return R.ok(new TtsResponse(oss.getUrl(), "openai-compatible", oss.getOssId(), inlineAudioUrl));
|
||||
} catch (Exception e) {
|
||||
log.warn("tts audio upload failed: {}", e.getMessage());
|
||||
return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible"));
|
||||
} finally {
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("delete tts temp file failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+181
-4
@@ -26,7 +26,62 @@ public final class AihrPracticeDto {
|
||||
public record PersonaResponse(String customer, String project, String strategy, String goal) {
|
||||
}
|
||||
|
||||
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl) {
|
||||
public record ScenarioResponse(
|
||||
String id,
|
||||
String name,
|
||||
String position,
|
||||
String scenarioType,
|
||||
String projectType,
|
||||
Integer difficulty,
|
||||
String customerPersona,
|
||||
String openingText,
|
||||
String explicitNeed,
|
||||
String hiddenNeed,
|
||||
String irritants,
|
||||
String delights,
|
||||
String sopRefs,
|
||||
String successCriteria,
|
||||
String strategy,
|
||||
String goal,
|
||||
Boolean enabled,
|
||||
RubricResponse rubric
|
||||
) {
|
||||
}
|
||||
|
||||
public record ScenarioSaveRequest(
|
||||
String id,
|
||||
String name,
|
||||
String position,
|
||||
String scenarioType,
|
||||
String projectType,
|
||||
Integer difficulty,
|
||||
String customerPersona,
|
||||
String openingText,
|
||||
String explicitNeed,
|
||||
String hiddenNeed,
|
||||
String irritants,
|
||||
String delights,
|
||||
String sopRefs,
|
||||
String successCriteria,
|
||||
String strategy,
|
||||
String goal,
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
|
||||
public record ScenarioEnabledRequest(Boolean enabled) {
|
||||
}
|
||||
|
||||
public record RubricResponse(String code, String name, String version, List<RubricDimensionResponse> dimensions) {
|
||||
}
|
||||
|
||||
public record RubricDimensionResponse(String code, String name, Double weight, String description) {
|
||||
}
|
||||
|
||||
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
|
||||
}
|
||||
|
||||
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId) {
|
||||
}
|
||||
|
||||
public record TurnResponse(
|
||||
@@ -35,6 +90,7 @@ public final class AihrPracticeDto {
|
||||
Integer emotion,
|
||||
Integer trust,
|
||||
String coachHint,
|
||||
Boolean redFlag,
|
||||
Integer roundIndex,
|
||||
Boolean finished
|
||||
) {
|
||||
@@ -59,7 +115,37 @@ public final class AihrPracticeDto {
|
||||
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary) {
|
||||
}
|
||||
|
||||
public record DialogueResponse(String role, String label, String text) {
|
||||
public record DialogueResponse(String role, String label, String text, String audioUrl, Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
|
||||
public DialogueResponse(String role, String label, String text) {
|
||||
this(role, label, text, "", null, null, null, "");
|
||||
}
|
||||
|
||||
public DialogueResponse(String role, String label, String text, String audioUrl) {
|
||||
this(role, label, text, audioUrl, null, null, null, "");
|
||||
}
|
||||
}
|
||||
|
||||
public record AnnotationResponse(Integer turnIndex, String sourceText, String issue, String rewrite, String sopPoint) {
|
||||
}
|
||||
|
||||
public record AnnotationSaveRequest(List<AnnotationResponse> annotations) {
|
||||
}
|
||||
|
||||
public record CalibrationRequest(
|
||||
String reviewer,
|
||||
Integer correctedTotal,
|
||||
Integer correctedCompliance,
|
||||
Integer correctedEmotion,
|
||||
Integer correctedCommunication,
|
||||
Integer correctedMarketing,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
|
||||
public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) {
|
||||
}
|
||||
|
||||
public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId, String nextReason) {
|
||||
}
|
||||
|
||||
public record ReviewDetailResponse(
|
||||
@@ -73,11 +159,102 @@ public final class AihrPracticeDto {
|
||||
String summary,
|
||||
String mentorRewrite,
|
||||
String aiComment,
|
||||
String reviewAdvice,
|
||||
Integer incentivePoint,
|
||||
List<DimensionResponse> scoreItems,
|
||||
List<DialogueResponse> dialogue
|
||||
List<DialogueResponse> dialogue,
|
||||
List<AnnotationResponse> annotations
|
||||
) {
|
||||
}
|
||||
|
||||
public record CompetencyResponse(Integer score, Integer completed, Integer pendingReview, List<DimensionResponse> dimensions) {
|
||||
public record CompetencyResponse(
|
||||
Integer score,
|
||||
Integer completed,
|
||||
Integer pendingReview,
|
||||
Integer trainingMinutes,
|
||||
Integer contribution,
|
||||
Integer assessmentScore,
|
||||
String aiLevel,
|
||||
List<DimensionResponse> dimensions,
|
||||
List<GrowthStageResponse> growthPath
|
||||
) {
|
||||
}
|
||||
|
||||
public record GrowthStageResponse(String level, Integer progress, String status, String evidence) {
|
||||
}
|
||||
|
||||
public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) {
|
||||
}
|
||||
|
||||
public record PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) {
|
||||
}
|
||||
|
||||
public record PracticeAssignmentResponse(
|
||||
Long id,
|
||||
String extPartyId,
|
||||
String traineeName,
|
||||
String scenarioId,
|
||||
String scenarioName,
|
||||
String source,
|
||||
String reason,
|
||||
String status,
|
||||
String createTime,
|
||||
String question,
|
||||
String referenceAnswer,
|
||||
String userAnswer,
|
||||
String feedback,
|
||||
Boolean correct
|
||||
) {
|
||||
}
|
||||
|
||||
public record DailyDrillAnswerRequest(String answer) {
|
||||
}
|
||||
|
||||
public record DailyDrillAnswerResponse(Long id, String status, Boolean correct, Integer score, String feedback, String referenceAnswer) {
|
||||
}
|
||||
|
||||
public record PromptTemplateRequest(
|
||||
String code,
|
||||
String name,
|
||||
String category,
|
||||
String outputFormat,
|
||||
String systemPrompt,
|
||||
String template,
|
||||
String variables,
|
||||
String version,
|
||||
Boolean enabled,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
|
||||
public record PromptTemplateResponse(
|
||||
Long id,
|
||||
String code,
|
||||
String name,
|
||||
String category,
|
||||
String outputFormat,
|
||||
String systemPrompt,
|
||||
String template,
|
||||
String variables,
|
||||
String version,
|
||||
Boolean enabled,
|
||||
String description,
|
||||
String updateTime
|
||||
) {
|
||||
}
|
||||
|
||||
public record PromotionEvidenceResponse(
|
||||
String extPartyId,
|
||||
String currentLevel,
|
||||
Integer assessmentScore,
|
||||
Integer trainingMinutes,
|
||||
Integer contribution,
|
||||
Integer completed,
|
||||
Integer pendingReview,
|
||||
List<DimensionResponse> dimensions,
|
||||
List<RecordResponse> latestRecords,
|
||||
List<PracticeAssignmentResponse> assignments,
|
||||
List<String> evidenceItems
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -7,12 +7,18 @@ public final class AihrSopDto {
|
||||
private AihrSopDto() {
|
||||
}
|
||||
|
||||
public record SearchRequest(String queryText, String category, Integer limit) {
|
||||
public record SearchRequest(String queryText, String category, String position, String source, Integer limit) {
|
||||
}
|
||||
|
||||
public record SummaryCardRequest(String queryText, String category) {
|
||||
}
|
||||
|
||||
public record SopReviewRequest(Boolean usable, String reviewer, String note) {
|
||||
}
|
||||
|
||||
public record SopReviewResponse(Long id, Boolean usable, String status, String reviewedAt) {
|
||||
}
|
||||
|
||||
public record SummaryCardResponse(String title, List<CardStep> steps, List<CardObjection> objections, List<String> scripts, List<String> reminders) {
|
||||
}
|
||||
|
||||
@@ -79,7 +85,8 @@ public final class AihrSopDto {
|
||||
List<String> training,
|
||||
List<RecordResponse> records,
|
||||
List<String> keyPoints,
|
||||
List<String> cautions
|
||||
List<String> cautions,
|
||||
Long reviewId
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -5,12 +5,18 @@ public final class AihrSpeechDto {
|
||||
private AihrSpeechDto() {
|
||||
}
|
||||
|
||||
public record AsrResponse(String text, String source) {
|
||||
public record AsrResponse(String text, String source, Long ossId, String audioUrl) {
|
||||
public AsrResponse(String text, String source) {
|
||||
this(text, source, null, "");
|
||||
}
|
||||
}
|
||||
|
||||
public record TtsRequest(String text, String voice) {
|
||||
}
|
||||
|
||||
public record TtsResponse(String audioUrl, String source) {
|
||||
public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) {
|
||||
public TtsResponse(String audioUrl, String source) {
|
||||
this(audioUrl, source, null, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -1,7 +1,14 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse;
|
||||
@@ -51,13 +58,41 @@ public class AihrMobileSeedService {
|
||||
}
|
||||
|
||||
public boolean markReviewed(Long id) {
|
||||
return practiceSeedService.markReviewed(id);
|
||||
return markReviewed(id, null);
|
||||
}
|
||||
|
||||
public boolean markReviewed(Long id, ReviewAdviceRequest request) {
|
||||
return practiceSeedService.markReviewed(id, request);
|
||||
}
|
||||
|
||||
public CompetencyResponse profile(String extPartyId) {
|
||||
return practiceSeedService.competency(extPartyId);
|
||||
}
|
||||
|
||||
public PromotionEvidenceResponse promotionEvidence(String extPartyId) {
|
||||
return practiceSeedService.promotionEvidence(extPartyId);
|
||||
}
|
||||
|
||||
public List<PracticeAlertResponse> practiceAlerts() {
|
||||
return practiceSeedService.alerts();
|
||||
}
|
||||
|
||||
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId) {
|
||||
return practiceSeedService.assignments(extPartyId, 10);
|
||||
}
|
||||
|
||||
public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) {
|
||||
return practiceSeedService.createAssignment(request);
|
||||
}
|
||||
|
||||
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) {
|
||||
return answerDailyDrill(id, request, "");
|
||||
}
|
||||
|
||||
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId) {
|
||||
return practiceSeedService.answerDailyDrill(id, request, extPartyId);
|
||||
}
|
||||
|
||||
private static String normalize(String role) {
|
||||
if (role == null) {
|
||||
return "user";
|
||||
|
||||
+37
@@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrModelDto.ConfigResponse;
|
||||
import org.dromara.aihr.domain.AihrModelDto.ProviderRequest;
|
||||
import org.dromara.aihr.domain.AihrModelDto.ProviderResponse;
|
||||
import org.dromara.aihr.domain.AihrModelDto.ProviderStatusRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -43,6 +44,15 @@ public class AihrModelSeedService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${aihr.ai-runtime.enabled:${AIHR_AI_RUNTIME_ENABLED:true}}")
|
||||
private boolean aiEnabled;
|
||||
|
||||
@Value("${aihr.ai-runtime.chat-enabled:${AIHR_AI_CHAT_ENABLED:true}}")
|
||||
private boolean chatEnabled;
|
||||
|
||||
@Value("${aihr.ai-runtime.speech-enabled:${AIHR_AI_SPEECH_ENABLED:true}}")
|
||||
private boolean speechEnabled;
|
||||
|
||||
public List<ProviderResponse> providers() {
|
||||
List<ProviderResponse> rows = dbProviders();
|
||||
return rows.isEmpty() ? seedProviders() : mergeProviders(rows);
|
||||
@@ -181,6 +191,18 @@ public class AihrModelSeedService {
|
||||
// 运行参数已就绪时以解析出的模型名为准:请求可能带的是 seed 占位模型名(如 gpt-4o-mini),不能透传给供应商
|
||||
String modelName = runtime.configured() || request == null || isBlank(request.model()) ? runtime.modelName() : request.model();
|
||||
|
||||
if (!chatAllowed()) {
|
||||
return new ChatResponse(
|
||||
false,
|
||||
runtime.providerCode(),
|
||||
modelName,
|
||||
"AI 调用已被试点成本闸门关闭,当前返回文本兜底:先安抚业主情绪,确认事实与责任人,再承诺首次反馈时间。",
|
||||
"cost-guard-fallback",
|
||||
null,
|
||||
List.of("需要恢复真实调用时设置 AIHR_AI_RUNTIME_ENABLED=true 且 AIHR_AI_CHAT_ENABLED=true。")
|
||||
);
|
||||
}
|
||||
|
||||
if (!runtime.configured()) {
|
||||
return new ChatResponse(
|
||||
false,
|
||||
@@ -216,6 +238,9 @@ public class AihrModelSeedService {
|
||||
* 按类目(asr/tts)查已启用且配置齐全的语音模型运行参数,供 AihrSpeechService 使用。
|
||||
*/
|
||||
public Optional<SpeechModel> speechModel(String category) {
|
||||
if (!speechAllowed()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
List<SpeechModel> rows = jdbcTemplate.query("""
|
||||
select c.model_name, c.provider_code,
|
||||
@@ -249,6 +274,10 @@ public class AihrModelSeedService {
|
||||
* 供其他模块(如三角色对练)复用的 chat 调用:模型未配置或调用失败返回 empty,由调用方决定兜底。
|
||||
*/
|
||||
public Optional<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
|
||||
if (!chatAllowed()) {
|
||||
// ponytail: manual cost breaker; replace with metered monthly billing guard when vendor usage data is wired.
|
||||
return Optional.empty();
|
||||
}
|
||||
RuntimeConfig runtime = runtimeConfig(null);
|
||||
if (!runtime.configured()) {
|
||||
return Optional.empty();
|
||||
@@ -261,6 +290,14 @@ public class AihrModelSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean chatAllowed() {
|
||||
return aiEnabled && chatEnabled;
|
||||
}
|
||||
|
||||
private boolean speechAllowed() {
|
||||
return aiEnabled && speechEnabled;
|
||||
}
|
||||
|
||||
private List<ProviderResponse> dbProviders() {
|
||||
try {
|
||||
return jdbcTemplate.query("""
|
||||
|
||||
+65
@@ -38,6 +38,15 @@ public class AihrPracticeLlmService {
|
||||
) {
|
||||
}
|
||||
|
||||
public record PracticeTurn(
|
||||
String customerText,
|
||||
int emotion,
|
||||
int trust,
|
||||
String coachHint,
|
||||
boolean redFlag
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。
|
||||
*/
|
||||
@@ -64,6 +73,42 @@ public class AihrPracticeLlmService {
|
||||
.filter(text -> !text.isBlank());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一句业主回应,并让模型同步给出回合情绪、信任、红线和教练提示;解析失败由调用方回退 seed。
|
||||
*/
|
||||
public Optional<PracticeTurn> customerTurn(
|
||||
String personaTraits,
|
||||
String project,
|
||||
String goal,
|
||||
List<DialogueResponse> dialogue,
|
||||
String traineeText,
|
||||
String nextBeat,
|
||||
int defaultEmotion,
|
||||
int defaultTrust,
|
||||
String defaultCoachHint
|
||||
) {
|
||||
String system = """
|
||||
你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。
|
||||
人设特征:%s
|
||||
场景:%s
|
||||
隐性诉求:%s
|
||||
规则:
|
||||
1. customerText 是业主下一句话,一句以内,不超过%d个字,口语化。
|
||||
2. 禁止在 customerText 中提及系统、人设、评分、SOP、AI、训练。
|
||||
3. emotion/trust 为 0-100 整数;员工承接越好 trust 越高,推责或对抗则 redFlag=true。
|
||||
4. coachHint 给员工下一步改进建议,不超过60字。
|
||||
5. 剧情推进方向(不要照抄原句):%s
|
||||
只输出 JSON,不要输出其他文字:
|
||||
{"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false}
|
||||
""".formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, defaultEmotion, defaultTrust);
|
||||
String user = "对话记录:\n" + renderDialogue(dialogue)
|
||||
+ "\n员工刚才说:" + traineeText
|
||||
+ "\n默认教练提示:" + defaultCoachHint
|
||||
+ "\n请输出 JSON。";
|
||||
return modelService.tryChat(system, user, CUSTOMER_TEMPERATURE)
|
||||
.flatMap(content -> parseTurn(content, defaultEmotion, defaultTrust, defaultCoachHint));
|
||||
}
|
||||
|
||||
/**
|
||||
* /finish 单 LLM 结构化评分:temperature=0 + 固定 prompt 版本 + 档位锚点 60/75/90。
|
||||
*/
|
||||
@@ -111,6 +156,26 @@ public class AihrPracticeLlmService {
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<PracticeTurn> parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(extractJson(content));
|
||||
String customerText = cleanCustomerLine(root.path("customerText").asText(""));
|
||||
if (customerText.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new PracticeTurn(
|
||||
truncateText(customerText, MAX_CUSTOMER_CHARS * 2),
|
||||
clampScore(root.path("emotion").asInt(defaultEmotion)),
|
||||
clampScore(root.path("trust").asInt(defaultTrust)),
|
||||
truncateText(root.path("coachHint").asText(defaultCoachHint), 120),
|
||||
root.path("redFlag").asBoolean(false)
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("practice turn parse failed, falls back to seed: {}", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */
|
||||
private static int dimension(JsonNode root, String field, int fallback) {
|
||||
JsonNode node = root.path(field);
|
||||
|
||||
+1765
-37
File diff suppressed because it is too large
Load Diff
+217
-5
@@ -23,6 +23,8 @@ import org.dromara.aihr.domain.AihrSopDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
@@ -98,6 +100,8 @@ public class AihrSopSeedService {
|
||||
private final String importRootConfig;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
private final AihrVideoService videoService;
|
||||
private volatile boolean knowledgeGapTableReady;
|
||||
private volatile boolean sopReviewTableReady;
|
||||
|
||||
public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService,
|
||||
@Value("${aihr.import.root:}") String importRootConfig,
|
||||
@@ -114,12 +118,15 @@ public class AihrSopSeedService {
|
||||
String requestedCategory = request == null ? "" : request.category();
|
||||
SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP"));
|
||||
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
|
||||
String position = firstNonBlank(request == null ? null : request.position(), "生活顾问");
|
||||
String source = normalizeSearchSource(request == null ? null : request.source());
|
||||
String queryText = request == null || request.queryText() == null || request.queryText().isBlank() ? seed.question() : request.queryText();
|
||||
SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit());
|
||||
if (dbResponse != null && !dbResponse.snippets().isEmpty()) {
|
||||
return dbResponse;
|
||||
return withReviewId(dbResponse, source);
|
||||
}
|
||||
return new SearchResponse(
|
||||
recordKnowledgeGap(queryText, category, position, source);
|
||||
return withReviewId(new SearchResponse(
|
||||
queryText,
|
||||
seed.category(),
|
||||
seed.answer(),
|
||||
@@ -129,8 +136,30 @@ public class AihrSopSeedService {
|
||||
seed.training(),
|
||||
records(),
|
||||
List.of(),
|
||||
List.of()
|
||||
List.of(),
|
||||
null
|
||||
), source);
|
||||
}
|
||||
|
||||
public SopReviewResponse reviewSearch(Long id, SopReviewRequest request) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
ensureSopReviewTable();
|
||||
int updated = jdbcTemplate.update("""
|
||||
UPDATE aihr_sop_answer_review
|
||||
SET usable = ?, reviewer = ?, note = ?, status = '已评审', reviewed_time = ?, update_time = ?
|
||||
WHERE tenant_id = ? AND id = ?
|
||||
""",
|
||||
request != null && Boolean.TRUE.equals(request.usable()),
|
||||
firstNonBlank(request == null ? null : request.reviewer(), "人工评审"),
|
||||
firstNonBlank(request == null ? null : request.note(), ""),
|
||||
Timestamp.valueOf(java.time.LocalDateTime.now()),
|
||||
Timestamp.valueOf(java.time.LocalDateTime.now()),
|
||||
TENANT_ID,
|
||||
id
|
||||
);
|
||||
return updated == 0 ? null : sopReview(id);
|
||||
}
|
||||
|
||||
public SummaryCardResponse summaryCard(SummaryCardRequest request) {
|
||||
@@ -1630,6 +1659,184 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private void recordKnowledgeGap(String queryText, String category, String position, String source) {
|
||||
if (isBlank(queryText)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureKnowledgeGapTable();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_knowledge_gap
|
||||
(tenant_id, query_text, category, position, source, status, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, '待补充', ?)
|
||||
""",
|
||||
TENANT_ID,
|
||||
queryText.trim(),
|
||||
firstNonBlank(category, "sop"),
|
||||
firstNonBlank(position, "生活顾问"),
|
||||
normalizeSearchSource(source),
|
||||
Timestamp.valueOf(java.time.LocalDateTime.now())
|
||||
);
|
||||
} catch (DataAccessException ignored) {
|
||||
// Gap logging is additive and must not break SOP search fallback.
|
||||
}
|
||||
}
|
||||
|
||||
private SearchResponse withReviewId(SearchResponse response, String source) {
|
||||
Long reviewId = createSopReview(response, source);
|
||||
return new SearchResponse(
|
||||
response.queryText(),
|
||||
response.category(),
|
||||
response.answer(),
|
||||
response.reference(),
|
||||
response.docs(),
|
||||
response.snippets(),
|
||||
response.training(),
|
||||
response.records(),
|
||||
response.keyPoints(),
|
||||
response.cautions(),
|
||||
reviewId
|
||||
);
|
||||
}
|
||||
|
||||
private Long createSopReview(SearchResponse response, String source) {
|
||||
try {
|
||||
ensureSopReviewTable();
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement ps = connection.prepareStatement("""
|
||||
INSERT INTO aihr_sop_answer_review
|
||||
(tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, status, create_time, update_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
|
||||
""", Statement.RETURN_GENERATED_KEYS);
|
||||
ps.setString(1, TENANT_ID);
|
||||
ps.setString(2, truncate(response.queryText(), 1000));
|
||||
ps.setString(3, firstNonBlank(response.category(), "sop"));
|
||||
ps.setString(4, truncate(response.answer(), 2000));
|
||||
ps.setString(5, truncate(response.reference(), 1000));
|
||||
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
|
||||
ps.setString(7, normalizeSearchSource(source));
|
||||
Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now());
|
||||
ps.setTimestamp(8, now);
|
||||
ps.setTimestamp(9, now);
|
||||
return ps;
|
||||
}, keyHolder);
|
||||
Number key = keyHolder.getKey();
|
||||
return key == null ? null : key.longValue();
|
||||
} catch (DataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SopReviewResponse sopReview(Long id) {
|
||||
List<SopReviewResponse> rows = jdbcTemplate.query("""
|
||||
SELECT id, usable, status, reviewed_time
|
||||
FROM aihr_sop_answer_review
|
||||
WHERE tenant_id = ? AND id = ?
|
||||
LIMIT 1
|
||||
""", (rs, rowNum) -> {
|
||||
Timestamp reviewedTime = rs.getTimestamp("reviewed_time");
|
||||
return new SopReviewResponse(
|
||||
rs.getLong("id"),
|
||||
rs.getObject("usable") == null ? null : rs.getBoolean("usable"),
|
||||
rs.getString("status"),
|
||||
reviewedTime == null ? "" : reviewedTime.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
);
|
||||
}, TENANT_ID, id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public SopReviewStats sopReviewStats() {
|
||||
ensureSopReviewTable();
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
|
||||
FROM aihr_sop_answer_review
|
||||
WHERE tenant_id = ? AND status = '已评审'
|
||||
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID);
|
||||
}
|
||||
|
||||
private void ensureSopReviewTable() {
|
||||
if (sopReviewTableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sopReviewTableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`query_text` varchar(1000) NOT NULL COMMENT '问题',
|
||||
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
|
||||
`answer_text` varchar(2000) DEFAULT NULL COMMENT '回答',
|
||||
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
|
||||
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
|
||||
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
|
||||
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
|
||||
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
|
||||
`note` varchar(500) DEFAULT NULL COMMENT '评审备注',
|
||||
`status` varchar(30) DEFAULT '待评审' COMMENT '状态',
|
||||
`reviewed_time` datetime DEFAULT NULL COMMENT '评审时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审';
|
||||
""");
|
||||
ensureColumn("aihr_sop_answer_review", "source", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源' AFTER `snippet_count`");
|
||||
sopReviewTableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureKnowledgeGapTable() {
|
||||
if (knowledgeGapTableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (knowledgeGapTableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`query_text` varchar(1000) NOT NULL COMMENT '未命中问题',
|
||||
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
|
||||
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
|
||||
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
|
||||
`status` varchar(30) DEFAULT '待补充' COMMENT '状态',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_knowledge_gap_status` (`tenant_id`, `status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识缺口日志';
|
||||
""");
|
||||
knowledgeGapTableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureColumn(String table, String column, String ddl) {
|
||||
Integer exists = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
|
||||
""", Integer.class, table, column);
|
||||
if (exists == null || exists == 0) {
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeSearchSource(String value) {
|
||||
if (isBlank(value)) {
|
||||
return "knowledge_search";
|
||||
}
|
||||
String source = value.trim().replaceAll("[^A-Za-z0-9_-]", "_");
|
||||
if (source.length() > 50) {
|
||||
source = source.substring(0, 50);
|
||||
}
|
||||
return source.isBlank() ? "knowledge_search" : source;
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
@@ -1806,7 +2013,8 @@ public class AihrSopSeedService {
|
||||
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
|
||||
records(),
|
||||
digest != null ? digest.keyPoints() : List.of(),
|
||||
digest != null ? digest.cautions() : List.of()
|
||||
digest != null ? digest.cautions() : List.of(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1901,7 +2109,8 @@ public class AihrSopSeedService {
|
||||
你是物业一线员工的SOP信息图总结助手。只返回 JSON,不要 markdown、代码块或解释。
|
||||
输出格式固定为:
|
||||
{"title":"XX总结卡","steps":[{"label":"步骤名","desc":"一句话"}],"objections":[{"concern":"异议","response":"应对"}],"scripts":["可直接开口的完整话术"],"reminders":["注意事项"]}
|
||||
字段约束:title ≤14字;steps 3-5条,label ≤6字,desc ≤20字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
|
||||
steps 必须且只允许 3 条,label 依次固定为「问题」「建议」「标准」:问题写当前要处理的核心问题;建议写下一步动作;标准写SOP依据或红线。
|
||||
字段约束:title ≤14字;每个 desc ≤32字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
|
||||
硬约束:只能基于给定片段内容归纳,不得编造片段中没有的流程、时限、承诺、金额、政策或话术。
|
||||
""");
|
||||
|
||||
@@ -2824,6 +3033,9 @@ public class AihrSopSeedService {
|
||||
) {
|
||||
}
|
||||
|
||||
public record SopReviewStats(int total, int usable) {
|
||||
}
|
||||
|
||||
private static final class CategorySummary {
|
||||
private final String name;
|
||||
private int files;
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import java.util.UUID;
|
||||
public class AihrSpeechService {
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final String DEFAULT_TTS_VOICE = "anna";
|
||||
|
||||
private final AihrModelSeedService modelService;
|
||||
|
||||
@@ -17,8 +17,11 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
|
||||
`dim_marketing` int DEFAULT NULL COMMENT '营销分',
|
||||
`mentor_rewrite` varchar(1000) DEFAULT NULL COMMENT '导师改写',
|
||||
`ai_comment` varchar(1000) DEFAULT NULL COMMENT 'AI点评',
|
||||
`review_advice` varchar(1000) DEFAULT NULL COMMENT '主管复盘建议',
|
||||
`incentive_point` int DEFAULT NULL COMMENT '主管人工贡献分',
|
||||
`summary` varchar(1000) DEFAULT NULL COMMENT '摘要',
|
||||
`dialogue_json` text DEFAULT NULL COMMENT '对练话术JSON',
|
||||
`annotations_json` text DEFAULT NULL COMMENT '逐句标注JSON',
|
||||
`trust_curve` varchar(100) DEFAULT NULL COMMENT '信任曲线',
|
||||
`status` varchar(30) DEFAULT '待复盘' COMMENT '状态',
|
||||
`started_time` datetime DEFAULT NULL COMMENT '开始时间',
|
||||
@@ -30,3 +33,352 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
|
||||
KEY `idx_aihr_practice_party` (`tenant_id`, `ext_party_id`, `finished_time`),
|
||||
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
|
||||
`turn_index` int NOT NULL COMMENT '话术序号',
|
||||
`source_text` varchar(1000) DEFAULT NULL COMMENT '原话',
|
||||
`issue_text` varchar(1000) DEFAULT NULL COMMENT '问题点',
|
||||
`rewrite_text` varchar(1000) DEFAULT NULL COMMENT '推荐改写',
|
||||
`sop_point` varchar(1000) DEFAULT NULL COMMENT '命中或漏掉的SOP点',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_annotation` (`tenant_id`, `session_id`, `turn_index`),
|
||||
KEY `idx_aihr_practice_annotation_session` (`tenant_id`, `session_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练逐句标注';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_audio` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
|
||||
`turn_index` int NOT NULL COMMENT '话术序号',
|
||||
`role` varchar(30) NOT NULL COMMENT '音频角色',
|
||||
`oss_id` bigint DEFAULT NULL COMMENT 'OSS文件ID',
|
||||
`audio_url` varchar(1000) DEFAULT NULL COMMENT '音频URL',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_audio` (`tenant_id`, `session_id`, `turn_index`, `role`),
|
||||
KEY `idx_aihr_practice_audio_session` (`tenant_id`, `session_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练音频';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`ext_party_id` varchar(100) NOT NULL COMMENT '员工外部主体或手机号',
|
||||
`trainee_name` varchar(100) DEFAULT NULL COMMENT '员工姓名',
|
||||
`scenario_id` varchar(80) NOT NULL COMMENT '场景ID',
|
||||
`scenario_name` varchar(100) DEFAULT NULL COMMENT '场景名称',
|
||||
`source` varchar(30) NOT NULL DEFAULT 'manual' COMMENT '来源 daily/camp/retry/manual',
|
||||
`reason` varchar(500) DEFAULT NULL COMMENT '派发原因',
|
||||
`status` varchar(30) NOT NULL DEFAULT '待训练' COMMENT '状态',
|
||||
`answer_text` varchar(1000) DEFAULT NULL COMMENT '每日三题作答',
|
||||
`feedback` varchar(1000) DEFAULT NULL COMMENT '每日三题反馈',
|
||||
`correct` tinyint DEFAULT NULL COMMENT '是否答对',
|
||||
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_assignment_party` (`tenant_id`, `ext_party_id`, `status`, `create_time`),
|
||||
KEY `idx_aihr_assignment_source` (`tenant_id`, `source`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练派发任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
|
||||
`reviewer` varchar(100) DEFAULT NULL COMMENT '校准人',
|
||||
`original_score` int DEFAULT NULL COMMENT 'AI原始总分',
|
||||
`corrected_score` int DEFAULT NULL COMMENT '人工校准总分',
|
||||
`corrected_dimensions_json` text DEFAULT NULL COMMENT '校准维度JSON',
|
||||
`reason` varchar(500) DEFAULT NULL COMMENT '校准原因',
|
||||
`status` varchar(30) DEFAULT '已记录' COMMENT '状态',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_calibration_session` (`tenant_id`, `session_id`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 评分校准记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_scenario` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`scenario_code` varchar(80) NOT NULL COMMENT '场景编码',
|
||||
`scenario_name` varchar(100) NOT NULL COMMENT '场景名称',
|
||||
`position` varchar(80) NOT NULL COMMENT '岗位',
|
||||
`scenario_type` varchar(50) NOT NULL COMMENT '场景类型',
|
||||
`project_type` varchar(80) DEFAULT NULL COMMENT '项目类型',
|
||||
`difficulty` tinyint DEFAULT 1 COMMENT '难度',
|
||||
`customer_persona` varchar(500) DEFAULT NULL COMMENT '业主人设',
|
||||
`opening_text` varchar(500) DEFAULT NULL COMMENT '开场白',
|
||||
`explicit_need` varchar(300) DEFAULT NULL COMMENT '显性诉求',
|
||||
`hidden_need` varchar(300) DEFAULT NULL COMMENT '隐性诉求',
|
||||
`irritants` varchar(500) DEFAULT NULL COMMENT '雷点',
|
||||
`delights` varchar(500) DEFAULT NULL COMMENT '爽点',
|
||||
`sop_refs` varchar(500) DEFAULT NULL COMMENT 'SOP引用',
|
||||
`success_criteria` varchar(500) DEFAULT NULL COMMENT '成功条件',
|
||||
`strategy` varchar(500) DEFAULT NULL COMMENT '训练策略',
|
||||
`goal` varchar(500) DEFAULT NULL COMMENT '训练目标',
|
||||
`trainee_name` varchar(100) DEFAULT '张三' COMMENT '默认学员',
|
||||
`trust_start` int DEFAULT 60 COMMENT '起始信任',
|
||||
`trust_end` int DEFAULT 80 COMMENT '目标信任',
|
||||
`rounds_json` text DEFAULT NULL COMMENT '回合seed JSON',
|
||||
`enabled` tinyint DEFAULT 1 COMMENT '是否启用',
|
||||
`sort_order` int DEFAULT 0 COMMENT '排序',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_scenario` (`tenant_id`, `scenario_code`),
|
||||
KEY `idx_aihr_practice_scenario_filter` (`tenant_id`, `position`, `scenario_type`, `enabled`, `sort_order`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练场景';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码',
|
||||
`scenario_code` varchar(80) NOT NULL COMMENT '场景编码',
|
||||
`rubric_name` varchar(100) NOT NULL COMMENT 'Rubric名称',
|
||||
`version` varchar(30) DEFAULT 'v1' COMMENT '版本',
|
||||
`enabled` tinyint DEFAULT 1 COMMENT '是否启用',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '说明',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_rubric` (`tenant_id`, `rubric_code`),
|
||||
KEY `idx_aihr_practice_rubric_scenario` (`tenant_id`, `scenario_code`, `enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练评分标准';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`rubric_code` varchar(100) NOT NULL COMMENT 'Rubric编码',
|
||||
`dimension_code` varchar(50) NOT NULL COMMENT '维度编码',
|
||||
`dimension_name` varchar(50) NOT NULL COMMENT '维度名称',
|
||||
`weight` decimal(5,2) NOT NULL COMMENT '权重',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '评分说明',
|
||||
`sort_order` int DEFAULT 0 COMMENT '排序',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `dimension_code`),
|
||||
KEY `idx_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `sort_order`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练评分维度';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_prompt_template` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`code` varchar(80) NOT NULL COMMENT '模板编码',
|
||||
`name` varchar(100) NOT NULL COMMENT '模板名称',
|
||||
`category` varchar(50) NOT NULL COMMENT '业务分类',
|
||||
`output_format` varchar(50) NOT NULL COMMENT '输出文体',
|
||||
`system_prompt` text DEFAULT NULL COMMENT '系统提示词',
|
||||
`template` text NOT NULL COMMENT '用户提示词模板',
|
||||
`variables` varchar(1000) DEFAULT NULL COMMENT '变量清单',
|
||||
`version` varchar(30) DEFAULT 'v1' COMMENT '版本',
|
||||
`enabled` tinyint DEFAULT 1 COMMENT '是否启用',
|
||||
`description` varchar(500) DEFAULT NULL COMMENT '说明',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_prompt_template` (`tenant_id`, `code`, `output_format`, `version`),
|
||||
KEY `idx_aihr_prompt_template_enabled` (`tenant_id`, `code`, `enabled`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 提示词模板';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`query_text` varchar(1000) NOT NULL COMMENT '未命中问题',
|
||||
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
|
||||
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
|
||||
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
|
||||
`status` varchar(30) DEFAULT '待补充' COMMENT '状态',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_knowledge_gap_status` (`tenant_id`, `status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识缺口日志';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`query_text` varchar(1000) NOT NULL COMMENT '问题',
|
||||
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
|
||||
`answer_text` varchar(2000) DEFAULT NULL COMMENT '回答',
|
||||
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
|
||||
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
|
||||
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
|
||||
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
|
||||
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
|
||||
`note` varchar(500) DEFAULT NULL COMMENT '评审备注',
|
||||
`status` varchar(30) DEFAULT '待评审' COMMENT '状态',
|
||||
`reviewed_time` datetime DEFAULT NULL COMMENT '评审时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审';
|
||||
|
||||
INSERT INTO `aihr_practice_scenario`
|
||||
(`tenant_id`, `scenario_code`, `scenario_name`, `position`, `scenario_type`, `project_type`, `difficulty`,
|
||||
`customer_persona`, `opening_text`, `explicit_need`, `hidden_need`, `irritants`, `delights`, `sop_refs`,
|
||||
`success_criteria`, `strategy`, `goal`, `trainee_name`, `trust_start`, `trust_end`, `rounds_json`, `enabled`, `sort_order`, `create_time`, `update_time`)
|
||||
VALUES
|
||||
('000000', 'complaint-water', '客服应对投诉', '生活顾问', '投诉处理', '住宅', 2,
|
||||
'高情绪、追问进度、质疑服务的业主', '你们说今天处理,到现在都没人联系我,这个车库还怎么停?', '投诉车库积水未处理', '想确认物业是否持续跟进', '被敷衍、只讲流程、推给工程', '先安抚、给责任人、给首次反馈时间', '投诉处理SOP;高情绪沟通话术',
|
||||
'先承接情绪,确认点位,给责任人与首次反馈时间,说明升级路径', '仅在漏关键步骤时提示', '训练学员先安抚、再确认诉求,并给出责任人和首次反馈时间。', '张三', 68, 82,
|
||||
'[{"customer":"你们说今天处理,到现在都没人联系我,这个车库还怎么停?","coachHint":"命中安抚、责任人、首次反馈时间后继续追问处理承诺。","emotion":76,"trust":75},{"customer":"我不要流程,我要知道如果今天还处理不好怎么办。","coachHint":"补上升级路径和兜底承诺,可以进入评分。","emotion":64,"trust":82}]', 1, 10, NOW(), NOW()),
|
||||
('000000', 'complaint-noise', '楼上噪音投诉', '生活顾问', '投诉处理', '住宅', 2,
|
||||
'长期受扰、睡眠受影响、对物业不信任的业主', '楼上每天晚上拖椅子,你们物业到底管不管?', '投诉夜间噪音', '希望物业持续跟踪而不是一次性劝说', '让业主自己协商、说没有执法权、不给反馈节点', '先共情睡眠影响、承诺核实、给回访时间', '噪音投诉SOP;邻里协调SOP',
|
||||
'记录时间段和房号,说明核实方式,约定首次沟通和回访时间', '提示不要先讲权限边界', '训练生活顾问在权限边界内给出可执行跟进。', '张三', 60, 80,
|
||||
'[{"customer":"楼上每天晚上拖椅子,你们物业到底管不管?","coachHint":"先承接睡眠受影响,再收集时间段和楼栋房号。","emotion":78,"trust":66},{"customer":"你别又说去问问,我要看到结果。","coachHint":"给出上门/电话沟通和回访节点,避免空泛承诺。","emotion":70,"trust":80}]', 1, 20, NOW(), NOW()),
|
||||
('000000', 'complaint-attitude', '服务态度投诉', '生活顾问', '投诉处理', '住宅', 1,
|
||||
'对前台态度不满、要求道歉的业主', '你们前台刚才那是什么态度?我要投诉。', '投诉服务态度', '希望被尊重并看到内部改进', '替同事辩解、追问业主责任、让业主冷静', '先道歉、复述问题、承诺核查和反馈', '服务投诉SOP;客户情绪安抚',
|
||||
'表达歉意,记录经过,明确核查责任人和反馈时限', '提示先道歉不等于先定责', '训练生活顾问把道歉、核查和后续反馈拆开说清。', '张三', 62, 82,
|
||||
'[{"customer":"你们前台刚才那是什么态度?我要投诉。","coachHint":"先为体验道歉,再记录时间、地点和涉及岗位。","emotion":74,"trust":68},{"customer":"你是不是想包庇你同事?","coachHint":"强调会客观核查并按节点反馈,不做现场定责。","emotion":66,"trust":82}]', 1, 30, NOW(), NOW()),
|
||||
('000000', 'fee-collection', '催缴沟通技巧', '生活顾问', '催费/停车费', '住宅', 2,
|
||||
'拒绝缴费、质疑服务、要求先整改的业主', '服务做成这样还让我缴费?你先把问题解决了再说。', '物业费催缴', '希望服务问题被负责到底', '压缴费、忽略服务问题、威胁停服务', '承接问题、争议复核、缴费与整改并行', '催缴沟通SOP;费用争议处理',
|
||||
'拆分费用和服务问题,说明复核、整改跟进和缴费节点', '提示服务改进与缴费并行', '训练学员把费用沟通和服务整改拆开处理,避免激化争议。', '张三', 61, 76,
|
||||
'[{"customer":"服务做成这样还让我缴费?你先把问题解决了再说。","coachHint":"表达方向正确,但要补争议项复核和责任人。","emotion":72,"trust":68},{"customer":"那我怎么知道你不是随便记一下?","coachHint":"补上主管复核和节点同步,可以进入评分。","emotion":66,"trust":76}]', 1, 40, NOW(), NOW()),
|
||||
('000000', 'fee-parking', '停车费催缴', '生活顾问', '催费/停车费', '住宅', 2,
|
||||
'对停车费涨价敏感、担心权益受损的业主', '停车费又催,我车位入口问题一直没解决,凭什么交?', '停车费缴费提醒', '希望停车体验问题同步解决', '只重复收费规则、否认问题、催得太硬', '先记录停车问题、说明费用依据、给处理节点', '停车费催缴SOP;停车场服务SOP',
|
||||
'说明费用依据,记录入口问题,给整改反馈时间和缴费办理方式', '提示先服务后收费说明', '训练收费季停车费沟通时的共情和政策表达。', '张三', 58, 78,
|
||||
'[{"customer":"停车费又催,我车位入口问题一直没解决,凭什么交?","coachHint":"先确认入口问题,再说明费用依据和复核方式。","emotion":75,"trust":65},{"customer":"你说的优惠和截止时间到底是什么?","coachHint":"把折扣、截止时间和办理路径一次说清。","emotion":62,"trust":78}]', 1, 50, NOW(), NOW()),
|
||||
('000000', 'fee-prepay-discount', '预交折扣沟通', '生活顾问', '催费/停车费', '住宅', 2,
|
||||
'精打细算、关注优惠边界和历史欠费的业主', '你们说预交有折扣,那我以前欠的怎么算?', '预交折扣咨询', '希望弄清优惠、欠费和办理路径', '含糊优惠口径、承诺无法兑现、回避欠费', '先说明以系统账单为准、再给办理路径', '预交折扣SOP;历史欠费沟通',
|
||||
'解释预交折扣、历史欠费处理口径和办理材料', '提示金额必须以系统账单为准', '训练收费季预交优惠和历史欠费的稳妥表达。', '张三', 65, 84,
|
||||
'[{"customer":"你们说预交有折扣,那我以前欠的怎么算?","coachHint":"先说明金额以系统账单为准,避免口头承诺具体金额。","emotion":60,"trust":72},{"customer":"我要今天就知道能省多少钱。","coachHint":"给出查询路径和反馈时间,不现场编金额。","emotion":58,"trust":84}]', 1, 60, NOW(), NOW()),
|
||||
('000000', 'repair-leak-followup', '漏水报修跟进', '生活顾问', '报修跟进', '住宅', 2,
|
||||
'家中受损、担心维修拖延和赔偿的业主', '我家天花还在滴水,报修两天了怎么没人来?', '漏水报修跟进', '希望明确上门时间和后续责任', '只说已派单、不给时限、回避损失记录', '确认安全、给上门时间、记录受损情况', '报修跟进SOP;漏水应急SOP',
|
||||
'确认漏水现状,安排上门节点,记录损失并同步责任人', '提示先排安全风险', '训练报修跟进中的时限、责任人和证据记录。', '张三', 57, 81,
|
||||
'[{"customer":"我家天花还在滴水,报修两天了怎么没人来?","coachHint":"先确认是否仍在漏水和用电安全,再给上门安排。","emotion":77,"trust":64},{"customer":"如果家具泡坏了谁负责?","coachHint":"说明先记录现场和照片,后续按责任认定处理。","emotion":69,"trust":81}]', 1, 70, NOW(), NOW()),
|
||||
('000000', 'repair-elevator-delay', '电梯故障跟进', '生活顾问', '报修跟进', '住宅', 3,
|
||||
'住高层、老人出行受影响、要求明确恢复时间的业主', '电梯坏了一上午,老人怎么下楼?你们给个准话。', '电梯故障跟进', '希望知道预计恢复和临时协助', '只说维保在路上、没有临时方案、不给播报', '先共情出行影响、说明维保进展、提供临时协助', '电梯故障SOP;应急协助SOP',
|
||||
'说明维保进展、预计反馈时间、老人出行临时协助和公告节奏', '提示不要承诺未经确认的恢复时间', '训练高影响报修的进展播报和临时协助。', '张三', 55, 80,
|
||||
'[{"customer":"电梯坏了一上午,老人怎么下楼?你们给个准话。","coachHint":"先承接老人出行影响,再说明维保进展和下一次反馈。","emotion":80,"trust":62},{"customer":"你就说几点能好。","coachHint":"不能编恢复时间,要承诺确认后反馈并给临时协助。","emotion":72,"trust":80}]', 1, 80, NOW(), NOW()),
|
||||
('000000', 'repair-public-light', '公区照明维修', '生活顾问', '报修跟进', '住宅', 1,
|
||||
'晚上回家担心安全、希望小问题快处理的业主', '楼道灯坏了三天,晚上黑漆漆的。', '公区照明维修', '希望确认维修时间', '轻视小问题、不给工单、让业主等', '记录点位、给工单和处理时限', '公区维修SOP;夜间安全巡查',
|
||||
'记录楼栋楼层点位,说明派单和反馈时间', '提示给出工单闭环', '训练轻量报修也要闭环表达。', '张三', 66, 86,
|
||||
'[{"customer":"楼道灯坏了三天,晚上黑漆漆的。","coachHint":"记录准确点位,并给派单和预计反馈时间。","emotion":62,"trust":76},{"customer":"别又登记完没下文。","coachHint":"补充工单编号或责任人和回访时间。","emotion":56,"trust":86}]', 1, 90, NOW(), NOW()),
|
||||
('000000', 'emergency-water-outage', '突发停水求助', '生活顾问', '突发求助', '住宅', 2,
|
||||
'家中有老人小孩、对突发停水焦虑的业主', '家里突然没水了,孩子还要洗澡,怎么回事?', '突发停水求助', '希望知道原因和临时供水办法', '不知道原因就乱猜、只让等待、不给通知渠道', '先核实范围、同步排查、提供临时取水信息', '停水应急SOP;突发通知SOP',
|
||||
'核实是否区域性停水,说明排查进展、通知渠道和临时方案', '提示未知原因时说排查中', '训练突发问题的原因核实和临时安置表达。', '张三', 60, 83,
|
||||
'[{"customer":"家里突然没水了,孩子还要洗澡,怎么回事?","coachHint":"先核实楼栋范围,不要猜测原因。","emotion":73,"trust":68},{"customer":"那现在怎么办?总不能一直等。","coachHint":"给临时取水点或下一次公告时间。","emotion":65,"trust":83}]', 1, 100, NOW(), NOW()),
|
||||
('000000', 'emergency-elder-fall', '老人摔倒求助', '生活顾问', '突发求助', '住宅', 3,
|
||||
'情绪慌张、需要立即协助的家属', '我爸在楼下摔倒了,你们快来帮忙!', '老人摔倒求助', '希望物业迅速到场并联系救助', '先追责、慢吞吞登记、忽略急救安全', '先确认位置和意识状态、派人到场、建议急救电话', '突发救助SOP;急救协助SOP',
|
||||
'确认位置和安全状态,派人到场,提醒拨打急救电话并联系家属', '提示物业协助不替代专业急救', '训练高紧急求助中的优先级和措辞。', '张三', 52, 79,
|
||||
'[{"customer":"我爸在楼下摔倒了,你们快来帮忙!","coachHint":"先问清准确位置和意识状态,同时安排人员到场。","emotion":88,"trust":60},{"customer":"你们到了能不能处理?","coachHint":"说明会协助保护现场和联系急救,不替代医生判断。","emotion":78,"trust":79}]', 1, 110, NOW(), NOW()),
|
||||
('000000', 'emergency-security', '陌生人尾随求助', '生活顾问', '突发求助', '住宅', 3,
|
||||
'夜间回家、感到被尾随、需要安全协助的业主', '我刚进小区感觉有人跟着我,你们保安在哪里?', '安全求助', '希望马上获得陪同和安全确认', '要求业主自己确认、轻描淡写、泄露巡逻盲区', '先确认位置、安排安保、保持通话或引导到安全点', '安全巡逻SOP;突发求助SOP',
|
||||
'确认实时位置,安排安保接应,引导到明亮公共区域并保留记录', '提示不要透露安防盲区', '训练夜间安全求助的接警式沟通。', '张三', 54, 82,
|
||||
'[{"customer":"我刚进小区感觉有人跟着我,你们保安在哪里?","coachHint":"先确认当前位置和是否安全,安排安保接应。","emotion":86,"trust":62},{"customer":"我要不要直接回家?","coachHint":"引导到明亮公共区域或岗亭,保持联系。","emotion":76,"trust":82}]', 1, 120, NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`scenario_name` = VALUES(`scenario_name`),
|
||||
`position` = VALUES(`position`),
|
||||
`scenario_type` = VALUES(`scenario_type`),
|
||||
`project_type` = VALUES(`project_type`),
|
||||
`difficulty` = VALUES(`difficulty`),
|
||||
`customer_persona` = VALUES(`customer_persona`),
|
||||
`opening_text` = VALUES(`opening_text`),
|
||||
`explicit_need` = VALUES(`explicit_need`),
|
||||
`hidden_need` = VALUES(`hidden_need`),
|
||||
`irritants` = VALUES(`irritants`),
|
||||
`delights` = VALUES(`delights`),
|
||||
`sop_refs` = VALUES(`sop_refs`),
|
||||
`success_criteria` = VALUES(`success_criteria`),
|
||||
`strategy` = VALUES(`strategy`),
|
||||
`goal` = VALUES(`goal`),
|
||||
`trainee_name` = VALUES(`trainee_name`),
|
||||
`trust_start` = VALUES(`trust_start`),
|
||||
`trust_end` = VALUES(`trust_end`),
|
||||
`rounds_json` = VALUES(`rounds_json`),
|
||||
`enabled` = VALUES(`enabled`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
INSERT INTO `aihr_practice_rubric`
|
||||
(`tenant_id`, `rubric_code`, `scenario_code`, `rubric_name`, `version`, `enabled`, `description`, `create_time`, `update_time`)
|
||||
SELECT
|
||||
`tenant_id`,
|
||||
CONCAT('rubric-', `scenario_code`),
|
||||
`scenario_code`,
|
||||
CONCAT(`scenario_name`, ' Rubric'),
|
||||
'v1',
|
||||
1,
|
||||
'生活顾问试点四维评分标准',
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM `aihr_practice_scenario`
|
||||
WHERE `tenant_id` = '000000'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`rubric_name` = VALUES(`rubric_name`),
|
||||
`scenario_code` = VALUES(`scenario_code`),
|
||||
`enabled` = VALUES(`enabled`),
|
||||
`description` = VALUES(`description`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
INSERT INTO `aihr_practice_rubric_dimension`
|
||||
(`tenant_id`, `rubric_code`, `dimension_code`, `dimension_name`, `weight`, `description`, `sort_order`, `create_time`, `update_time`)
|
||||
SELECT
|
||||
r.`tenant_id`,
|
||||
r.`rubric_code`,
|
||||
d.`dimension_code`,
|
||||
d.`dimension_name`,
|
||||
CASE
|
||||
WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'compliance' THEN 0.30
|
||||
WHEN s.`scenario_type` = '催费/停车费' AND d.`dimension_code` = 'marketing' THEN 0.20
|
||||
ELSE d.`weight`
|
||||
END,
|
||||
d.`description`,
|
||||
d.`sort_order`,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM `aihr_practice_rubric` r
|
||||
JOIN `aihr_practice_scenario` s
|
||||
ON s.`tenant_id` = r.`tenant_id` AND s.`scenario_code` = r.`scenario_code`
|
||||
JOIN (
|
||||
SELECT 'compliance' AS `dimension_code`, '合规' AS `dimension_name`, 0.35 AS `weight`, 'SOP关键点、权限边界、承诺口径' AS `description`, 10 AS `sort_order`
|
||||
UNION ALL SELECT 'communication', '沟通', 0.25, '诉求确认、信息结构、反馈节点', 20
|
||||
UNION ALL SELECT 'emotion', '情绪', 0.25, '共情承接、降温、信任恢复', 30
|
||||
UNION ALL SELECT 'marketing', '营销', 0.15, '收费季转化、政策解释、办理引导', 40
|
||||
) d
|
||||
WHERE r.`tenant_id` = '000000'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`dimension_name` = VALUES(`dimension_name`),
|
||||
`weight` = VALUES(`weight`),
|
||||
`description` = VALUES(`description`),
|
||||
`sort_order` = VALUES(`sort_order`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
INSERT INTO `aihr_prompt_template`
|
||||
(`tenant_id`, `code`, `name`, `category`, `output_format`, `system_prompt`, `template`, `variables`, `version`, `enabled`, `description`, `create_time`, `update_time`)
|
||||
VALUES
|
||||
('000000', 'summary_card', 'SOP总结卡', 'knowledge', 'json_card',
|
||||
'你是物业一线员工的SOP总结卡助手。只返回JSON,不要markdown。固定输出title、steps、objections、scripts、reminders。steps必须且只包含问题、建议、标准三项。',
|
||||
'员工问题:{{queryText}}。知识片段:{{context}}。请生成可拆分展示的总结卡,问题写员工当前要处理的核心问题,建议写下一步动作,标准写SOP依据和红线。',
|
||||
'queryText,context', 'v1', 1, '总结卡固定问题/建议/标准三段式', NOW(), NOW()),
|
||||
('000000', 'mentor_rewrite', '导师改写', 'practice', 'text',
|
||||
'你是物业管家带教师傅。只基于本场对话给出一段更稳妥的话术,不评价人格。',
|
||||
'场景:{{scenarioName}}。目标:{{goal}}。员工原话:{{traineeText}}。请改写成可直接说出口的版本,80字以内。',
|
||||
'scenarioName,goal,traineeText', 'v1', 1, '训练后标杆话术生成', NOW(), NOW()),
|
||||
('000000', 'review_annotation', '逐句标注', 'practice', 'json_annotation',
|
||||
'你是物业AI陪练复盘教练。只返回JSON数组,每项包含quote、problem、rewrite、sopPoint。',
|
||||
'场景:{{scenarioName}}。Rubric:{{rubric}}。对话:{{dialogue}}。请只标注员工关键句,指出问题、推荐改写和命中或漏掉的SOP点。',
|
||||
'scenarioName,rubric,dialogue', 'v1', 1, 'finish后逐句标注', NOW(), NOW()),
|
||||
('000000', 'prep_card', '训练前预习卡', 'practice', 'json_prep',
|
||||
'你是物业培训教练。只返回JSON,字段固定为keyPoints、redLines、scripts。',
|
||||
'场景:{{scenarioName}}。SOP引用:{{sopRefs}}。成功条件:{{successCriteria}}。请生成训练前预习卡:要点3条、红线3条、可说话术2条。',
|
||||
'scenarioName,sopRefs,successCriteria', 'v1', 1, '训练前卡固定三栏结构', NOW(), NOW()),
|
||||
('000000', 'supervision_image_analysis_maintenance', '图片督导分析', 'supervision', 'json_card',
|
||||
'你是物业现场督导助手。只返回JSON,字段固定为problem、suggestion、standard。',
|
||||
'图片/视频描述:{{visualText}}。相关标准:{{standard}}。请按问题、建议、标准三段输出,不要扩写无依据内容。',
|
||||
'visualText,standard', 'v1', 1, '督导图片分析三段式', NOW(), NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`name` = VALUES(`name`),
|
||||
`category` = VALUES(`category`),
|
||||
`system_prompt` = VALUES(`system_prompt`),
|
||||
`template` = VALUES(`template`),
|
||||
`variables` = VALUES(`variables`),
|
||||
`enabled` = VALUES(`enabled`),
|
||||
`description` = VALUES(`description`),
|
||||
`update_time` = VALUES(`update_time`);
|
||||
|
||||
+21
-21
@@ -330,10 +330,10 @@
|
||||
|
||||
**Steps**
|
||||
|
||||
- [ ] 增加 `aihr_practice_scenario`、`aihr_practice_rubric`、`aihr_practice_rubric_dimension`。
|
||||
- [ ] 增加 `aihr_prompt_template`,内置 `summary_card`、`mentor_rewrite`、`review_annotation`、`prep_card`、`supervision_image_analysis_maintenance` 等模板。
|
||||
- [ ] 写入 12 条生活顾问场景 seed。
|
||||
- [ ] reset 后确认场景、rubric 和 prompt template 表有数据。
|
||||
- [x] 增加 `aihr_practice_scenario`、`aihr_practice_rubric`、`aihr_practice_rubric_dimension`。
|
||||
- [x] 增加 `aihr_prompt_template`,内置 `summary_card`、`mentor_rewrite`、`review_annotation`、`prep_card`、`supervision_image_analysis_maintenance` 等模板。
|
||||
- [x] 写入 12 条生活顾问场景 seed。
|
||||
- [x] reset 后确认场景、rubric 和 prompt template 表有数据。
|
||||
|
||||
### Task 2: 场景读取替换 seed
|
||||
|
||||
@@ -345,9 +345,9 @@
|
||||
|
||||
**Steps**
|
||||
|
||||
- [ ] `start` 优先按 `scenarioId` 读数据库场景。
|
||||
- [ ] 数据库无场景时回退现有 seed。
|
||||
- [ ] `turn/finish` 保持当前契约不破坏前端。
|
||||
- [x] `start` 优先按 `scenarioId` 读数据库场景。
|
||||
- [x] 数据库无场景时回退现有 seed。
|
||||
- [x] `turn/finish` 保持当前契约不破坏前端。
|
||||
|
||||
### Task 3: 训练前卡和求助
|
||||
|
||||
@@ -360,9 +360,9 @@
|
||||
|
||||
**Steps**
|
||||
|
||||
- [ ] 开始训练前展示场景预习卡。
|
||||
- [ ] 训练中加“求助”入口,用场景 category/SOP 引用查知识库。
|
||||
- [ ] 求助失败显示明确错误,不兜假答案。
|
||||
- [x] 开始训练前展示场景预习卡。
|
||||
- [x] 训练中加“求助”入口,用场景 category/SOP 引用查知识库。
|
||||
- [x] 求助失败显示明确错误,不兜假答案。
|
||||
|
||||
### Task 4: 动态情绪和红线纠偏
|
||||
|
||||
@@ -375,9 +375,9 @@
|
||||
|
||||
**Steps**
|
||||
|
||||
- [ ] LLM turn 输出增加 `emotion/trust/redFlag/coachHint`。
|
||||
- [ ] 前端展示情绪条和红线纠偏。
|
||||
- [ ] 解析失败时回退现有 coachHint,不阻断训练。
|
||||
- [x] LLM turn 输出增加 `emotion/trust/redFlag/coachHint`。
|
||||
- [x] 前端展示情绪条和红线纠偏。
|
||||
- [x] 解析失败时回退现有 coachHint,不阻断训练。
|
||||
|
||||
### Task 5: 回放与逐句标注
|
||||
|
||||
@@ -390,9 +390,9 @@
|
||||
|
||||
**Steps**
|
||||
|
||||
- [ ] 对练音频写 `sys_oss`/MinIO 并关联 session。
|
||||
- [ ] 主管复盘详情显示音频回放。
|
||||
- [ ] finish 后生成逐句标注,刷新不重复生成。
|
||||
- [x] 对练音频写 `sys_oss`/MinIO 并关联 session。
|
||||
- [x] 主管复盘详情显示音频回放。
|
||||
- [x] finish 后生成逐句标注,刷新不重复生成。
|
||||
|
||||
### Task 6: 画像与主管派发
|
||||
|
||||
@@ -405,11 +405,11 @@
|
||||
|
||||
**Steps**
|
||||
|
||||
- [ ] 画像补训练时长、贡献度、测评分、AI 等级。
|
||||
- [ ] 主管端补预警列表。
|
||||
- [ ] 主管可派发专项训练,员工端今日安排可见。
|
||||
- [ ] 主管端可记录复盘建议并派发下一步训练。
|
||||
- [ ] 员工端展示初/中/高级成长路径进度,管理端可导出评级证据包。
|
||||
- [x] 画像补训练时长、贡献度、测评分、AI 等级。
|
||||
- [x] 主管端补预警列表。
|
||||
- [x] 主管可派发专项训练,员工端今日安排可见。
|
||||
- [x] 主管端可记录复盘建议并派发下一步训练。
|
||||
- [x] 员工端展示初/中/高级成长路径进度,管理端可导出评级证据包。
|
||||
|
||||
## 11. 二期完成定义
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,映射并刷新 `aihr_org_snapshot`;默认 `replaceExisting=true` 覆盖本租户快照,先用 `{"dryRun":true}` 预检;外部接口未配置时保留 SQL seed |
|
||||
| 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`,prod profile 代码级强制失效) |
|
||||
| 移动端三端首页 `/h5/user`、`/h5/candidate`、`/h5/supervisor` | `GET /api/aihr/mobile/home/{role}` | 已接入员工、候选人、主管首页公开只读 API;移动端本地 fallback 保演示 |
|
||||
| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化 |
|
||||
| 移动端员工训练闭环 | 复用 `POST /api/train/practice/start`、`/turn`、`/finish`;查询 `GET /api/aihr/mobile/practice/history`、`/practice/reviews`、`/practice/reviews/{id}`、`/profile`;标记 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed` | 员工端登录后带 `Authorization` 与 `clientid` 调用;`mode=mobile` 完成后写入 `aihr_practice_session`,主管端首页完训率、待复盘列表、复盘详情、员工训练历史和能力画像同步变化;复盘标记可带 `incentivePoint` 写入 `incentive_point`,用于贡献度和试点 CSV |
|
||||
| 移动端候选人闭环 | 面试复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;资料 `POST /api/aihr/mobile/candidate/materials`、`GET /api/aihr/mobile/candidate/materials`;HR 审核 `GET /api/aihr/hr/candidate/materials`、`POST /api/aihr/hr/candidate/materials/{id}/review` | 候选人端登录后带 `Authorization` 与 `clientid` 调用;面试拉题/评分走真实模型优先;资料上传写 `sys_oss`/MinIO 和 `aihr_candidate_material`,HR 在 AI 面试页标记 `已通过/已驳回`,候选人端列表同步状态 |
|
||||
|
||||
## 后端落点
|
||||
@@ -126,8 +126,8 @@ curl -fsS http://127.0.0.1:8080/api/aihr/org/sync \
|
||||
- 资料处理页面:`frontend/src/views/knowledge/processing.vue`
|
||||
- 模型配置 API 文件:`frontend/src/api/aihr/model.ts`
|
||||
- 模型配置页面:`frontend/src/views/system/model/index.vue`
|
||||
- 移动端独立工程:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`;当前三端首页已按高保真原型实现,未登录先走手机号短信登录,登录后 API 优先请求 `/api/aihr/mobile/home/{role}`,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
|
||||
- 员工端训练:任务卡“开始训练”复用管理端三角色对练 API,完成两轮后展示评分和导师改写;记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
|
||||
- 移动端独立工程:`mobile/src/App.vue`,路由 `/h5/user`、`/h5/candidate`、`/h5/supervisor`;员工端子路由为 `/h5/user/training`(训练列表)和 `/h5/user/practice`(对练页)。当前三端首页已按高保真原型实现,未登录先走手机号短信登录,登录后 API 优先请求 `/api/aihr/mobile/home/{role}`,不复用后台页面路由。语音输入不要新增移动端专用接口,浏览器录音和文件选择兜底都复用 `/api/ai/asr`。
|
||||
- 员工端训练:底部“练”进入 `/h5/user/training`,首屏展示场景模拟、开始练习和最近练过,每日三题后置;点“开始练习”切换到 `/h5/user/practice`,复用管理端三角色对练 API,完成两轮后展示评分和导师改写;记录写入 `aihr_practice_session`,员工端展示训练历史和能力画像,主管端展示待复盘列表,点进单条可看评分、话术、导师改写,并可标记“已复盘”。
|
||||
- 候选人端:`/h5/candidate` 的“开始面试/面试练习”复用 AI 面试 API;“补充资料”走候选资料上传接口,成功后展示 OSS ID、资料类型和审核状态。管理端 `/recruit/interview` 底部展示“候选资料审核”,支持通过/驳回。
|
||||
- 演示 fallback 只作为未配置模型或外部接口异常时的降级,不作为真实测试通过证据。
|
||||
|
||||
|
||||
+13
-1
@@ -15,13 +15,25 @@
|
||||
| 顺序 | 页面 | 点击路径 | 必须看到 |
|
||||
|---|---|---|---|
|
||||
| 1 | 首页 `/index` | 打开首页 | 只展示“首页 / AI面试 / 三角色对练 / 案例沉淀 / SOP知识库 / 资料处理 / 系统设置-模型配置” |
|
||||
| 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端首页为「师傅区(头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图(问/练/学/报四色块)→我的成长摘要」,点“开始训练”完成两轮提交,再打开主管端;点快捷 chip 或「问」可文本/语音查 SOP,结果页点「生成总结卡·约10秒」弹出信息图卡(四步法/异议应对/开口话术/关键提醒+听一遍/开始跟读);候选人端点“开始面试/面试练习”和“补充资料”;管理端 `/recruit/interview` 审核候选资料 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;底部 tab 整页切换(员工端为 今日/练/问/我,切换回顶),滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页(返回首页按钮),无死按钮;主管端完训人数、“待复盘对练”计数和待复盘列表增加;训练页语音输入在浏览器录音失败时应切到“选择语音”而不是死按钮;点进待复盘记录可看评分、话术、导师改写,并可标记已复盘;候选人端可拉题/答题/评分,资料上传后显示“已提交资料”、`OSS ...`、`待审核`;HR 审核后候选人端显示 `已通过/已驳回`;接口不可用时走本地 fallback |
|
||||
| 2 | 移动端三端 `http://127.0.0.1:5174/h5/user`、`/h5/candidate`、`/h5/supervisor` | 打开后先手机号登录;已配置 `AIHR_SMS_LOGIN_TEMPLATE_ID` 时可真实收短信,新手机号会自动注册;员工端首页为「师傅区(头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图(问/练/学/报四色块)→我的成长摘要」;点底部“练”进入 `/h5/user/training`,首屏必须先看到场景模拟、开始练习和最近练过,每日三题在下方;点“开始练习”必须切到 `/h5/user/practice` 对练页,完成两轮提交,再打开主管端;点快捷 chip 或「问」可文本/语音查 SOP,结果页点「生成总结卡·约10秒」弹出信息图卡(四步法/异议应对/开口话术/关键提醒+听一遍/开始跟读);候选人端点“开始面试/面试练习”和“补充资料”;管理端 `/recruit/interview` 审核候选资料 | 员工端显示评分、“训练完成,主管端待复盘已更新”、训练历史和能力画像;底部 tab 整页切换(员工端为 今日/练/问/我,切换回顶),滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页(返回首页按钮),无死按钮;主管端完训人数、“待复盘对练”计数和待复盘列表增加;训练页语音输入在浏览器录音失败时应切到“选择语音”而不是死按钮;点进待复盘记录可看评分、话术、导师改写,并可标记已复盘;候选人端可拉题/答题/评分,资料上传后显示“已提交资料”、`OSS ...`、`待审核`;HR 审核后候选人端显示 `已通过/已驳回`;接口不可用时走本地 fallback |
|
||||
| 3 | AI面试 `/recruit/interview` | 生成题目 → 填满参考回答或输入真实回答 → 完成评分 | 配置 chat 模型时真实生成题目和评分;未配置时用本地 Rubric 估分,不返回固定候选人分数;应看到新增面试记录 |
|
||||
| 4 | 三角色对练 `/train/practice` | 开始对练 → 填入参考回复或输入真实回复 → 继续一轮 → 结束并评分 | 已完成闭环、导师改写、新增对练记录;数据库已启用 chat 模型时,客户回复与评分为真实 LLM 生成(回复不再是固定台词,分数随话术变化),未配置时使用本地剧本兜底 |
|
||||
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看样片 | 已完成闭环,案例列表新增真实转写生成的记录;视频仍用样片兜底 |
|
||||
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
|
||||
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 可选点“服务端导入”导入 `.data/import` 下少量样例 | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态和事件列表 |
|
||||
|
||||
## AI陪练二期增量脚本
|
||||
|
||||
| 顺序 | 页面 | 点击路径 | 必须看到 |
|
||||
|---|---|---|---|
|
||||
| 1 | 管理端 `/train/practice` | 切换生活顾问场景 → 查看 Rubric/预习卡 → 开始对练 | 场景来自数据库 12 条 seed;Rubric、SOP 引用、预习卡可见;模型不可用时仍回退内置剧本 |
|
||||
| 2 | 员工端 `/h5/user` | 今日安排 → 每日三题作答 → 开始训练 → 训练中点“求助” → 完成评分 | 每日三题即时反馈;求助命中 SOP 或明确报错;完成后画像、历史和主管端待复盘同步 |
|
||||
| 3 | 主管端 `/h5/supervisor` | 预警/待复盘 → 进入复盘详情 → 听回放/看逐句标注 → 写复盘建议 → 派发再练 | 能看到评分、音频、逐句标注、主管建议;员工端今日安排出现专项/再练任务 |
|
||||
| 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 导出试点 CSV | 导出包含训练次数、完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率 |
|
||||
| 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 |
|
||||
|
||||
试点正式验收前,用 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门;样本不足时应失败,不用静态演示 marker 代替真实试点数据。
|
||||
|
||||
## 录屏兜底
|
||||
|
||||
录屏只保留一条完整路径,不录框架后台配置。
|
||||
|
||||
+5
-1
@@ -33,6 +33,10 @@ AIHR_ORG_SYNC_ACCESS_TOKEN=
|
||||
AIHR_ORG_SYNC_CLIENT_ID=
|
||||
AIHR_ORG_SYNC_CLIENT_SECRET=
|
||||
AIHR_ORG_SYNC_SIGNING_SECRET=
|
||||
# 试点成本闸门:超预算时改 false,chat/asr/tts 会走现有文本兜底
|
||||
AIHR_AI_RUNTIME_ENABLED=true
|
||||
AIHR_AI_CHAT_ENABLED=true
|
||||
AIHR_AI_SPEECH_ENABLED=true
|
||||
```
|
||||
|
||||
`application-dev.yml` 只保留占位和默认值,不提交真实短信密钥。dev 环境不配阿里云短信也能登录移动端:验证码固定 `123456`。
|
||||
@@ -134,7 +138,7 @@ curl -k -s https://peilian.njzhmj.top/h5/supervisor | sed -n '1,20p'
|
||||
- 案例沉淀:进入 `/knowledge/cases`,上传真实语音 → “AI 整理” → “送审” → “入库”,应看到“已完成闭环”和新增案例记录;上传必须先完成 ASR 转写。
|
||||
- SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。
|
||||
- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;可用少量文件验证“批量导入/选择目录/服务端导入”。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。服务端导入读取 `./.data/import` 下的相对目录,启动后台任务并在页面显示进度,运行中任务可点“取消”;目录导入的重试粒度是同目录重新导入,批量上传的重试粒度是单文件。
|
||||
- 移动端员工训练:进入 `http://127.0.0.1:5174/h5/user`,手机号登录(dev 验证码固定 `123456`)。员工端首页为「师傅区(mentor.png 头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图四色块→我的成长摘要」(视觉基准 `feedback/2026070423/design/`),底部 tab(今日/练/问/我)整页切换,滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页。“开始训练” → 两次“填入建议回复 / 提交本轮”,应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。训练页有“语音输入”和“播报”按钮,需启用 asr/tts 模型后生效;iOS/WebView 若无法直接访问麦克风,会切到“选择语音”并用系统录音/音频文件继续走 ASR,否则降级文本。快捷 chip 或「问」打开查 SOP 面板:文本/语音提问 → 轻概要(回答+要点+注意)+依据折叠;点「生成总结卡·约10秒」弹出信息图卡(四步法/异议/话术/提醒+听一遍/开始跟读),需 chat 模型;语音需 asr 模型。
|
||||
- 移动端员工训练:进入 `http://127.0.0.1:5174/h5/user`,手机号登录(dev 验证码固定 `123456`)。员工端首页为「师傅区(mentor.png 头像+气泡+按住说话/打字问+快捷chips)→今日安排→我的地图四色块→我的成长摘要」(视觉基准 `feedback/2026070423/design/`),底部 tab(今日/练/问/我)整页切换,滚动后师傅头像收缩为右下悬浮球;未实现入口进「建设中」整页。点底部“练”进入 `http://127.0.0.1:5174/h5/user/training`,首屏应先看到场景模拟、开始练习和最近练过,每日三题在下方;点“开始练习”进入 `http://127.0.0.1:5174/h5/user/practice`,两次“填入建议回复 / 提交本轮”后应看到评分、导师改写、训练历史和能力画像;切到主管端后完训人数与“待复盘对练”计数增加,并展示待复盘列表;点进单条可看评分、话术、导师改写,并可标记已复盘。训练页有“语音输入”和“播报”按钮,需启用 asr/tts 模型后生效;iOS/WebView 若无法直接访问麦克风,会切到“选择语音”并用系统录音/音频文件继续走 ASR,否则降级文本。快捷 chip 或「问」打开查 SOP 面板:文本/语音提问 → 轻概要(回答+要点+注意)+依据折叠;点「生成总结卡·约10秒」弹出信息图卡(四步法/异议/话术/提醒+听一遍/开始跟读),需 chat 模型;语音需 asr 模型。
|
||||
- 移动端候选人:进入 `http://127.0.0.1:5174/h5/candidate`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到“已提交资料”、`OSS ...`、`待审核`。再进入管理端 `/recruit/interview` 底部“候选资料审核”点“通过/驳回”,候选人端刷新后应显示新状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。
|
||||
- 真 LLM 激活:在 `/system/model` 给供应商填 api_host/api_key 并启用 `category=chat` 模型后,三角色对练的客户回复与评分即为真实 LLM 生成;再启用 `category=asr/tts`(如硅基流动 SenseVoice/CosyVoice2)语音路径生效。未配置时全链路自动回退 seed。
|
||||
|
||||
|
||||
@@ -12,6 +12,43 @@ export type PracticeStartRequest = {
|
||||
mode: string;
|
||||
};
|
||||
|
||||
export type PracticeScenarioResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
scenarioType: string;
|
||||
projectType: string;
|
||||
difficulty: number;
|
||||
customerPersona: string;
|
||||
openingText: string;
|
||||
explicitNeed: string;
|
||||
hiddenNeed: string;
|
||||
irritants: string;
|
||||
delights: string;
|
||||
sopRefs: string;
|
||||
successCriteria: string;
|
||||
strategy: string;
|
||||
goal: string;
|
||||
enabled: boolean;
|
||||
rubric?: {
|
||||
code: string;
|
||||
name: string;
|
||||
version: string;
|
||||
dimensions: Array<{ code: string; name: string; weight: number; description: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type PracticeScenarioEnabledRequest = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PracticePrepCardResponse = {
|
||||
keyPoints: string[];
|
||||
redLines: string[];
|
||||
scripts: string[];
|
||||
promptVersion: string;
|
||||
};
|
||||
|
||||
export type PracticeStartResponse = {
|
||||
sessionId: string;
|
||||
scenarioId: string;
|
||||
@@ -34,6 +71,7 @@ export type PracticeTurnRequest = {
|
||||
roundIndex: number;
|
||||
traineeText: string;
|
||||
traineeAudioUrl?: string;
|
||||
traineeAudioOssId?: number;
|
||||
};
|
||||
|
||||
export type PracticeTurnResponse = {
|
||||
@@ -42,6 +80,7 @@ export type PracticeTurnResponse = {
|
||||
emotion: number;
|
||||
trust: number;
|
||||
coachHint: string;
|
||||
redFlag: boolean;
|
||||
roundIndex: number;
|
||||
finished: boolean;
|
||||
};
|
||||
@@ -70,6 +109,78 @@ export type PracticeFinishResponse = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PracticeCalibrationRequest = {
|
||||
reviewer?: string;
|
||||
correctedTotal?: number;
|
||||
correctedCompliance?: number;
|
||||
correctedEmotion?: number;
|
||||
correctedCommunication?: number;
|
||||
correctedMarketing?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PracticeCalibrationResponse = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
originalScore: number;
|
||||
correctedScore: number;
|
||||
reviewer: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createTime: string;
|
||||
};
|
||||
|
||||
export type PromptTemplateResponse = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
outputFormat: string;
|
||||
systemPrompt: string;
|
||||
template: string;
|
||||
variables: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
updateTime: string;
|
||||
};
|
||||
|
||||
export type PromptTemplateRequest = {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
outputFormat: string;
|
||||
systemPrompt: string;
|
||||
template: string;
|
||||
variables: string;
|
||||
version: string;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise<ApiResult<PracticeScenarioResponse[]>> {
|
||||
return request({
|
||||
url: '/api/train/practice/scenarios',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise<ApiResult<null>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${id}/enabled`,
|
||||
method: 'patch',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${id}/prep-card`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function startPracticeSession(data: PracticeStartRequest): Promise<ApiResult<PracticeStartResponse>> {
|
||||
return request({
|
||||
url: '/api/train/practice/start',
|
||||
@@ -96,3 +207,44 @@ export function finishPracticeSession(data: PracticeFinishRequest): Promise<ApiR
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function calibratePracticeSession(sessionId: string, data: PracticeCalibrationRequest): Promise<ApiResult<PracticeCalibrationResponse>> {
|
||||
return request({
|
||||
url: `/api/train/practice/sessions/${sessionId}/calibration`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function exportPracticePilotCsv(): Promise<Blob> {
|
||||
return request({
|
||||
url: '/api/train/practice/export',
|
||||
method: 'get',
|
||||
responseType: 'blob'
|
||||
}) as unknown as Promise<Blob>;
|
||||
}
|
||||
|
||||
export function listPromptTemplates(params?: { category?: string; code?: string; enabled?: boolean }): Promise<ApiResult<PromptTemplateResponse[]>> {
|
||||
return request({
|
||||
url: '/api/aihr/prompt-templates',
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function createPromptTemplate(data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
|
||||
return request({
|
||||
url: '/api/aihr/prompt-templates',
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePromptTemplate(id: number, data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
|
||||
return request({
|
||||
url: `/api/aihr/prompt-templates/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ export type SopSearchResponse = {
|
||||
snippets: SopSnippet[];
|
||||
training: string[];
|
||||
records: SopRecord[];
|
||||
keyPoints?: string[];
|
||||
cautions?: string[];
|
||||
reviewId?: number;
|
||||
};
|
||||
|
||||
export type SopReviewResponse = {
|
||||
id: number;
|
||||
usable: boolean | null;
|
||||
status: string;
|
||||
reviewedAt: string;
|
||||
};
|
||||
|
||||
export type SopUploadResponse = {
|
||||
@@ -49,7 +59,7 @@ export type SopUploadResponse = {
|
||||
snippets: SopSnippet[];
|
||||
};
|
||||
|
||||
export function searchKnowledgeSop(data: { queryText: string; category: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
|
||||
export function searchKnowledgeSop(data: { queryText: string; category: string; position?: string; source?: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
|
||||
return request({
|
||||
url: '/api/knowledge/search',
|
||||
method: 'post',
|
||||
@@ -68,6 +78,15 @@ export function uploadKnowledgeDoc(data: FormData): Promise<ApiResult<SopUploadR
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer?: string; note?: string }): Promise<ApiResult<SopReviewResponse>> {
|
||||
return request({
|
||||
url: `/api/knowledge/reviews/${id}`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export type UploadEnqueueResponse = {
|
||||
itemId: number;
|
||||
batchId: string;
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
<strong>AI 回答</strong>
|
||||
<p>{{ answerText }}</p>
|
||||
<span>引用:{{ referenceText }}</span>
|
||||
<div class="review-row">
|
||||
<small>{{ reviewMessage }}</small>
|
||||
<div>
|
||||
<el-button size="small" type="success" :loading="reviewSaving" :disabled="!canReview" @click="markReview(true)">可用</el-button>
|
||||
<el-button size="small" :loading="reviewSaving" :disabled="!canReview" @click="markReview(false)">不可用</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">点击检索后展示命中文档、原文片段和带引用的 AI 回答。</div>
|
||||
<div v-if="searched" class="doc-list">
|
||||
@@ -105,7 +112,7 @@ import { ElMessage } from 'element-plus';
|
||||
import type { UploadFile } from 'element-plus';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -203,6 +210,8 @@ const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
|
||||
const apiResult = ref<SopSearchResponse | null>(null);
|
||||
const uploading = ref(false);
|
||||
const uploadedRecords = ref<SopRecord[]>([]);
|
||||
const reviewSaving = ref(false);
|
||||
const reviewStatus = ref('');
|
||||
|
||||
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
|
||||
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
|
||||
@@ -220,6 +229,12 @@ const flowStatus = computed(() => {
|
||||
if (searched.value) return { label: `已命中 ${snippets.value.length} 段`, type: 'pill-primary' };
|
||||
return { label: '可引用 SOP 内容', type: 'pill-success' };
|
||||
});
|
||||
const canReview = computed(() => apiMode.value === 'api' && Boolean(apiResult.value?.reviewId));
|
||||
const reviewMessage = computed(() => {
|
||||
if (reviewStatus.value) return reviewStatus.value;
|
||||
if (canReview.value) return `待评审样本 #${apiResult.value?.reviewId}`;
|
||||
return '后端检索后可评审';
|
||||
});
|
||||
|
||||
const seedRecords = [
|
||||
{
|
||||
@@ -257,10 +272,12 @@ function selectCategory(item: Category) {
|
||||
trainingGenerated.value = false;
|
||||
apiMode.value = 'seed';
|
||||
apiResult.value = null;
|
||||
reviewStatus.value = '';
|
||||
}
|
||||
|
||||
async function runSearch() {
|
||||
trainingGenerated.value = false;
|
||||
reviewStatus.value = '';
|
||||
try {
|
||||
const { data } = await searchKnowledgeSop({
|
||||
queryText: queryText.value,
|
||||
@@ -279,6 +296,28 @@ async function runSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function markReview(usable: boolean) {
|
||||
const reviewId = apiResult.value?.reviewId;
|
||||
if (!reviewId) {
|
||||
ElMessage.warning('请先完成一次后端 SOP 检索');
|
||||
return;
|
||||
}
|
||||
reviewSaving.value = true;
|
||||
try {
|
||||
const { data } = await reviewKnowledgeSop(reviewId, {
|
||||
usable,
|
||||
reviewer: '管理端评审',
|
||||
note: usable ? '回答可用于试点问答' : '回答需补充或修正'
|
||||
});
|
||||
reviewStatus.value = `${data.status}:${data.usable ? '可用' : '不可用'} ${data.reviewedAt}`;
|
||||
ElMessage.success('SOP 问答评审已记录');
|
||||
} catch {
|
||||
ElMessage.error('SOP 问答评审失败');
|
||||
} finally {
|
||||
reviewSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDocSelected(uploadFile: UploadFile) {
|
||||
const raw = uploadFile.raw;
|
||||
if (!raw) return;
|
||||
@@ -442,6 +481,19 @@ async function generateTraining() {
|
||||
}
|
||||
}
|
||||
|
||||
.review-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
|
||||
small {
|
||||
color: #657184;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.training-card {
|
||||
padding: 14px;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<div class="head-actions">
|
||||
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
|
||||
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
|
||||
<el-button :loading="exportingPilot" @click="exportPilotCsv">导出试点CSV</el-button>
|
||||
<el-button @click="router.push('/index')">返回首页</el-button>
|
||||
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置演示</el-button>
|
||||
</div>
|
||||
@@ -30,7 +31,7 @@
|
||||
|
||||
<div class="select-card">
|
||||
<span>训练场景</span>
|
||||
<el-select v-model="scenarioId" class="scenario-select" @change="resetPractice">
|
||||
<el-select v-model="scenarioId" class="scenario-select" @change="handleScenarioChange">
|
||||
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
@@ -42,6 +43,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rubric-card">
|
||||
<div class="rubric-head">
|
||||
<strong>Rubric 预览</strong>
|
||||
<small>{{ selectedScenario.scores.length }} 个维度</small>
|
||||
</div>
|
||||
<p v-for="score in selectedScenario.scores" :key="score.label">
|
||||
<span>{{ score.label }}</span>
|
||||
<b>{{ score.value }}</b>
|
||||
<small>{{ score.note }}</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="trust-card">
|
||||
<div>
|
||||
<span>业主信任度</span>
|
||||
@@ -50,6 +63,14 @@
|
||||
<div class="meter"><i :style="{ width: `${trustScore}%` }"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="trust-card emotion-card">
|
||||
<div>
|
||||
<span>情绪波动</span>
|
||||
<strong>{{ emotionScore }}%</strong>
|
||||
</div>
|
||||
<div class="meter"><i :style="{ width: `${emotionScore}%` }"></i></div>
|
||||
</div>
|
||||
|
||||
<div class="action-row config-actions">
|
||||
<el-button type="danger" :disabled="started" :icon="Microphone" @click="startPractice">开始对练</el-button>
|
||||
<el-button :icon="Tickets" @click="showGoal = !showGoal">{{ showGoal ? '收起目标' : '查看目标' }}</el-button>
|
||||
@@ -59,6 +80,16 @@
|
||||
<strong>训练目标</strong>
|
||||
<p>{{ selectedScenario.goal }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="prepCard || prepLoading" class="prep-card">
|
||||
<strong>训练前预习卡</strong>
|
||||
<small>{{ prepLoading ? '加载中' : prepCard?.promptVersion }}</small>
|
||||
<template v-if="prepCard">
|
||||
<p>要点:{{ prepCard.keyPoints.join(' / ') }}</p>
|
||||
<p>红线:{{ prepCard.redLines.join(' / ') }}</p>
|
||||
<p>话术:{{ prepCard.scripts[0] }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel chat-panel">
|
||||
@@ -101,8 +132,16 @@
|
||||
|
||||
<div class="action-row">
|
||||
<el-button :disabled="finished" @click="continueRound">继续一轮</el-button>
|
||||
<el-button :disabled="sopHelpLoading" @click="askSopHelp">{{ sopHelpLoading ? '查询中' : '求助 SOP' }}</el-button>
|
||||
<el-button type="danger" :disabled="!canScore" @click="finishScore">结束并评分</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="sopHelp || sopHelpMessage" class="help-card">
|
||||
<strong>SOP 求助</strong>
|
||||
<p v-if="sopHelp">{{ sopHelp.answer }}</p>
|
||||
<p v-else>{{ sopHelpMessage }}</p>
|
||||
<small v-if="sopHelp?.reference">{{ sopHelp.reference }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
|
||||
@@ -129,6 +168,24 @@
|
||||
<strong>导师改写</strong>
|
||||
<p>{{ displayRewrite }}</p>
|
||||
</div>
|
||||
<div class="calibration-card">
|
||||
<div class="calibration-head">
|
||||
<strong>人工校准</strong>
|
||||
<small>{{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }}</small>
|
||||
</div>
|
||||
<el-input-number v-model="calibrationScore" :min="0" :max="100" :disabled="!canCalibrate" />
|
||||
<el-input
|
||||
v-model="calibrationReason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
resize="none"
|
||||
:disabled="!canCalibrate"
|
||||
placeholder="校准原因"
|
||||
/>
|
||||
<div class="action-row">
|
||||
<el-button type="danger" :loading="calibrationSaving" :disabled="!canCalibrate" @click="saveCalibration">记录校准样本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</section>
|
||||
@@ -147,15 +204,130 @@
|
||||
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel prompt-panel">
|
||||
<div class="panel-title title-with-extra">
|
||||
<div>
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<h2>提示词模板</h2>
|
||||
</div>
|
||||
<span>{{ promptTemplates.length }} 个模板</span>
|
||||
</div>
|
||||
|
||||
<div class="prompt-layout">
|
||||
<div class="prompt-list">
|
||||
<el-table v-loading="promptLoading" :data="promptTemplates" size="small" class="record-table" row-key="id">
|
||||
<el-table-column prop="code" label="Code" min-width="150" />
|
||||
<el-table-column prop="name" label="名称" min-width="130" />
|
||||
<el-table-column prop="category" label="分类" width="110" />
|
||||
<el-table-column prop="outputFormat" label="输出" width="118" />
|
||||
<el-table-column prop="version" label="版本" width="82" />
|
||||
<el-table-column label="状态" width="92">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" effect="plain">{{ row.enabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="editPromptTemplate(row)">编辑</el-button>
|
||||
<el-button link :type="row.enabled ? 'warning' : 'success'" @click="togglePromptTemplate(row)">
|
||||
{{ row.enabled ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" class="prompt-form">
|
||||
<div class="prompt-form-head">
|
||||
<strong>{{ promptFormMode }}</strong>
|
||||
<el-button text type="primary" @click="resetPromptTemplateForm">新建模板</el-button>
|
||||
</div>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Code">
|
||||
<el-input v-model="promptForm.code" placeholder="mentor_rewrite" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="promptForm.name" placeholder="导师改写" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="promptForm.category" class="full-control" filterable allow-create>
|
||||
<el-option v-for="category in promptCategoryOptions" :key="category" :label="category" :value="category" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="输出格式">
|
||||
<el-input v-model="promptForm.outputFormat" placeholder="json_card / text" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="版本">
|
||||
<el-input v-model="promptForm.version" placeholder="v1" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="系统提示词">
|
||||
<el-input v-model="promptForm.systemPrompt" type="textarea" :rows="3" resize="none" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板正文">
|
||||
<el-input v-model="promptForm.template" type="textarea" :rows="5" resize="none" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="变量">
|
||||
<el-input v-model="promptForm.variables" placeholder="scenarioName,goal,traineeText 或 JSON" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="promptForm.enabled" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="promptForm.description" type="textarea" :rows="2" resize="none" />
|
||||
</el-form-item>
|
||||
<div class="action-row prompt-actions">
|
||||
<el-button @click="loadPromptTemplates">刷新</el-button>
|
||||
<el-button type="danger" :loading="promptSaving" @click="savePromptTemplate">保存模板</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TrainPractice" lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { finishPracticeSession, startPracticeSession, submitPracticeTurn } from '@/api/aihr/practice';
|
||||
import FileSaver from 'file-saver';
|
||||
import {
|
||||
calibratePracticeSession,
|
||||
createPromptTemplate,
|
||||
exportPracticePilotCsv,
|
||||
finishPracticeSession,
|
||||
getPracticePrepCard,
|
||||
listPromptTemplates,
|
||||
listPracticeScenarios,
|
||||
startPracticeSession,
|
||||
submitPracticeTurn,
|
||||
updatePromptTemplate,
|
||||
type PromptTemplateRequest,
|
||||
type PromptTemplateResponse,
|
||||
type PracticePrepCardResponse,
|
||||
type PracticeScenarioResponse
|
||||
} from '@/api/aihr/practice';
|
||||
import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
|
||||
|
||||
type ChatRole = 'customer' | 'trainee' | 'coach';
|
||||
|
||||
@@ -175,6 +347,7 @@ type PracticeRound = {
|
||||
type ScenarioSeed = {
|
||||
id: string;
|
||||
name: string;
|
||||
position?: string;
|
||||
trainee: string;
|
||||
customer: string;
|
||||
project: string;
|
||||
@@ -193,9 +366,13 @@ type ScenarioSeed = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type PromptTemplateForm = PromptTemplateRequest & {
|
||||
id: number | null;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const scenarios: ScenarioSeed[] = [
|
||||
const fallbackScenarios: ScenarioSeed[] = [
|
||||
{
|
||||
id: 'complaint-water',
|
||||
name: '客服应对投诉',
|
||||
@@ -268,7 +445,8 @@ const baseRecords = [
|
||||
{ time: '07-02 08:47', trainee: '王五', scene: '突发事件处理', score: 72, status: '进行中', summary: '多方协调表达不够清晰,建议继续一轮。' }
|
||||
];
|
||||
|
||||
const scenarioId = ref(scenarios[0].id);
|
||||
const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]);
|
||||
const scenarioId = ref(fallbackScenarios[0].id);
|
||||
const started = ref(false);
|
||||
const finished = ref(false);
|
||||
const scored = ref(false);
|
||||
@@ -282,12 +460,43 @@ const apiTotal = ref<number | null>(null);
|
||||
const apiRewrite = ref('');
|
||||
const apiSummary = ref('');
|
||||
const apiTrust = ref<number | null>(null);
|
||||
const apiEmotion = ref<number | null>(null);
|
||||
const prepCard = ref<PracticePrepCardResponse | null>(null);
|
||||
const prepLoading = ref(false);
|
||||
const sopHelp = ref<SopSearchResponse | null>(null);
|
||||
const sopHelpLoading = ref(false);
|
||||
const sopHelpMessage = ref('');
|
||||
const conversation = ref<ChatTurn[]>([]);
|
||||
const records = ref([...baseRecords]);
|
||||
const promptTemplates = ref<PromptTemplateResponse[]>([]);
|
||||
const promptLoading = ref(false);
|
||||
const promptSaving = ref(false);
|
||||
const calibrationScore = ref(0);
|
||||
const calibrationReason = ref('管理端人工校准');
|
||||
const calibrationSaving = ref(false);
|
||||
const calibrationMessage = ref('');
|
||||
const exportingPilot = ref(false);
|
||||
const promptCategoryOptions = ['practice', 'knowledge', 'supervision'];
|
||||
const defaultPromptForm = (): PromptTemplateForm => ({
|
||||
id: null,
|
||||
code: 'mentor_rewrite',
|
||||
name: '导师改写',
|
||||
category: 'practice',
|
||||
outputFormat: 'text',
|
||||
systemPrompt: '',
|
||||
template: '',
|
||||
variables: 'scenarioName,goal,traineeText',
|
||||
version: 'v1',
|
||||
enabled: true,
|
||||
description: ''
|
||||
});
|
||||
const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
|
||||
|
||||
const selectedScenario = computed(() => scenarios.find((scenario) => scenario.id === scenarioId.value) ?? scenarios[0]);
|
||||
const selectedScenario = computed(() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0]);
|
||||
const promptFormMode = computed(() => (promptForm.value.id ? '编辑模板' : '新增模板'));
|
||||
const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length);
|
||||
const canScore = computed(() => finished.value && !scored.value);
|
||||
const canCalibrate = computed(() => scored.value && apiMode.value === 'api' && Boolean(sessionId.value));
|
||||
const displayScores = computed(() => apiScores.value ?? selectedScenario.value.scores);
|
||||
const displayTotal = computed(() => apiTotal.value ?? selectedScenario.value.total);
|
||||
const displayRewrite = computed(() => apiRewrite.value || selectedScenario.value.rewrite);
|
||||
@@ -304,6 +513,15 @@ const trustScore = computed(() => {
|
||||
}
|
||||
return selectedScenario.value.trustStart + completedRounds.value * 7;
|
||||
});
|
||||
const emotionScore = computed(() => {
|
||||
if (apiEmotion.value !== null) {
|
||||
return apiEmotion.value;
|
||||
}
|
||||
if (!started.value) {
|
||||
return 70;
|
||||
}
|
||||
return Math.max(45, 76 - completedRounds.value * 8);
|
||||
});
|
||||
const apiPill = computed(() => {
|
||||
if (apiMode.value === 'api') {
|
||||
return { text: '真实 API', className: 'pill-info' };
|
||||
@@ -341,6 +559,67 @@ const scenarioProfile = computed(() => [
|
||||
{ label: '学员', value: selectedScenario.value.trainee }
|
||||
]);
|
||||
|
||||
const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
position: item.position,
|
||||
trainee: '张三',
|
||||
customer: item.customerPersona,
|
||||
project: item.projectType || item.scenarioType,
|
||||
strategy: item.strategy,
|
||||
goal: item.goal || item.successCriteria,
|
||||
trustStart: Math.max(50, 70 - item.difficulty * 4),
|
||||
trustEnd: 80 + Math.min(item.difficulty, 3),
|
||||
rounds: [
|
||||
{
|
||||
customer: item.openingText || item.explicitNeed,
|
||||
sampleReply: '我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。',
|
||||
coachHint: item.successCriteria || '补上责任人、处理节点和回访方式。'
|
||||
},
|
||||
{
|
||||
customer: '那接下来谁负责、什么时候给我反馈?',
|
||||
sampleReply: '我会把责任人、处理节点和回访时间同步给您,过程有变化也会及时说明。',
|
||||
coachHint: '说明闭环节点,不要做未经确认的承诺。'
|
||||
}
|
||||
],
|
||||
scores:
|
||||
item.rubric?.dimensions?.map((dimension) => ({
|
||||
label: dimension.name,
|
||||
value: Math.round(dimension.weight * 100),
|
||||
note: dimension.description
|
||||
})) || fallbackScenarios[0].scores,
|
||||
total: 82,
|
||||
rewrite: '建议先承接情绪,再说明责任人、处理节点和反馈方式。',
|
||||
summary: item.successCriteria || '能按SOP给出明确闭环。'
|
||||
});
|
||||
|
||||
const loadPrepCard = async () => {
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
const { data } = await getPracticePrepCard(scenarioId.value);
|
||||
prepCard.value = data;
|
||||
} catch {
|
||||
prepCard.value = null;
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadScenarioOptions = async () => {
|
||||
try {
|
||||
const { data } = await listPracticeScenarios({ position: '生活顾问' });
|
||||
if (data?.length) {
|
||||
scenarios.value = data.map(mapApiScenario);
|
||||
if (!scenarios.value.some((scenario) => scenario.id === scenarioId.value)) {
|
||||
scenarioId.value = scenarios.value[0].id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
scenarios.value = [...fallbackScenarios];
|
||||
}
|
||||
await loadPrepCard();
|
||||
};
|
||||
|
||||
const appendTurn = (role: ChatRole, text: string) => {
|
||||
const nameMap = { customer: 'AI 业主', trainee: '学员', coach: 'AI 教练' };
|
||||
conversation.value.push({ id: `${role}-${conversation.value.length}`, role, name: nameMap[role], text });
|
||||
@@ -360,10 +639,21 @@ const resetPractice = () => {
|
||||
apiRewrite.value = '';
|
||||
apiSummary.value = '';
|
||||
apiTrust.value = null;
|
||||
apiEmotion.value = null;
|
||||
sopHelp.value = null;
|
||||
sopHelpMessage.value = '';
|
||||
calibrationScore.value = 0;
|
||||
calibrationReason.value = '管理端人工校准';
|
||||
calibrationMessage.value = '';
|
||||
conversation.value = [];
|
||||
records.value = [...baseRecords];
|
||||
};
|
||||
|
||||
const handleScenarioChange = () => {
|
||||
resetPractice();
|
||||
void loadPrepCard();
|
||||
};
|
||||
|
||||
const startPractice = async () => {
|
||||
resetPractice();
|
||||
const fallbackCustomer = selectedScenario.value.rounds[0].customer;
|
||||
@@ -376,6 +666,7 @@ const startPractice = async () => {
|
||||
sessionId.value = data.sessionId;
|
||||
apiMode.value = 'api';
|
||||
apiTrust.value = data.trust;
|
||||
apiEmotion.value = data.emotion;
|
||||
appendTurn('customer', data.customerText || fallbackCustomer);
|
||||
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
|
||||
} catch {
|
||||
@@ -408,8 +699,9 @@ const continueRound = async () => {
|
||||
roundIndex: activeRound.value,
|
||||
traineeText
|
||||
});
|
||||
appendTurn('coach', data.coachHint || round.coachHint);
|
||||
appendTurn('coach', data.redFlag ? data.coachHint || '红线纠偏:先承接情绪,再给责任人与反馈节点。' : data.coachHint || round.coachHint);
|
||||
apiTrust.value = data.trust;
|
||||
apiEmotion.value = data.emotion;
|
||||
activeRound.value = Math.min(data.roundIndex ?? activeRound.value + 1, selectedScenario.value.rounds.length);
|
||||
if (data.finished || activeRound.value >= selectedScenario.value.rounds.length) {
|
||||
finished.value = true;
|
||||
@@ -434,6 +726,29 @@ const continueRound = async () => {
|
||||
appendTurn('customer', selectedScenario.value.rounds[activeRound.value].customer);
|
||||
};
|
||||
|
||||
const askSopHelp = async () => {
|
||||
sopHelpLoading.value = true;
|
||||
sopHelpMessage.value = '';
|
||||
sopHelp.value = null;
|
||||
try {
|
||||
const { data } = await searchKnowledgeSop({
|
||||
queryText: `${selectedScenario.value.name} ${selectedScenario.value.goal}`,
|
||||
category: 'sop',
|
||||
position: selectedScenario.value.position || '生活顾问',
|
||||
source: 'practice_help',
|
||||
limit: 3
|
||||
});
|
||||
if (!data?.answer) {
|
||||
throw new Error('SOP 未返回可用答案');
|
||||
}
|
||||
sopHelp.value = data;
|
||||
} catch (error) {
|
||||
sopHelpMessage.value = error instanceof Error ? error.message : 'SOP 求助失败,请稍后重试';
|
||||
} finally {
|
||||
sopHelpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const finishScore = async () => {
|
||||
let nextRecord = {
|
||||
time: '07-02 10:42',
|
||||
@@ -470,11 +785,167 @@ const finishScore = async () => {
|
||||
}
|
||||
|
||||
scored.value = true;
|
||||
calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0)));
|
||||
records.value = [nextRecord, ...baseRecords];
|
||||
if (apiMode.value !== 'api') {
|
||||
ElMessage.success('考官评分已生成,并写入本地对练记录');
|
||||
}
|
||||
};
|
||||
|
||||
const saveCalibration = async () => {
|
||||
if (!canCalibrate.value) {
|
||||
ElMessage.warning('请先完成一次真实 API 对练评分');
|
||||
return;
|
||||
}
|
||||
calibrationSaving.value = true;
|
||||
try {
|
||||
const { data } = await calibratePracticeSession(sessionId.value, {
|
||||
reviewer: '管理端校准',
|
||||
correctedTotal: calibrationScore.value,
|
||||
reason: calibrationReason.value.trim() || '管理端人工校准'
|
||||
});
|
||||
calibrationMessage.value = `已记录:AI ${data.originalScore} / 人工 ${data.correctedScore}`;
|
||||
ElMessage.success('校准样本已记录');
|
||||
} catch {
|
||||
ElMessage.error('校准样本记录失败,请检查权限或会话状态');
|
||||
} finally {
|
||||
calibrationSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const exportPilotCsv = async () => {
|
||||
exportingPilot.value = true;
|
||||
try {
|
||||
const data = await exportPracticePilotCsv();
|
||||
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), 'aihr-practice-pilot.csv');
|
||||
ElMessage.success('试点数据 CSV 已导出');
|
||||
} catch {
|
||||
ElMessage.error('试点数据导出失败,请检查权限');
|
||||
} finally {
|
||||
exportingPilot.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const promptTemplateToForm = (template: PromptTemplateResponse): PromptTemplateForm => ({
|
||||
id: template.id,
|
||||
code: template.code || '',
|
||||
name: template.name || '',
|
||||
category: template.category || 'practice',
|
||||
outputFormat: template.outputFormat || 'text',
|
||||
systemPrompt: template.systemPrompt || '',
|
||||
template: template.template || '',
|
||||
variables: template.variables || '',
|
||||
version: template.version || 'v1',
|
||||
enabled: Boolean(template.enabled),
|
||||
description: template.description || ''
|
||||
});
|
||||
|
||||
const promptFormToRequest = (form: PromptTemplateForm): PromptTemplateRequest => ({
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
category: form.category.trim() || 'practice',
|
||||
outputFormat: form.outputFormat.trim() || 'text',
|
||||
systemPrompt: form.systemPrompt.trim(),
|
||||
template: form.template.trim(),
|
||||
variables: form.variables.trim(),
|
||||
version: form.version.trim() || 'v1',
|
||||
enabled: form.enabled,
|
||||
description: form.description.trim()
|
||||
});
|
||||
|
||||
const validatePromptVariables = (variables: string) => {
|
||||
const value = variables.trim();
|
||||
if (!value || !/^[{[]/.test(value)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePromptForm = () => {
|
||||
if (!promptForm.value.code.trim() || !promptForm.value.name.trim()) {
|
||||
ElMessage.warning('请填写模板 Code 和名称');
|
||||
return false;
|
||||
}
|
||||
if (!promptForm.value.template.trim()) {
|
||||
ElMessage.warning('请填写模板正文');
|
||||
return false;
|
||||
}
|
||||
if (!validatePromptVariables(promptForm.value.variables)) {
|
||||
ElMessage.warning('变量使用 JSON 时必须是合法 JSON;逗号分隔变量可直接填写');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadPromptTemplates = async () => {
|
||||
promptLoading.value = true;
|
||||
try {
|
||||
const { data } = await listPromptTemplates();
|
||||
promptTemplates.value = data || [];
|
||||
} catch {
|
||||
promptTemplates.value = [];
|
||||
ElMessage.warning('提示词模板加载失败');
|
||||
} finally {
|
||||
promptLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const editPromptTemplate = (template: PromptTemplateResponse) => {
|
||||
promptForm.value = promptTemplateToForm(template);
|
||||
};
|
||||
|
||||
const resetPromptTemplateForm = () => {
|
||||
promptForm.value = defaultPromptForm();
|
||||
};
|
||||
|
||||
const savePromptTemplate = async () => {
|
||||
if (!validatePromptForm()) {
|
||||
return;
|
||||
}
|
||||
promptSaving.value = true;
|
||||
try {
|
||||
const requestData = promptFormToRequest(promptForm.value);
|
||||
if (promptForm.value.id) {
|
||||
const { data } = await updatePromptTemplate(promptForm.value.id, requestData);
|
||||
promptForm.value = promptTemplateToForm(data);
|
||||
ElMessage.success('提示词模板已更新');
|
||||
} else {
|
||||
const { data } = await createPromptTemplate(requestData);
|
||||
promptForm.value = promptTemplateToForm(data);
|
||||
ElMessage.success('提示词模板已创建');
|
||||
}
|
||||
await loadPromptTemplates();
|
||||
} catch {
|
||||
ElMessage.error('提示词模板保存失败,请检查权限或字段唯一性');
|
||||
} finally {
|
||||
promptSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const togglePromptTemplate = async (template: PromptTemplateResponse) => {
|
||||
promptSaving.value = true;
|
||||
try {
|
||||
const nextForm = promptTemplateToForm(template);
|
||||
nextForm.enabled = !template.enabled;
|
||||
await updatePromptTemplate(template.id, promptFormToRequest(nextForm));
|
||||
await loadPromptTemplates();
|
||||
ElMessage.success(template.enabled ? '提示词模板已停用' : '提示词模板已启用');
|
||||
} catch {
|
||||
ElMessage.error('提示词模板状态更新失败');
|
||||
} finally {
|
||||
promptSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadScenarioOptions();
|
||||
void loadPromptTemplates();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -613,10 +1084,14 @@ const finishScore = async () => {
|
||||
|
||||
.select-card,
|
||||
.field-item,
|
||||
.rubric-card,
|
||||
.reply-box,
|
||||
.coach-card,
|
||||
.calibration-card,
|
||||
.trust-card,
|
||||
.goal-card {
|
||||
.goal-card,
|
||||
.prep-card,
|
||||
.help-card {
|
||||
padding: 12px;
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 8px;
|
||||
@@ -657,8 +1132,47 @@ const finishScore = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.rubric-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rubric-head,
|
||||
.rubric-card p {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 10px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rubric-head small,
|
||||
.rubric-card p small {
|
||||
grid-column: 1 / -1;
|
||||
color: #7a8597;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rubric-card p {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e5eaf3;
|
||||
|
||||
span {
|
||||
color: #22314a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
b {
|
||||
color: #d70f20;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-card,
|
||||
.goal-card {
|
||||
.goal-card,
|
||||
.prep-card,
|
||||
.help-card {
|
||||
margin-top: 12px;
|
||||
|
||||
p {
|
||||
@@ -666,6 +1180,13 @@ const finishScore = async () => {
|
||||
color: #46566e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #7a8597;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-card div:first-child {
|
||||
@@ -692,6 +1213,10 @@ const finishScore = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.emotion-card .meter i {
|
||||
background: linear-gradient(90deg, #31b367, #ffb020, #f65f61);
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -818,6 +1343,23 @@ const finishScore = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.calibration-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.calibration-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
|
||||
small {
|
||||
color: #647084;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -857,10 +1399,65 @@ const finishScore = async () => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prompt-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.prompt-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(520px, 1.2fr) minmax(360px, 0.8fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.prompt-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prompt-form {
|
||||
padding: 14px;
|
||||
border: 1px solid #e5eaf3;
|
||||
border-radius: 8px;
|
||||
background: #f9fbfe;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-textarea__inner) {
|
||||
line-height: 1.65;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
strong {
|
||||
color: #22314a;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.full-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt-actions {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.practice-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.prompt-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -877,6 +1474,15 @@ const finishScore = async () => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.prompt-form {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.prompt-form :deep(.el-col) {
|
||||
max-width: 100%;
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.score-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
+905
-105
File diff suppressed because it is too large
Load Diff
+69
-2
@@ -31,6 +31,47 @@ check_url() {
|
||||
echo "OK: $name"
|
||||
}
|
||||
|
||||
pilot_sql_scalar() {
|
||||
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 -N -B -D ry-vue -e "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
check_pilot_samples() {
|
||||
if ! command -v docker >/dev/null || ! docker ps --format '{{.Names}}' | grep -qx 'wygj-mysql'; then
|
||||
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || fail "pilot DB sample counts unavailable (wygj-mysql not running)"
|
||||
echo "SKIP: pilot DB sample counts (wygj-mysql not running)"
|
||||
return
|
||||
fi
|
||||
|
||||
local scenario_count calibration_stats calibration_count calibration_matched sop_total sop_usable
|
||||
scenario_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = '000000' AND position = '生活顾问' AND enabled = 1")"
|
||||
calibration_stats="$(pilot_sql_scalar "
|
||||
SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN (CASE WHEN c.original_score < 60 THEN 0 WHEN c.original_score < 75 THEN 1 WHEN c.original_score < 90 THEN 2 ELSE 3 END)
|
||||
= (CASE WHEN c.corrected_score < 60 THEN 0 WHEN c.corrected_score < 75 THEN 1 WHEN c.corrected_score < 90 THEN 2 ELSE 3 END)
|
||||
THEN 1 ELSE 0 END), 0) AS matched
|
||||
FROM aihr_practice_calibration c
|
||||
JOIN (
|
||||
SELECT session_id, MAX(id) AS id
|
||||
FROM aihr_practice_calibration
|
||||
WHERE tenant_id = '000000' AND original_score IS NOT NULL AND corrected_score IS NOT NULL
|
||||
GROUP BY session_id
|
||||
) latest ON latest.id = c.id
|
||||
WHERE c.tenant_id = '000000'")"
|
||||
read -r calibration_count calibration_matched <<<"$calibration_stats"
|
||||
sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review")"
|
||||
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review")"
|
||||
echo "OK: pilot gates scenarios=${scenario_count}/12 calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total}"
|
||||
|
||||
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
|
||||
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
|
||||
(( calibration_count >= 20 )) || fail "pilot calibration samples ${calibration_count}/20"
|
||||
(( calibration_matched * 100 >= calibration_count * 70 )) || fail "pilot AI/human band consistency below 70%"
|
||||
(( sop_total > 0 )) || fail "pilot SOP review samples missing"
|
||||
(( sop_usable * 100 >= sop_total * 80 )) || fail "pilot SOP usable rate below 80%"
|
||||
}
|
||||
}
|
||||
|
||||
need curl
|
||||
|
||||
check_url "frontend" "$FRONTEND_URL/"
|
||||
@@ -52,7 +93,7 @@ contains frontend/src/router/index.ts "/system/model"
|
||||
|
||||
contains mobile/src/App.vue "早上好,王敏"
|
||||
contains mobile/src/App.vue "AI结构化面试"
|
||||
contains mobile/src/App.vue "团队今日概况"
|
||||
contains mobile/src/App.vue "团队今日动态"
|
||||
contains mobile/src/App.vue "sendSmsCode"
|
||||
contains mobile/src/App.vue "/dev-api/resource/sms/code"
|
||||
contains mobile/src/App.vue "/dev-api/auth/mobile/sms-login"
|
||||
@@ -75,17 +116,41 @@ contains frontend/src/api/aihr/practice.ts "/api/train/practice/start"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/api/train/practice"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "mobileCompletedCount"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "aihr_practice_session"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_scenario"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_prompt_template"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "summary_card"
|
||||
contains frontend/src/views/train/practice.vue "训练前预习卡"
|
||||
contains frontend/src/views/train/practice.vue "Rubric 预览"
|
||||
contains frontend/src/views/train/practice.vue "提示词模板"
|
||||
contains frontend/src/views/train/practice.vue "导出试点CSV"
|
||||
contains frontend/src/api/aihr/practice.ts "/api/train/practice/export"
|
||||
contains frontend/src/api/aihr/practice.ts '/api/train/practice/scenarios/${id}/enabled'
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java "/scenarios/{id}/enabled"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java "String position"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java 'firstNonBlank(position, "生活顾问")'
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeSearchSource"
|
||||
contains frontend/src/views/train/practice.vue "practice_help"
|
||||
contains mobile/src/App.vue "practice_help"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
|
||||
contains mobile/src/App.vue "新员工每日三题"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "incentive_point"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "cost-guard-fallback"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
|
||||
contains mobile/src/App.vue "贡献度加分"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
|
||||
contains mobile/src/App.vue "标记已复盘"
|
||||
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/upload"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
|
||||
contains frontend/src/api/aihr/sop.ts "/api/knowledge/search"
|
||||
contains frontend/src/api/aihr/sop.ts "/api/knowledge/doc/upload"
|
||||
contains frontend/src/api/aihr/sop.ts "/api/knowledge/reviews"
|
||||
contains frontend/src/views/knowledge/sop.vue "SOP 问答评审已记录"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "/api/knowledge"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "/doc/upload"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx 文件"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "/embeddings"
|
||||
contains backend/script/sql/aihr_knowledge_mysql8.sql "embedding_json"
|
||||
contains frontend/src/views/knowledge/sop.vue "上传文档"
|
||||
@@ -96,6 +161,7 @@ contains backend/script/sql/aihr_knowledge_mysql8.sql "aihr_knowledge_fragment"
|
||||
contains backend/script/sql/aihr_model_mysql8.sql "aihr_model_config"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_session"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "dialogue_json"
|
||||
contains backend/script/sql/aihr_practice_mysql8.sql "incentive_point"
|
||||
contains backend/script/sql/aihr_model_mysql8.sql "api_key"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrModelController.java "/api/aihr/model"
|
||||
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrModelController.java "/providers/{providerCode}"
|
||||
@@ -119,4 +185,5 @@ echo "OK: ruoyi-ai incremental migration markers"
|
||||
echo "OK: AI model migration markers"
|
||||
echo "OK: AI model config page markers"
|
||||
echo "OK: AI mobile role home markers"
|
||||
check_pilot_samples
|
||||
echo "PASS: demo preflight"
|
||||
|
||||
Reference in New Issue
Block a user