feat(aihr): close phase two practice pilot gates

This commit is contained in:
2026-07-08 00:51:32 +08:00
parent 1d14497bc3
commit 4b10b08eeb
27 changed files with 4807 additions and 206 deletions
+1 -1
View File
@@ -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`。
+1 -1
View File
@@ -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` 拉取公司、部门、员工快照并刷新本地组织人员口径。
@@ -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()
);
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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));
@@ -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());
}
}
}
}
}
@@ -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
) {
}
}
@@ -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
) {
}
@@ -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, "");
}
}
}
@@ -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";
@@ -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("""
@@ -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);
@@ -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;
@@ -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;
+352
View File
@@ -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
View File
@@ -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. 二期完成定义
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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。
+152
View File
@@ -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
});
}
+20 -1
View File
@@ -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;
+53 -1
View File
@@ -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;
+615 -9
View File
@@ -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;
}
+903 -103
View File
File diff suppressed because it is too large Load Diff
+69 -2
View File
@@ -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"