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
@@ -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`);