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;