feat(aihr): close phase two practice pilot gates
This commit is contained in:
+80
-6
@@ -2,15 +2,27 @@ package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.service.AihrMobileSeedService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -22,7 +34,6 @@ import java.util.Map;
|
||||
/**
|
||||
* Mobile H5 home APIs.
|
||||
*/
|
||||
@SaIgnore
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/aihr/mobile")
|
||||
@@ -30,6 +41,7 @@ public class AihrMobileController {
|
||||
|
||||
private final AihrMobileSeedService mobileSeedService;
|
||||
|
||||
@SaIgnore
|
||||
@GetMapping("/home/{role}")
|
||||
public R<HomeResponse> home(@PathVariable String role) {
|
||||
return R.ok(mobileSeedService.home(role));
|
||||
@@ -37,7 +49,7 @@ public class AihrMobileController {
|
||||
|
||||
@GetMapping("/practice/history")
|
||||
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.practiceHistory(extPartyId));
|
||||
return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@GetMapping("/practice/reviews")
|
||||
@@ -52,13 +64,61 @@ public class AihrMobileController {
|
||||
}
|
||||
|
||||
@PostMapping("/practice/reviews/{id}/reviewed")
|
||||
public R<Void> markReviewed(@PathVariable Long id) {
|
||||
return mobileSeedService.markReviewed(id) ? R.ok() : R.fail("复盘记录不存在");
|
||||
public R<Void> markReviewed(@PathVariable Long id, @RequestBody(required = false) ReviewAdviceRequest request) {
|
||||
return mobileSeedService.markReviewed(id, request) ? R.ok() : R.fail("复盘记录不存在");
|
||||
}
|
||||
|
||||
@GetMapping("/profile")
|
||||
public R<CompetencyResponse> profile(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.profile(extPartyId));
|
||||
return R.ok(mobileSeedService.profile(ownMobileExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@GetMapping("/profile/promotion-evidence")
|
||||
public R<PromotionEvidenceResponse> promotionEvidence(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.promotionEvidence(ownMobileExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@GetMapping("/practice/alerts")
|
||||
public R<List<PracticeAlertResponse>> practiceAlerts() {
|
||||
return R.ok(mobileSeedService.practiceAlerts());
|
||||
}
|
||||
|
||||
@GetMapping("/practice/assignments")
|
||||
public R<List<PracticeAssignmentResponse>> practiceAssignments(@RequestParam(required = false) String extPartyId) {
|
||||
return R.ok(mobileSeedService.practiceAssignments(scopedAssignmentExtPartyId(extPartyId)));
|
||||
}
|
||||
|
||||
@PostMapping("/practice/assignments")
|
||||
public R<PracticeAssignmentResponse> createPracticeAssignment(@RequestBody PracticeAssignmentRequest request) {
|
||||
return R.ok(mobileSeedService.createPracticeAssignment(request));
|
||||
}
|
||||
|
||||
@PostMapping("/practice/assignments/{id}/answer")
|
||||
public R<DailyDrillAnswerResponse> answerDailyDrill(@PathVariable Long id, @RequestBody(required = false) DailyDrillAnswerRequest request) {
|
||||
DailyDrillAnswerResponse response = mobileSeedService.answerDailyDrill(id, request, ownMobileExtPartyId(null));
|
||||
return response == null ? R.fail("每日三题不存在") : R.ok(response);
|
||||
}
|
||||
|
||||
private static String ownMobileExtPartyId(String requested) {
|
||||
String username = currentAppUsername();
|
||||
return username.isBlank() ? requested : username;
|
||||
}
|
||||
|
||||
private static String scopedAssignmentExtPartyId(String requested) {
|
||||
if (requested == null || requested.isBlank()) {
|
||||
// ponytail: supervisor H5 keeps team-scope listing until mobile roles exist.
|
||||
return requested;
|
||||
}
|
||||
return ownMobileExtPartyId(requested);
|
||||
}
|
||||
|
||||
private static String currentAppUsername() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return "";
|
||||
}
|
||||
String username = loginUser.getUsername();
|
||||
return username == null ? "" : username.trim();
|
||||
}
|
||||
|
||||
private static Map<String, Object> reviewDetailPayload(ReviewDetailResponse detail) {
|
||||
@@ -73,6 +133,8 @@ public class AihrMobileController {
|
||||
data.put("summary", detail.summary());
|
||||
data.put("mentorRewrite", detail.mentorRewrite());
|
||||
data.put("aiComment", detail.aiComment());
|
||||
data.put("reviewAdvice", detail.reviewAdvice());
|
||||
data.put("incentivePoint", detail.incentivePoint());
|
||||
data.put("scoreItems", detail.scoreItems().stream().map(item -> Map.of(
|
||||
"label", item.label(),
|
||||
"value", item.value(),
|
||||
@@ -81,8 +143,20 @@ public class AihrMobileController {
|
||||
data.put("dialogue", detail.dialogue().stream().map(turn -> Map.of(
|
||||
"role", turn.role(),
|
||||
"label", turn.label(),
|
||||
"text", turn.text()
|
||||
"text", turn.text(),
|
||||
"audioUrl", turn.audioUrl()
|
||||
)).toList());
|
||||
data.put("annotations", detail.annotations().stream().map(AihrMobileController::annotationPayload).toList());
|
||||
return data;
|
||||
}
|
||||
|
||||
private static Map<String, Object> annotationPayload(AnnotationResponse annotation) {
|
||||
return Map.of(
|
||||
"turnIndex", annotation.turnIndex(),
|
||||
"sourceText", annotation.sourceText(),
|
||||
"issue", annotation.issue(),
|
||||
"rewrite", annotation.rewrite(),
|
||||
"sopPoint", annotation.sopPoint()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+102
-1
@@ -1,19 +1,43 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
|
||||
import org.dromara.aihr.service.AihrPracticeSeedService;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 三角色对练 API。
|
||||
*/
|
||||
@@ -22,11 +46,55 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RequestMapping("/api/train/practice")
|
||||
public class AihrPracticeController {
|
||||
|
||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||
|
||||
private final AihrPracticeSeedService practiceSeedService;
|
||||
|
||||
@GetMapping("/scenarios")
|
||||
public R<List<ScenarioResponse>> scenarios(@RequestParam(required = false) String position,
|
||||
@RequestParam(required = false) String scenarioType) {
|
||||
return R.ok(practiceSeedService.scenarios(position, scenarioType));
|
||||
}
|
||||
|
||||
@GetMapping("/scenarios/{id}")
|
||||
public R<ScenarioResponse> scenario(@PathVariable String id) {
|
||||
return R.ok(practiceSeedService.scenario(id));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping("/scenarios")
|
||||
public R<ScenarioResponse> saveScenario(@RequestBody ScenarioSaveRequest request) {
|
||||
return R.ok(practiceSeedService.saveScenario(request));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PatchMapping("/scenarios/{id}/enabled")
|
||||
public R<Void> updateScenarioEnabled(@PathVariable String id, @RequestBody ScenarioEnabledRequest request) {
|
||||
return practiceSeedService.updateScenarioEnabled(id, request) ? R.ok() : R.fail("训练场景不存在");
|
||||
}
|
||||
|
||||
@GetMapping("/scenarios/{id}/prep-card")
|
||||
public R<PrepCardResponse> prepCard(@PathVariable String id) {
|
||||
return R.ok(practiceSeedService.prepCard(id));
|
||||
}
|
||||
|
||||
@GetMapping("/rubrics")
|
||||
public R<List<RubricResponse>> rubrics(@RequestParam(required = false) String scenarioId) {
|
||||
return R.ok(practiceSeedService.rubrics(scenarioId));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@GetMapping("/export")
|
||||
public ResponseEntity<String> export() {
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=aihr-practice-pilot.csv")
|
||||
.contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
|
||||
.body(practiceSeedService.exportPilotCsv());
|
||||
}
|
||||
|
||||
@PostMapping("/start")
|
||||
public R<StartResponse> start(@RequestBody StartRequest request) {
|
||||
return R.ok(practiceSeedService.start(request));
|
||||
return R.ok(practiceSeedService.start(bindMobileStartIdentity(request)));
|
||||
}
|
||||
|
||||
@PostMapping("/turn")
|
||||
@@ -38,4 +106,37 @@ public class AihrPracticeController {
|
||||
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
|
||||
return R.ok(practiceSeedService.finish(request));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/annotations")
|
||||
public R<List<AnnotationResponse>> annotations(@PathVariable String sessionId, @RequestBody(required = false) AnnotationSaveRequest request) {
|
||||
List<AnnotationResponse> annotations = practiceSeedService.saveAnnotations(sessionId, request);
|
||||
return annotations.isEmpty() ? R.fail("训练会话不存在") : R.ok(annotations);
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping("/sessions/{sessionId}/calibration")
|
||||
public R<CalibrationResponse> calibration(@PathVariable String sessionId, @RequestBody(required = false) CalibrationRequest request) {
|
||||
CalibrationResponse response = practiceSeedService.calibrate(sessionId, request);
|
||||
return response == null ? R.fail("训练会话不存在") : R.ok(response);
|
||||
}
|
||||
|
||||
private static StartRequest bindMobileStartIdentity(StartRequest request) {
|
||||
if (request == null || !"mobile".equalsIgnoreCase(request.mode())) {
|
||||
return request;
|
||||
}
|
||||
String username = currentAppUsername();
|
||||
if (username.isBlank()) {
|
||||
return request;
|
||||
}
|
||||
return new StartRequest(username, request.scenarioId(), request.mode());
|
||||
}
|
||||
|
||||
private static String currentAppUsername() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return "";
|
||||
}
|
||||
String username = loginUser.getUsername();
|
||||
return username == null ? "" : username.trim();
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromptTemplateResponse;
|
||||
import org.dromara.aihr.service.AihrPracticeSeedService;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI HR 提示词模板 API。
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/aihr/prompt-templates")
|
||||
public class AihrPromptTemplateController {
|
||||
|
||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||
|
||||
private final AihrPracticeSeedService practiceSeedService;
|
||||
|
||||
@GetMapping
|
||||
public R<List<PromptTemplateResponse>> list(@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String code,
|
||||
@RequestParam(required = false) Boolean enabled) {
|
||||
return R.ok(practiceSeedService.promptTemplates(category, code, enabled));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PostMapping
|
||||
public R<PromptTemplateResponse> create(@RequestBody PromptTemplateRequest request) {
|
||||
return R.ok(practiceSeedService.savePromptTemplate(request));
|
||||
}
|
||||
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
|
||||
@PutMapping("/{id}")
|
||||
public R<PromptTemplateResponse> update(@PathVariable Long id, @RequestBody PromptTemplateRequest request) {
|
||||
PromptTemplateResponse response = practiceSeedService.updatePromptTemplate(id, request);
|
||||
return response == null ? R.fail("提示词模板不存在") : R.ok(response);
|
||||
}
|
||||
}
|
||||
+8
@@ -7,6 +7,8 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
|
||||
@@ -63,6 +65,12 @@ public class AihrSopController {
|
||||
return R.ok(sopSeedService.search(request));
|
||||
}
|
||||
|
||||
@PostMapping("/reviews/{id}")
|
||||
public R<SopReviewResponse> review(@PathVariable Long id, @RequestBody(required = false) SopReviewRequest request) {
|
||||
SopReviewResponse response = sopSeedService.reviewSearch(id, request);
|
||||
return response == null ? R.fail("SOP问答评审记录不存在") : R.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping("/summary-card")
|
||||
public R<SummaryCardResponse> summaryCard(@RequestBody SummaryCardRequest request) {
|
||||
return R.ok(sopSeedService.summaryCard(request));
|
||||
|
||||
+36
-2
@@ -1,11 +1,14 @@
|
||||
package org.dromara.aihr.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.AsrResponse;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
|
||||
import org.dromara.aihr.service.AihrSpeechService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -14,12 +17,16 @@ import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/ai")
|
||||
@@ -30,6 +37,7 @@ public class AihrSpeechController {
|
||||
private static final int MAX_TTS_CHARS = 300;
|
||||
|
||||
private final AihrSpeechService speechService;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@PostMapping(value = "/asr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<AsrResponse> asr(@RequestPart("file") MultipartFile file) throws IOException {
|
||||
@@ -43,7 +51,10 @@ public class AihrSpeechController {
|
||||
return R.fail("音频过大(上限 5MB),请缩短录音时长");
|
||||
}
|
||||
return speechService.transcribe(file.getBytes(), file.getOriginalFilename(), file.getContentType())
|
||||
.map(text -> R.ok(new AsrResponse(text, "openai-compatible")))
|
||||
.map(text -> {
|
||||
SysOssVo oss = ossService.upload(file);
|
||||
return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl()));
|
||||
})
|
||||
.orElseGet(() -> R.fail("语音识别失败,请改用文字输入"));
|
||||
}
|
||||
|
||||
@@ -60,7 +71,30 @@ public class AihrSpeechController {
|
||||
text = text.substring(0, MAX_TTS_CHARS);
|
||||
}
|
||||
return speechService.synthesize(text, request == null ? null : request.voice())
|
||||
.map(audio -> R.ok(new TtsResponse("data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio), "openai-compatible")))
|
||||
.map(this::storeTtsAudio)
|
||||
.orElseGet(() -> R.fail("语音合成失败,已降级为文本展示"));
|
||||
}
|
||||
|
||||
private R<TtsResponse> storeTtsAudio(byte[] audio) {
|
||||
String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio);
|
||||
Path tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("aihr-tts-", ".mp3");
|
||||
Files.write(tempFile, audio);
|
||||
File file = tempFile.toFile();
|
||||
SysOssVo oss = ossService.upload(file);
|
||||
return R.ok(new TtsResponse(oss.getUrl(), "openai-compatible", oss.getOssId(), inlineAudioUrl));
|
||||
} catch (Exception e) {
|
||||
log.warn("tts audio upload failed: {}", e.getMessage());
|
||||
return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible"));
|
||||
} finally {
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("delete tts temp file failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+181
-4
@@ -26,7 +26,62 @@ public final class AihrPracticeDto {
|
||||
public record PersonaResponse(String customer, String project, String strategy, String goal) {
|
||||
}
|
||||
|
||||
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl) {
|
||||
public record ScenarioResponse(
|
||||
String id,
|
||||
String name,
|
||||
String position,
|
||||
String scenarioType,
|
||||
String projectType,
|
||||
Integer difficulty,
|
||||
String customerPersona,
|
||||
String openingText,
|
||||
String explicitNeed,
|
||||
String hiddenNeed,
|
||||
String irritants,
|
||||
String delights,
|
||||
String sopRefs,
|
||||
String successCriteria,
|
||||
String strategy,
|
||||
String goal,
|
||||
Boolean enabled,
|
||||
RubricResponse rubric
|
||||
) {
|
||||
}
|
||||
|
||||
public record ScenarioSaveRequest(
|
||||
String id,
|
||||
String name,
|
||||
String position,
|
||||
String scenarioType,
|
||||
String projectType,
|
||||
Integer difficulty,
|
||||
String customerPersona,
|
||||
String openingText,
|
||||
String explicitNeed,
|
||||
String hiddenNeed,
|
||||
String irritants,
|
||||
String delights,
|
||||
String sopRefs,
|
||||
String successCriteria,
|
||||
String strategy,
|
||||
String goal,
|
||||
Boolean enabled
|
||||
) {
|
||||
}
|
||||
|
||||
public record ScenarioEnabledRequest(Boolean enabled) {
|
||||
}
|
||||
|
||||
public record RubricResponse(String code, String name, String version, List<RubricDimensionResponse> dimensions) {
|
||||
}
|
||||
|
||||
public record RubricDimensionResponse(String code, String name, Double weight, String description) {
|
||||
}
|
||||
|
||||
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
|
||||
}
|
||||
|
||||
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId) {
|
||||
}
|
||||
|
||||
public record TurnResponse(
|
||||
@@ -35,6 +90,7 @@ public final class AihrPracticeDto {
|
||||
Integer emotion,
|
||||
Integer trust,
|
||||
String coachHint,
|
||||
Boolean redFlag,
|
||||
Integer roundIndex,
|
||||
Boolean finished
|
||||
) {
|
||||
@@ -59,7 +115,37 @@ public final class AihrPracticeDto {
|
||||
public record RecordResponse(Long id, String sessionId, String time, String trainee, String scene, Integer score, String status, String summary) {
|
||||
}
|
||||
|
||||
public record DialogueResponse(String role, String label, String text) {
|
||||
public record DialogueResponse(String role, String label, String text, String audioUrl, Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
|
||||
public DialogueResponse(String role, String label, String text) {
|
||||
this(role, label, text, "", null, null, null, "");
|
||||
}
|
||||
|
||||
public DialogueResponse(String role, String label, String text, String audioUrl) {
|
||||
this(role, label, text, audioUrl, null, null, null, "");
|
||||
}
|
||||
}
|
||||
|
||||
public record AnnotationResponse(Integer turnIndex, String sourceText, String issue, String rewrite, String sopPoint) {
|
||||
}
|
||||
|
||||
public record AnnotationSaveRequest(List<AnnotationResponse> annotations) {
|
||||
}
|
||||
|
||||
public record CalibrationRequest(
|
||||
String reviewer,
|
||||
Integer correctedTotal,
|
||||
Integer correctedCompliance,
|
||||
Integer correctedEmotion,
|
||||
Integer correctedCommunication,
|
||||
Integer correctedMarketing,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
|
||||
public record CalibrationResponse(Long id, String sessionId, Integer originalScore, Integer correctedScore, String reviewer, String reason, String status, String createTime) {
|
||||
}
|
||||
|
||||
public record ReviewAdviceRequest(String reviewer, String advice, Integer incentivePoint, String nextScenarioId, String nextReason) {
|
||||
}
|
||||
|
||||
public record ReviewDetailResponse(
|
||||
@@ -73,11 +159,102 @@ public final class AihrPracticeDto {
|
||||
String summary,
|
||||
String mentorRewrite,
|
||||
String aiComment,
|
||||
String reviewAdvice,
|
||||
Integer incentivePoint,
|
||||
List<DimensionResponse> scoreItems,
|
||||
List<DialogueResponse> dialogue
|
||||
List<DialogueResponse> dialogue,
|
||||
List<AnnotationResponse> annotations
|
||||
) {
|
||||
}
|
||||
|
||||
public record CompetencyResponse(Integer score, Integer completed, Integer pendingReview, List<DimensionResponse> dimensions) {
|
||||
public record CompetencyResponse(
|
||||
Integer score,
|
||||
Integer completed,
|
||||
Integer pendingReview,
|
||||
Integer trainingMinutes,
|
||||
Integer contribution,
|
||||
Integer assessmentScore,
|
||||
String aiLevel,
|
||||
List<DimensionResponse> dimensions,
|
||||
List<GrowthStageResponse> growthPath
|
||||
) {
|
||||
}
|
||||
|
||||
public record GrowthStageResponse(String level, Integer progress, String status, String evidence) {
|
||||
}
|
||||
|
||||
public record PracticeAlertResponse(String type, String title, String desc, String severity, String extPartyId) {
|
||||
}
|
||||
|
||||
public record PracticeAssignmentRequest(String extPartyId, String traineeName, String scenarioId, String scenarioName, String source, String reason) {
|
||||
}
|
||||
|
||||
public record PracticeAssignmentResponse(
|
||||
Long id,
|
||||
String extPartyId,
|
||||
String traineeName,
|
||||
String scenarioId,
|
||||
String scenarioName,
|
||||
String source,
|
||||
String reason,
|
||||
String status,
|
||||
String createTime,
|
||||
String question,
|
||||
String referenceAnswer,
|
||||
String userAnswer,
|
||||
String feedback,
|
||||
Boolean correct
|
||||
) {
|
||||
}
|
||||
|
||||
public record DailyDrillAnswerRequest(String answer) {
|
||||
}
|
||||
|
||||
public record DailyDrillAnswerResponse(Long id, String status, Boolean correct, Integer score, String feedback, String referenceAnswer) {
|
||||
}
|
||||
|
||||
public record PromptTemplateRequest(
|
||||
String code,
|
||||
String name,
|
||||
String category,
|
||||
String outputFormat,
|
||||
String systemPrompt,
|
||||
String template,
|
||||
String variables,
|
||||
String version,
|
||||
Boolean enabled,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
|
||||
public record PromptTemplateResponse(
|
||||
Long id,
|
||||
String code,
|
||||
String name,
|
||||
String category,
|
||||
String outputFormat,
|
||||
String systemPrompt,
|
||||
String template,
|
||||
String variables,
|
||||
String version,
|
||||
Boolean enabled,
|
||||
String description,
|
||||
String updateTime
|
||||
) {
|
||||
}
|
||||
|
||||
public record PromotionEvidenceResponse(
|
||||
String extPartyId,
|
||||
String currentLevel,
|
||||
Integer assessmentScore,
|
||||
Integer trainingMinutes,
|
||||
Integer contribution,
|
||||
Integer completed,
|
||||
Integer pendingReview,
|
||||
List<DimensionResponse> dimensions,
|
||||
List<RecordResponse> latestRecords,
|
||||
List<PracticeAssignmentResponse> assignments,
|
||||
List<String> evidenceItems
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -7,12 +7,18 @@ public final class AihrSopDto {
|
||||
private AihrSopDto() {
|
||||
}
|
||||
|
||||
public record SearchRequest(String queryText, String category, Integer limit) {
|
||||
public record SearchRequest(String queryText, String category, String position, String source, Integer limit) {
|
||||
}
|
||||
|
||||
public record SummaryCardRequest(String queryText, String category) {
|
||||
}
|
||||
|
||||
public record SopReviewRequest(Boolean usable, String reviewer, String note) {
|
||||
}
|
||||
|
||||
public record SopReviewResponse(Long id, Boolean usable, String status, String reviewedAt) {
|
||||
}
|
||||
|
||||
public record SummaryCardResponse(String title, List<CardStep> steps, List<CardObjection> objections, List<String> scripts, List<String> reminders) {
|
||||
}
|
||||
|
||||
@@ -79,7 +85,8 @@ public final class AihrSopDto {
|
||||
List<String> training,
|
||||
List<RecordResponse> records,
|
||||
List<String> keyPoints,
|
||||
List<String> cautions
|
||||
List<String> cautions,
|
||||
Long reviewId
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -5,12 +5,18 @@ public final class AihrSpeechDto {
|
||||
private AihrSpeechDto() {
|
||||
}
|
||||
|
||||
public record AsrResponse(String text, String source) {
|
||||
public record AsrResponse(String text, String source, Long ossId, String audioUrl) {
|
||||
public AsrResponse(String text, String source) {
|
||||
this(text, source, null, "");
|
||||
}
|
||||
}
|
||||
|
||||
public record TtsRequest(String text, String voice) {
|
||||
}
|
||||
|
||||
public record TtsResponse(String audioUrl, String source) {
|
||||
public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) {
|
||||
public TtsResponse(String audioUrl, String source) {
|
||||
this(audioUrl, source, null, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -1,7 +1,14 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CompetencyResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.DailyDrillAnswerResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.PromotionEvidenceResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
|
||||
import org.dromara.aihr.domain.vo.AihrMobileDto.MetricResponse;
|
||||
@@ -51,13 +58,41 @@ public class AihrMobileSeedService {
|
||||
}
|
||||
|
||||
public boolean markReviewed(Long id) {
|
||||
return practiceSeedService.markReviewed(id);
|
||||
return markReviewed(id, null);
|
||||
}
|
||||
|
||||
public boolean markReviewed(Long id, ReviewAdviceRequest request) {
|
||||
return practiceSeedService.markReviewed(id, request);
|
||||
}
|
||||
|
||||
public CompetencyResponse profile(String extPartyId) {
|
||||
return practiceSeedService.competency(extPartyId);
|
||||
}
|
||||
|
||||
public PromotionEvidenceResponse promotionEvidence(String extPartyId) {
|
||||
return practiceSeedService.promotionEvidence(extPartyId);
|
||||
}
|
||||
|
||||
public List<PracticeAlertResponse> practiceAlerts() {
|
||||
return practiceSeedService.alerts();
|
||||
}
|
||||
|
||||
public List<PracticeAssignmentResponse> practiceAssignments(String extPartyId) {
|
||||
return practiceSeedService.assignments(extPartyId, 10);
|
||||
}
|
||||
|
||||
public PracticeAssignmentResponse createPracticeAssignment(PracticeAssignmentRequest request) {
|
||||
return practiceSeedService.createAssignment(request);
|
||||
}
|
||||
|
||||
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request) {
|
||||
return answerDailyDrill(id, request, "");
|
||||
}
|
||||
|
||||
public DailyDrillAnswerResponse answerDailyDrill(Long id, DailyDrillAnswerRequest request, String extPartyId) {
|
||||
return practiceSeedService.answerDailyDrill(id, request, extPartyId);
|
||||
}
|
||||
|
||||
private static String normalize(String role) {
|
||||
if (role == null) {
|
||||
return "user";
|
||||
|
||||
+37
@@ -14,6 +14,7 @@ import org.dromara.aihr.domain.AihrModelDto.ConfigResponse;
|
||||
import org.dromara.aihr.domain.AihrModelDto.ProviderRequest;
|
||||
import org.dromara.aihr.domain.AihrModelDto.ProviderResponse;
|
||||
import org.dromara.aihr.domain.AihrModelDto.ProviderStatusRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -43,6 +44,15 @@ public class AihrModelSeedService {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${aihr.ai-runtime.enabled:${AIHR_AI_RUNTIME_ENABLED:true}}")
|
||||
private boolean aiEnabled;
|
||||
|
||||
@Value("${aihr.ai-runtime.chat-enabled:${AIHR_AI_CHAT_ENABLED:true}}")
|
||||
private boolean chatEnabled;
|
||||
|
||||
@Value("${aihr.ai-runtime.speech-enabled:${AIHR_AI_SPEECH_ENABLED:true}}")
|
||||
private boolean speechEnabled;
|
||||
|
||||
public List<ProviderResponse> providers() {
|
||||
List<ProviderResponse> rows = dbProviders();
|
||||
return rows.isEmpty() ? seedProviders() : mergeProviders(rows);
|
||||
@@ -181,6 +191,18 @@ public class AihrModelSeedService {
|
||||
// 运行参数已就绪时以解析出的模型名为准:请求可能带的是 seed 占位模型名(如 gpt-4o-mini),不能透传给供应商
|
||||
String modelName = runtime.configured() || request == null || isBlank(request.model()) ? runtime.modelName() : request.model();
|
||||
|
||||
if (!chatAllowed()) {
|
||||
return new ChatResponse(
|
||||
false,
|
||||
runtime.providerCode(),
|
||||
modelName,
|
||||
"AI 调用已被试点成本闸门关闭,当前返回文本兜底:先安抚业主情绪,确认事实与责任人,再承诺首次反馈时间。",
|
||||
"cost-guard-fallback",
|
||||
null,
|
||||
List.of("需要恢复真实调用时设置 AIHR_AI_RUNTIME_ENABLED=true 且 AIHR_AI_CHAT_ENABLED=true。")
|
||||
);
|
||||
}
|
||||
|
||||
if (!runtime.configured()) {
|
||||
return new ChatResponse(
|
||||
false,
|
||||
@@ -216,6 +238,9 @@ public class AihrModelSeedService {
|
||||
* 按类目(asr/tts)查已启用且配置齐全的语音模型运行参数,供 AihrSpeechService 使用。
|
||||
*/
|
||||
public Optional<SpeechModel> speechModel(String category) {
|
||||
if (!speechAllowed()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
List<SpeechModel> rows = jdbcTemplate.query("""
|
||||
select c.model_name, c.provider_code,
|
||||
@@ -249,6 +274,10 @@ public class AihrModelSeedService {
|
||||
* 供其他模块(如三角色对练)复用的 chat 调用:模型未配置或调用失败返回 empty,由调用方决定兜底。
|
||||
*/
|
||||
public Optional<String> tryChat(String systemPrompt, String userPrompt, double temperature) {
|
||||
if (!chatAllowed()) {
|
||||
// ponytail: manual cost breaker; replace with metered monthly billing guard when vendor usage data is wired.
|
||||
return Optional.empty();
|
||||
}
|
||||
RuntimeConfig runtime = runtimeConfig(null);
|
||||
if (!runtime.configured()) {
|
||||
return Optional.empty();
|
||||
@@ -261,6 +290,14 @@ public class AihrModelSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean chatAllowed() {
|
||||
return aiEnabled && chatEnabled;
|
||||
}
|
||||
|
||||
private boolean speechAllowed() {
|
||||
return aiEnabled && speechEnabled;
|
||||
}
|
||||
|
||||
private List<ProviderResponse> dbProviders() {
|
||||
try {
|
||||
return jdbcTemplate.query("""
|
||||
|
||||
+65
@@ -38,6 +38,15 @@ public class AihrPracticeLlmService {
|
||||
) {
|
||||
}
|
||||
|
||||
public record PracticeTurn(
|
||||
String customerText,
|
||||
int emotion,
|
||||
int trust,
|
||||
String coachHint,
|
||||
boolean redFlag
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。
|
||||
*/
|
||||
@@ -64,6 +73,42 @@ public class AihrPracticeLlmService {
|
||||
.filter(text -> !text.isBlank());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一句业主回应,并让模型同步给出回合情绪、信任、红线和教练提示;解析失败由调用方回退 seed。
|
||||
*/
|
||||
public Optional<PracticeTurn> customerTurn(
|
||||
String personaTraits,
|
||||
String project,
|
||||
String goal,
|
||||
List<DialogueResponse> dialogue,
|
||||
String traineeText,
|
||||
String nextBeat,
|
||||
int defaultEmotion,
|
||||
int defaultTrust,
|
||||
String defaultCoachHint
|
||||
) {
|
||||
String system = """
|
||||
你只扮演物业小区业主,同时作为训练引擎给出结构化回合状态。
|
||||
人设特征:%s
|
||||
场景:%s
|
||||
隐性诉求:%s
|
||||
规则:
|
||||
1. customerText 是业主下一句话,一句以内,不超过%d个字,口语化。
|
||||
2. 禁止在 customerText 中提及系统、人设、评分、SOP、AI、训练。
|
||||
3. emotion/trust 为 0-100 整数;员工承接越好 trust 越高,推责或对抗则 redFlag=true。
|
||||
4. coachHint 给员工下一步改进建议,不超过60字。
|
||||
5. 剧情推进方向(不要照抄原句):%s
|
||||
只输出 JSON,不要输出其他文字:
|
||||
{"customerText":"","emotion":%d,"trust":%d,"coachHint":"","redFlag":false}
|
||||
""".formatted(personaTraits, project, goal, MAX_CUSTOMER_CHARS, nextBeat, defaultEmotion, defaultTrust);
|
||||
String user = "对话记录:\n" + renderDialogue(dialogue)
|
||||
+ "\n员工刚才说:" + traineeText
|
||||
+ "\n默认教练提示:" + defaultCoachHint
|
||||
+ "\n请输出 JSON。";
|
||||
return modelService.tryChat(system, user, CUSTOMER_TEMPERATURE)
|
||||
.flatMap(content -> parseTurn(content, defaultEmotion, defaultTrust, defaultCoachHint));
|
||||
}
|
||||
|
||||
/**
|
||||
* /finish 单 LLM 结构化评分:temperature=0 + 固定 prompt 版本 + 档位锚点 60/75/90。
|
||||
*/
|
||||
@@ -111,6 +156,26 @@ public class AihrPracticeLlmService {
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<PracticeTurn> parseTurn(String content, int defaultEmotion, int defaultTrust, String defaultCoachHint) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(extractJson(content));
|
||||
String customerText = cleanCustomerLine(root.path("customerText").asText(""));
|
||||
if (customerText.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new PracticeTurn(
|
||||
truncateText(customerText, MAX_CUSTOMER_CHARS * 2),
|
||||
clampScore(root.path("emotion").asInt(defaultEmotion)),
|
||||
clampScore(root.path("trust").asInt(defaultTrust)),
|
||||
truncateText(root.path("coachHint").asText(defaultCoachHint), 120),
|
||||
root.path("redFlag").asBoolean(false)
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.warn("practice turn parse failed, falls back to seed: {}", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */
|
||||
private static int dimension(JsonNode root, String field, int fallback) {
|
||||
JsonNode node = root.path(field);
|
||||
|
||||
+1766
-38
File diff suppressed because it is too large
Load Diff
+217
-5
@@ -23,6 +23,8 @@ import org.dromara.aihr.domain.AihrSopDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SopReviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
@@ -98,6 +100,8 @@ public class AihrSopSeedService {
|
||||
private final String importRootConfig;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
private final AihrVideoService videoService;
|
||||
private volatile boolean knowledgeGapTableReady;
|
||||
private volatile boolean sopReviewTableReady;
|
||||
|
||||
public AihrSopSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, ISysOssService ossService,
|
||||
@Value("${aihr.import.root:}") String importRootConfig,
|
||||
@@ -114,12 +118,15 @@ public class AihrSopSeedService {
|
||||
String requestedCategory = request == null ? "" : request.category();
|
||||
SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP"));
|
||||
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
|
||||
String position = firstNonBlank(request == null ? null : request.position(), "生活顾问");
|
||||
String source = normalizeSearchSource(request == null ? null : request.source());
|
||||
String queryText = request == null || request.queryText() == null || request.queryText().isBlank() ? seed.question() : request.queryText();
|
||||
SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit());
|
||||
if (dbResponse != null && !dbResponse.snippets().isEmpty()) {
|
||||
return dbResponse;
|
||||
return withReviewId(dbResponse, source);
|
||||
}
|
||||
return new SearchResponse(
|
||||
recordKnowledgeGap(queryText, category, position, source);
|
||||
return withReviewId(new SearchResponse(
|
||||
queryText,
|
||||
seed.category(),
|
||||
seed.answer(),
|
||||
@@ -129,8 +136,30 @@ public class AihrSopSeedService {
|
||||
seed.training(),
|
||||
records(),
|
||||
List.of(),
|
||||
List.of()
|
||||
List.of(),
|
||||
null
|
||||
), source);
|
||||
}
|
||||
|
||||
public SopReviewResponse reviewSearch(Long id, SopReviewRequest request) {
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
ensureSopReviewTable();
|
||||
int updated = jdbcTemplate.update("""
|
||||
UPDATE aihr_sop_answer_review
|
||||
SET usable = ?, reviewer = ?, note = ?, status = '已评审', reviewed_time = ?, update_time = ?
|
||||
WHERE tenant_id = ? AND id = ?
|
||||
""",
|
||||
request != null && Boolean.TRUE.equals(request.usable()),
|
||||
firstNonBlank(request == null ? null : request.reviewer(), "人工评审"),
|
||||
firstNonBlank(request == null ? null : request.note(), ""),
|
||||
Timestamp.valueOf(java.time.LocalDateTime.now()),
|
||||
Timestamp.valueOf(java.time.LocalDateTime.now()),
|
||||
TENANT_ID,
|
||||
id
|
||||
);
|
||||
return updated == 0 ? null : sopReview(id);
|
||||
}
|
||||
|
||||
public SummaryCardResponse summaryCard(SummaryCardRequest request) {
|
||||
@@ -1630,6 +1659,184 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private void recordKnowledgeGap(String queryText, String category, String position, String source) {
|
||||
if (isBlank(queryText)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureKnowledgeGapTable();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_knowledge_gap
|
||||
(tenant_id, query_text, category, position, source, status, create_time)
|
||||
VALUES (?, ?, ?, ?, ?, '待补充', ?)
|
||||
""",
|
||||
TENANT_ID,
|
||||
queryText.trim(),
|
||||
firstNonBlank(category, "sop"),
|
||||
firstNonBlank(position, "生活顾问"),
|
||||
normalizeSearchSource(source),
|
||||
Timestamp.valueOf(java.time.LocalDateTime.now())
|
||||
);
|
||||
} catch (DataAccessException ignored) {
|
||||
// Gap logging is additive and must not break SOP search fallback.
|
||||
}
|
||||
}
|
||||
|
||||
private SearchResponse withReviewId(SearchResponse response, String source) {
|
||||
Long reviewId = createSopReview(response, source);
|
||||
return new SearchResponse(
|
||||
response.queryText(),
|
||||
response.category(),
|
||||
response.answer(),
|
||||
response.reference(),
|
||||
response.docs(),
|
||||
response.snippets(),
|
||||
response.training(),
|
||||
response.records(),
|
||||
response.keyPoints(),
|
||||
response.cautions(),
|
||||
reviewId
|
||||
);
|
||||
}
|
||||
|
||||
private Long createSopReview(SearchResponse response, String source) {
|
||||
try {
|
||||
ensureSopReviewTable();
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement ps = connection.prepareStatement("""
|
||||
INSERT INTO aihr_sop_answer_review
|
||||
(tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, status, create_time, update_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
|
||||
""", Statement.RETURN_GENERATED_KEYS);
|
||||
ps.setString(1, TENANT_ID);
|
||||
ps.setString(2, truncate(response.queryText(), 1000));
|
||||
ps.setString(3, firstNonBlank(response.category(), "sop"));
|
||||
ps.setString(4, truncate(response.answer(), 2000));
|
||||
ps.setString(5, truncate(response.reference(), 1000));
|
||||
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
|
||||
ps.setString(7, normalizeSearchSource(source));
|
||||
Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now());
|
||||
ps.setTimestamp(8, now);
|
||||
ps.setTimestamp(9, now);
|
||||
return ps;
|
||||
}, keyHolder);
|
||||
Number key = keyHolder.getKey();
|
||||
return key == null ? null : key.longValue();
|
||||
} catch (DataAccessException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private SopReviewResponse sopReview(Long id) {
|
||||
List<SopReviewResponse> rows = jdbcTemplate.query("""
|
||||
SELECT id, usable, status, reviewed_time
|
||||
FROM aihr_sop_answer_review
|
||||
WHERE tenant_id = ? AND id = ?
|
||||
LIMIT 1
|
||||
""", (rs, rowNum) -> {
|
||||
Timestamp reviewedTime = rs.getTimestamp("reviewed_time");
|
||||
return new SopReviewResponse(
|
||||
rs.getLong("id"),
|
||||
rs.getObject("usable") == null ? null : rs.getBoolean("usable"),
|
||||
rs.getString("status"),
|
||||
reviewedTime == null ? "" : reviewedTime.toLocalDateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||
);
|
||||
}, TENANT_ID, id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public SopReviewStats sopReviewStats() {
|
||||
ensureSopReviewTable();
|
||||
return jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
|
||||
FROM aihr_sop_answer_review
|
||||
WHERE tenant_id = ? AND status = '已评审'
|
||||
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), TENANT_ID);
|
||||
}
|
||||
|
||||
private void ensureSopReviewTable() {
|
||||
if (sopReviewTableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sopReviewTableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`query_text` varchar(1000) NOT NULL COMMENT '问题',
|
||||
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
|
||||
`answer_text` varchar(2000) DEFAULT NULL COMMENT '回答',
|
||||
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
|
||||
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
|
||||
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
|
||||
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
|
||||
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
|
||||
`note` varchar(500) DEFAULT NULL COMMENT '评审备注',
|
||||
`status` varchar(30) DEFAULT '待评审' COMMENT '状态',
|
||||
`reviewed_time` datetime DEFAULT NULL COMMENT '评审时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_sop_answer_review_status` (`tenant_id`, `status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审';
|
||||
""");
|
||||
ensureColumn("aihr_sop_answer_review", "source", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源' AFTER `snippet_count`");
|
||||
sopReviewTableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureKnowledgeGapTable() {
|
||||
if (knowledgeGapTableReady) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (knowledgeGapTableReady) {
|
||||
return;
|
||||
}
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_knowledge_gap` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`query_text` varchar(1000) NOT NULL COMMENT '未命中问题',
|
||||
`category` varchar(100) DEFAULT NULL COMMENT '知识分类',
|
||||
`position` varchar(80) DEFAULT NULL COMMENT '岗位',
|
||||
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
|
||||
`status` varchar(30) DEFAULT '待补充' COMMENT '状态',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_knowledge_gap_status` (`tenant_id`, `status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 知识缺口日志';
|
||||
""");
|
||||
knowledgeGapTableReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureColumn(String table, String column, String ddl) {
|
||||
Integer exists = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
|
||||
""", Integer.class, table, column);
|
||||
if (exists == null || exists == 0) {
|
||||
jdbcTemplate.execute(ddl);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeSearchSource(String value) {
|
||||
if (isBlank(value)) {
|
||||
return "knowledge_search";
|
||||
}
|
||||
String source = value.trim().replaceAll("[^A-Za-z0-9_-]", "_");
|
||||
if (source.length() > 50) {
|
||||
source = source.substring(0, 50);
|
||||
}
|
||||
return source.isBlank() ? "knowledge_search" : source;
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
@@ -1806,7 +2013,8 @@ public class AihrSopSeedService {
|
||||
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
|
||||
records(),
|
||||
digest != null ? digest.keyPoints() : List.of(),
|
||||
digest != null ? digest.cautions() : List.of()
|
||||
digest != null ? digest.cautions() : List.of(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1901,7 +2109,8 @@ public class AihrSopSeedService {
|
||||
你是物业一线员工的SOP信息图总结助手。只返回 JSON,不要 markdown、代码块或解释。
|
||||
输出格式固定为:
|
||||
{"title":"XX总结卡","steps":[{"label":"步骤名","desc":"一句话"}],"objections":[{"concern":"异议","response":"应对"}],"scripts":["可直接开口的完整话术"],"reminders":["注意事项"]}
|
||||
字段约束:title ≤14字;steps 3-5条,label ≤6字,desc ≤20字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
|
||||
steps 必须且只允许 3 条,label 依次固定为「问题」「建议」「标准」:问题写当前要处理的核心问题;建议写下一步动作;标准写SOP依据或红线。
|
||||
字段约束:title ≤14字;每个 desc ≤32字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
|
||||
硬约束:只能基于给定片段内容归纳,不得编造片段中没有的流程、时限、承诺、金额、政策或话术。
|
||||
""");
|
||||
|
||||
@@ -2824,6 +3033,9 @@ public class AihrSopSeedService {
|
||||
) {
|
||||
}
|
||||
|
||||
public record SopReviewStats(int total, int usable) {
|
||||
}
|
||||
|
||||
private static final class CategorySummary {
|
||||
private final String name;
|
||||
private int files;
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import java.util.UUID;
|
||||
public class AihrSpeechService {
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(15);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(60);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final String DEFAULT_TTS_VOICE = "anna";
|
||||
|
||||
private final AihrModelSeedService modelService;
|
||||
|
||||
Reference in New Issue
Block a user