chore: initialize wygj monorepo

This commit is contained in:
2026-07-03 01:25:10 +08:00
commit 62ffcb6e99
1248 changed files with 137340 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-modules</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-aihr</artifactId>
<description>
物业 AI 人力资源 Demo 业务模块
</description>
<dependencies>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-core</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-mybatis</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-web</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-system</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.2.2</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-pdf-module</artifactId>
<version>3.2.2</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parser-microsoft-module</artifactId>
<version>3.2.2</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,41 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadRequest;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.dromara.aihr.service.AihrCaseSeedService;
import org.dromara.common.core.domain.R;
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.RestController;
/**
* 案例语音整理 seed API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/knowledge/case")
public class AihrCaseController {
private final AihrCaseSeedService caseSeedService;
@PostMapping("/upload")
public R<UploadResponse> upload(@RequestBody UploadRequest request) {
return R.ok(caseSeedService.upload(request));
}
@PostMapping("/organize")
public R<OrganizeResponse> organize(@RequestBody OrganizeRequest request) {
return R.ok(caseSeedService.organize(request));
}
@PostMapping("/curate")
public R<CurateResponse> curate(@RequestBody CurateRequest request) {
return R.ok(caseSeedService.curate(request));
}
}
@@ -0,0 +1,41 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.aihr.service.AihrInterviewSeedService;
import org.dromara.common.core.domain.R;
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.RestController;
/**
* AI 面试 seed API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/recruit/interview")
public class AihrInterviewController {
private final AihrInterviewSeedService interviewSeedService;
@PostMapping("/start")
public R<StartResponse> start(@RequestBody StartRequest request) {
return R.ok(interviewSeedService.start(request));
}
@PostMapping("/answer")
public R<AnswerResponse> answer(@RequestBody AnswerRequest request) {
return R.ok(interviewSeedService.answer(request));
}
@PostMapping("/finish")
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
return R.ok(interviewSeedService.finish(request));
}
}
@@ -0,0 +1,85 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrModelDto.ChatRequest;
import org.dromara.aihr.domain.AihrModelDto.ChatResponse;
import org.dromara.aihr.domain.AihrModelDto.ConfigEnabledRequest;
import org.dromara.aihr.domain.AihrModelDto.ConfigRequest;
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.dromara.aihr.service.AihrModelSeedService;
import org.dromara.common.core.domain.R;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 模型能力最小迁移 API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/aihr/model")
public class AihrModelController {
private final AihrModelSeedService modelSeedService;
@GetMapping("/providers")
public R<List<ProviderResponse>> providers() {
return R.ok(modelSeedService.providers());
}
@PostMapping("/providers")
public R<Void> createProvider(@RequestBody ProviderRequest request) {
modelSeedService.createProvider(request);
return R.ok();
}
@PutMapping("/providers/{providerCode}")
public R<Void> updateProvider(@PathVariable String providerCode, @RequestBody ProviderRequest request) {
modelSeedService.updateProvider(providerCode, request);
return R.ok();
}
@PatchMapping("/providers/{providerCode}/status")
public R<Void> updateProviderStatus(@PathVariable String providerCode, @RequestBody ProviderStatusRequest request) {
modelSeedService.updateProviderStatus(providerCode, request);
return R.ok();
}
@GetMapping("/configs")
public R<List<ConfigResponse>> configs() {
return R.ok(modelSeedService.configs());
}
@PostMapping("/configs")
public R<Void> createConfig(@RequestBody ConfigRequest request) {
modelSeedService.createConfig(request);
return R.ok();
}
@PutMapping("/configs/{id}")
public R<Void> updateConfig(@PathVariable Long id, @RequestBody ConfigRequest request) {
modelSeedService.updateConfig(id, request);
return R.ok();
}
@PatchMapping("/configs/{id}/enabled")
public R<Void> updateConfigEnabled(@PathVariable Long id, @RequestBody ConfigEnabledRequest request) {
modelSeedService.updateConfigEnabled(id, request);
return R.ok();
}
@PostMapping("/chat")
public R<ChatResponse> chat(@RequestBody(required = false) ChatRequest request) {
return R.ok(modelSeedService.chat(request));
}
}
@@ -0,0 +1,41 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
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.domain.R;
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.RestController;
/**
* 三角色对练 seed API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/train/practice")
public class AihrPracticeController {
private final AihrPracticeSeedService practiceSeedService;
@PostMapping("/start")
public R<StartResponse> start(@RequestBody StartRequest request) {
return R.ok(practiceSeedService.start(request));
}
@PostMapping("/turn")
public R<TurnResponse> turn(@RequestBody TurnRequest request) {
return R.ok(practiceSeedService.turn(request));
}
@PostMapping("/finish")
public R<FinishResponse> finish(@RequestBody FinishRequest request) {
return R.ok(practiceSeedService.finish(request));
}
}
@@ -0,0 +1,63 @@
package org.dromara.aihr.controller;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest;
import org.dromara.aihr.domain.AihrSopDto.LocalImportResponse;
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.UploadResponse;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* SOP 知识库 seed API。
*/
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/knowledge")
public class AihrSopController {
private final AihrSopSeedService sopSeedService;
@PostMapping("/search")
public R<SearchResponse> search(@RequestBody SearchRequest request) {
return R.ok(sopSeedService.search(request));
}
@PostMapping("/doc/upload")
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) {
return R.ok(sopSeedService.uploadDoc(file, category));
}
@PostMapping("/doc/import-local")
public R<LocalImportResponse> importLocalDocs(@RequestBody LocalImportRequest request) {
return R.ok(sopSeedService.importLocalDocs(request));
}
@PostMapping("/doc/import-local-task")
public R<LocalImportTaskResponse> startLocalImportTask(@RequestBody LocalImportRequest request) {
return R.ok(sopSeedService.startLocalImportTask(request));
}
@GetMapping("/doc/import-tasks")
public R<List<LocalImportTaskResponse>> localImportTasks() {
return R.ok(sopSeedService.localImportTasks());
}
@GetMapping("/processing/overview")
public R<ProcessingOverviewResponse> processingOverview() {
return R.ok(sopSeedService.processingOverview());
}
}
@@ -0,0 +1,36 @@
package org.dromara.aihr.domain;
import java.util.List;
public final class AihrCaseDto {
private AihrCaseDto() {
}
public record UploadRequest(String fileName, String projectExtOrgId) {
}
public record UploadResponse(String caseId, String fileName, String transcript, List<TagResponse> tags) {
}
public record OrganizeRequest(String caseId) {
}
public record OrganizeResponse(String caseId, List<SummaryResponse> summary, List<TagResponse> tags, String aiSummary) {
}
public record CurateRequest(String caseId, String criteria, Integer limit) {
}
public record CurateResponse(List<String> selectedCaseIds, List<RecordResponse> records, String sampleHint) {
}
public record SummaryResponse(String label, String text) {
}
public record TagResponse(String text, String type) {
}
public record RecordResponse(String title, String type, String status, String time, String owner, String summary) {
}
}
@@ -0,0 +1,45 @@
package org.dromara.aihr.domain;
import java.util.List;
import java.util.Map;
public final class AihrInterviewDto {
private AihrInterviewDto() {
}
public record StartRequest(String candidateId, String positionCode, String mode) {
}
public record StartResponse(String sessionId, String candidateId, String candidateName, String positionCode, List<QuestionResponse> questions) {
}
public record QuestionResponse(String questionId, Integer seq, String questionText, String point, String scenario, String sampleAnswer) {
}
public record AnswerRequest(String sessionId, String questionId, String answerText, String answerAudioUrl) {
}
public record AnswerResponse(Integer score, Map<String, Integer> dimensions, String aiComment) {
}
public record FinishRequest(String sessionId) {
}
public record FinishResponse(
Integer totalScore,
String suggestion,
String result,
String aiSummary,
String evidence,
List<DimensionResponse> dimensions,
List<RecordResponse> records
) {
}
public record DimensionResponse(String label, Object value, String note) {
}
public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary) {
}
}
@@ -0,0 +1,83 @@
package org.dromara.aihr.domain;
import java.util.List;
public final class AihrModelDto {
private AihrModelDto() {
}
public record ProviderResponse(
Long id,
String providerName,
String providerCode,
String providerDesc,
String apiHost,
String status,
Integer sortOrder,
String source,
boolean configured
) {
}
public record ConfigResponse(
Long id,
String category,
String modelName,
String providerCode,
String modelDescribe,
Integer modelDimension,
String modelShow,
String apiHost,
boolean enabled,
boolean apiKeyConfigured,
boolean configured
) {
}
public record ProviderRequest(
String providerName,
String providerCode,
String providerDesc,
String apiHost,
String apiKey,
String status,
Integer sortOrder,
String remark
) {
}
public record ProviderStatusRequest(String status) {
}
public record ConfigRequest(
String category,
String modelName,
String providerCode,
String modelDescribe,
Integer modelDimension,
String modelShow,
String apiHost,
String apiKey,
Boolean enabled,
String remark
) {
}
public record ConfigEnabledRequest(Boolean enabled) {
}
public record ChatRequest(String prompt, String model, String systemPrompt) {
}
public record ChatResponse(
boolean configured,
String providerCode,
String modelName,
String answer,
String mode,
String error,
List<String> hints
) {
}
}
@@ -0,0 +1,61 @@
package org.dromara.aihr.domain;
import java.util.List;
public final class AihrPracticeDto {
private AihrPracticeDto() {
}
public record StartRequest(String extPartyId, String scenarioId, String mode) {
}
public record StartResponse(
String sessionId,
String scenarioId,
String scenarioName,
String trainee,
String customerText,
Integer emotion,
Integer trust,
String customerAudioUrl,
PersonaResponse persona
) {
}
public record PersonaResponse(String customer, String project, String strategy, String goal) {
}
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl) {
}
public record TurnResponse(
String customerText,
String customerAudioUrl,
Integer emotion,
Integer trust,
String coachHint,
Integer roundIndex,
Boolean finished
) {
}
public record FinishRequest(String sessionId) {
}
public record FinishResponse(
Integer totalScore,
List<DimensionResponse> score,
String mentorRewrite,
String aiComment,
List<Integer> trustCurve,
List<RecordResponse> records
) {
}
public record DimensionResponse(String label, Object value, String note) {
}
public record RecordResponse(String time, String trainee, String scene, Integer score, String status, String summary) {
}
}
@@ -0,0 +1,114 @@
package org.dromara.aihr.domain;
import java.util.List;
public final class AihrSopDto {
private AihrSopDto() {
}
public record SearchRequest(String queryText, String category, Integer limit) {
}
public record UploadResponse(String docId, Long ossId, String fileName, String category, Integer fragments, String summary, List<String> tags, List<SnippetResponse> snippets) {
}
public record LocalImportRequest(String directory, String category, Integer limit) {
}
public record LocalImportResponse(String root, String directory, String category, Integer discovered, Integer imported, Integer failed, List<LocalImportItemResponse> items) {
}
public record LocalImportItemResponse(String fileName, String status, String message, Integer fragments) {
}
public record LocalImportTaskResponse(
Long id,
String directory,
String category,
String status,
String statusType,
Integer progress,
Integer totalFiles,
Integer importedFiles,
Integer failedFiles,
String currentFile,
String createdAt,
String updatedAt,
String remark
) {
}
public record SearchResponse(
String queryText,
String category,
String answer,
String reference,
List<DocResponse> docs,
List<SnippetResponse> snippets,
List<String> training,
List<RecordResponse> records
) {
}
public record DocResponse(String title, String desc, String hit, String type) {
}
public record SnippetResponse(String title, String text) {
}
public record RecordResponse(String title, String category, String version, String status, String owner, String summary) {
}
public record ProcessingOverviewResponse(
List<MetricResponse> metrics,
List<CategoryStatResponse> categories,
List<ProcessingTaskResponse> tasks,
List<PipelineStepResponse> pipeline,
List<ProcessingEventResponse> events,
ProcessingRulesResponse rules
) {
}
public record MetricResponse(String key, String label, String value, String note, String type) {
}
public record CategoryStatResponse(String name, Integer files, String size, String icon, String type) {
}
public record ProcessingTaskResponse(
Long id,
String category,
String fileName,
String fileType,
String size,
String status,
String statusType,
Integer progress,
Integer fragments,
String vectorStatus,
String vectorType,
String updatedAt,
String action,
String remark,
String summary,
List<String> tags,
String classifyReason
) {
}
public record PipelineStepResponse(String name, String value, String status) {
}
public record ProcessingEventResponse(String time, String taskNo, String fileName, String event, String result, String resultType, String detail) {
}
public record ProcessingRulesResponse(
Boolean parserEnabled,
Boolean imageOcrEnabled,
Boolean lowConfidenceRefusal,
Boolean citationRequired,
Integer failedTasks
) {
}
}
@@ -0,0 +1,72 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrCaseDto.CurateRequest;
import org.dromara.aihr.domain.AihrCaseDto.CurateResponse;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeRequest;
import org.dromara.aihr.domain.AihrCaseDto.OrganizeResponse;
import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadRequest;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* ponytail: JSON seed, replace with multipart + ASR when real audio is in scope.
*/
@Service
public class AihrCaseSeedService {
private static final CaseSeed GARAGE_WATER = new CaseSeed(
"case-garage-water",
"车库积水投诉_20260702.wav",
"业主反映地下车库长期积水,担心车辆受损。管家先确认车位号与积水时间,安排工程人员排查排水沟,并在业主群同步处理进度。",
List.of(
new TagResponse("投诉处理", "pill-success"),
new TagResponse("车库", "pill-primary"),
new TagResponse("高情绪", "pill-warning")
),
List.of(
new SummaryResponse("背景", "雨后车库积水,业主担心车辆与安全问题。"),
new SummaryResponse("处理", "确认位置、派工排查、群内同步、次日复盘。"),
new SummaryResponse("结果", "排水沟堵塞已清理,业主确认问题解决。"),
new SummaryResponse("亮点", "响应快、过程透明、主动同步进度。")
),
new RecordResponse(
"车库积水投诉处理 · seed入库",
"投诉处理",
"已入库",
"07-02 10:50",
"培训组",
"ASR 转写后自动整理为案例稿,完成送审与入库,可用于三角色对练复用。"
)
);
public UploadResponse upload(UploadRequest request) {
String fileName = request == null || request.fileName() == null || request.fileName().isBlank()
? GARAGE_WATER.fileName()
: request.fileName();
return new UploadResponse(GARAGE_WATER.id(), fileName, GARAGE_WATER.transcript(), GARAGE_WATER.tags());
}
public OrganizeResponse organize(OrganizeRequest request) {
return new OrganizeResponse(GARAGE_WATER.id(), GARAGE_WATER.summary(), GARAGE_WATER.tags(), GARAGE_WATER.record().summary());
}
public CurateResponse curate(CurateRequest request) {
int limit = request == null || request.limit() == null ? 1 : Math.max(request.limit(), 1);
return new CurateResponse(List.of(GARAGE_WATER.id()).subList(0, Math.min(limit, 1)), List.of(GARAGE_WATER.record()), "已命中车库积水投诉处理样片,可用于演示案例沉淀后的培训复用。");
}
private record CaseSeed(
String id,
String fileName,
String transcript,
List<TagResponse> tags,
List<SummaryResponse> summary,
RecordResponse record
) {
}
}
@@ -0,0 +1,139 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* ponytail: seed service only; replace with DB + LLM adapter after frontend API flow is stable.
*/
@Service
public class AihrInterviewSeedService {
private final Map<String, CandidateSeed> candidates = buildCandidates();
public StartResponse start(StartRequest request) {
CandidateSeed candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
String sessionId = "seed-" + candidate.id() + "-" + System.currentTimeMillis();
return new StartResponse(sessionId, candidate.id(), candidate.name(), candidate.position(), candidate.questions());
}
public AnswerResponse answer(AnswerRequest request) {
String answerText = request == null ? "" : request.answerText();
int baseScore = answerText == null || answerText.isBlank() ? 0 : 82;
return new AnswerResponse(
baseScore,
Map.of("clarity", baseScore, "sop", Math.max(baseScore - 4, 0), "empathy", Math.min(baseScore + 5, 100)),
baseScore == 0 ? "未收到有效回答" : "回答已暂存,评分将在 finish 时统一生成"
);
}
public FinishResponse finish(FinishRequest request) {
CandidateSeed candidate = resolveCandidate(null, request == null ? null : request.sessionId());
ScoreSeed score = candidate.score();
return new FinishResponse(
score.total(),
score.suggestion(),
score.result(),
score.summary(),
score.evidence(),
score.dimensions(),
List.of(new RecordResponse("07-02 10:35", candidate.name(), candidate.position(), score.total(), score.result(), score.summary()))
);
}
private CandidateSeed resolveCandidate(String candidateId, String sessionId) {
if (candidateId != null && candidates.containsKey(candidateId)) {
return candidates.get(candidateId);
}
if (sessionId != null) {
for (Map.Entry<String, CandidateSeed> entry : candidates.entrySet()) {
if (sessionId.contains(entry.getKey())) {
return entry.getValue();
}
}
}
return candidates.get("zhang-ming");
}
private static Map<String, CandidateSeed> buildCandidates() {
Map<String, CandidateSeed> result = new LinkedHashMap<>();
result.put("zhang-ming", new CandidateSeed(
"zhang-ming",
"张明",
"生活顾问",
List.of(
new QuestionResponse("q1", 1, "业主因漏水情绪激动到前台投诉,你第一句话怎么说?", "考察情绪安抚、诉求确认、SOP 起手动作", "投诉接待",
"我会先说“您先别着急,我先把情况完整记下来,今天一定给您明确反馈”。随后确认漏水位置、影响范围和是否已经报修,再同步工程人员到场时间。"),
new QuestionResponse("q2", 2, "催缴物业费时,业主质疑服务不到位,你如何回应?", "考察费用争议、服务改进、冲突降级", "费用沟通",
"我会先承认服务感受需要重视,把业主提到的问题列成清单,同时说明费用缴纳和服务整改可以并行推进。对争议事项约定复核时限,必要时请主管一起回访。"),
new QuestionResponse("q3", 3, "突发停水时,多名业主集中追问,你如何组织信息同步?", "考察群体沟通、反馈时限和跨部门协同", "突发事件",
"我会先确认是否已有工程排查结论,再在业主群发布统一口径,包括影响范围、预计恢复时间和下一次更新时间。现场安排同事分流解释,避免多人重复给出不同说法。")
),
new ScoreSeed(
86,
"建议:进入复试,重点补强催缴冲突话术。",
"建议复试",
"服务意识较好,能先安抚再确认诉求,催缴争议处理需要补强。",
"命中 SOP:先安抚情绪、确认诉求、明确反馈时限;遗漏:费用争议的升级处理路径还不够明确。",
List.of(
new DimensionResponse("表达清晰", 86, "结构完整"),
new DimensionResponse("SOP 命中", 82, "漏升级路径"),
new DimensionResponse("情绪安抚", 90, "开场稳定"),
new DimensionResponse("录用风险", "低", "需复试确认")
)
)
));
result.put("li-na", new CandidateSeed(
"li-na",
"李娜",
"客服管家",
List.of(
new QuestionResponse("q1", 1, "业主反映报修两天没人跟进,你如何核实并回复?", "考察问题核实、责任确认和反馈时限", "报修跟进",
"我会先查报修工单和工程派单记录,确认卡点在哪里,再向业主说明当前状态和下一步处理时间。如果确实超时,我会先道歉并同步主管跟进。"),
new QuestionResponse("q2", 2, "业主在群里连续表达不满,你如何避免情绪扩散?", "考察群体沟通和单点安抚", "社群沟通",
"我会在群里先给出统一、准确的处理进展,再私聊业主了解细节,避免群内反复争论。后续在约定时间把结果同步到群里。"),
new QuestionResponse("q3", 3, "回访满意度低于预期时,你会如何复盘?", "考察复盘意识和改进动作", "满意度维护",
"我会拆分低分原因,是响应慢、解释不清还是结果未达预期,再对照工单节点找责任点。能当天补救的先补救,不能当天解决的给出明确计划。")
),
new ScoreSeed(
78,
"建议:进入观察名单,需补现场协调和群体沟通训练。",
"观察",
"回答完整但反馈时限不够明确,建议补充 SOP 训练。",
"命中 SOP:核实工单、私聊安抚、结果回访;遗漏:跨部门升级责任人和节点确认不够具体。",
List.of(
new DimensionResponse("表达清晰", 80, "逻辑完整"),
new DimensionResponse("SOP 命中", 74, "升级不足"),
new DimensionResponse("情绪安抚", 82, "能稳住群聊"),
new DimensionResponse("录用风险", "中", "需带教")
)
)
));
return result;
}
private record CandidateSeed(String id, String name, String position, List<QuestionResponse> questions, ScoreSeed score) {
}
private record ScoreSeed(
Integer total,
String suggestion,
String result,
String summary,
String evidence,
List<DimensionResponse> dimensions
) {
}
}
@@ -0,0 +1,511 @@
package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrModelDto.ChatRequest;
import org.dromara.aihr.domain.AihrModelDto.ChatResponse;
import org.dromara.aihr.domain.AihrModelDto.ConfigEnabledRequest;
import org.dromara.aihr.domain.AihrModelDto.ConfigRequest;
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.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Minimal model boundary adapted from ruoyi-ai chat_model/chat_provider.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AihrModelSeedService {
private static final String DEFAULT_PROVIDER = "custom_api";
private static final String DEFAULT_MODEL = "gpt-4o-mini";
private static final String TENANT_ID = "000000";
private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
public List<ProviderResponse> providers() {
List<ProviderResponse> rows = dbProviders();
return rows.isEmpty() ? seedProviders() : mergeProviders(rows);
}
public List<ConfigResponse> configs() {
List<ConfigResponse> rows = dbConfigs();
return rows.isEmpty() ? seedConfigs() : mergeConfigs(rows);
}
private static List<ProviderResponse> seedProviders() {
return List.of(
new ProviderResponse(-1L, "OpenAI 兼容接口", DEFAULT_PROVIDER, "OPENAI兼容格式", "自定义", "0", 0, "seed", false),
new ProviderResponse(-2L, "DeepSeek", "deepseek", "DeepSeek 官方 API", "https://api.deepseek.com", "0", 1, "ruoyi-ai", false),
new ProviderResponse(-3L, "智谱 AI", "zhipu", "智谱 AI 大模型服务", "https://open.bigmodel.cn", "0", 2, "ruoyi-ai", false),
new ProviderResponse(-4L, "阿里云百炼", "qianwen", "阿里云百炼大模型服务", "https://dashscope.aliyuncs.com/compatible-mode/v1", "0", 3, "ruoyi-ai", false),
new ProviderResponse(-5L, "Ollama", "ollama", "本地 Ollama 服务", "http://127.0.0.1:11434/v1", "0", 4, "ruoyi-ai", false)
);
}
private static List<ConfigResponse> seedConfigs() {
return List.of(
new ConfigResponse(-1L, "chat", DEFAULT_MODEL, DEFAULT_PROVIDER, "Demo 对话与 RAG 生成模型", null, "Y", null, true, false, false),
new ConfigResponse(-2L, "vector", "embedding-3", "zhipu", "后续用于知识片段向量化", 2048, "N", null, false, false, false),
new ConfigResponse(-3L, "rerank", "rerank", "zhipu", "后续用于召回片段重排序", null, "N", null, false, false, false)
);
}
private static List<ProviderResponse> mergeProviders(List<ProviderResponse> rows) {
Map<String, ProviderResponse> merged = new LinkedHashMap<>();
for (ProviderResponse seed : seedProviders()) {
merged.put(seed.providerCode(), seed);
}
for (ProviderResponse row : rows) {
merged.put(row.providerCode(), row);
}
return List.copyOf(merged.values());
}
private static List<ConfigResponse> mergeConfigs(List<ConfigResponse> rows) {
Map<String, ConfigResponse> merged = new LinkedHashMap<>();
for (ConfigResponse seed : seedConfigs()) {
merged.put(configKey(seed), seed);
}
for (ConfigResponse row : rows) {
merged.put(configKey(row), row);
}
return List.copyOf(merged.values());
}
private static String configKey(ConfigResponse config) {
return config.category() + ":" + config.providerCode() + ":" + config.modelName();
}
public void createProvider(ProviderRequest request) {
ProviderData data = providerData(request);
jdbcTemplate.update("""
insert into aihr_model_provider
(tenant_id, provider_name, provider_code, provider_desc, api_host, api_key, status, sort_order, remark, create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
""", TENANT_ID, data.providerName(), data.providerCode(), data.providerDesc(), data.apiHost(), emptyToNull(request.apiKey()),
data.status(), data.sortOrder(), data.remark());
}
public void updateProvider(String providerCode, ProviderRequest request) {
ProviderData data = providerData(request);
String code = clean(providerCode);
int updated = jdbcTemplate.update("""
update aihr_model_provider
set provider_name = ?, provider_desc = ?, api_host = ?,
api_key = case when ? is null then api_key else ? end,
status = ?, sort_order = ?, remark = ?, update_time = now()
where tenant_id = ? and provider_code = ?
""", data.providerName(), data.providerDesc(), data.apiHost(), emptyToNull(request.apiKey()), emptyToNull(request.apiKey()),
data.status(), data.sortOrder(), data.remark(), TENANT_ID, code);
requireUpdated(updated, "供应商不存在: " + code);
}
public void updateProviderStatus(String providerCode, ProviderStatusRequest request) {
String status = validStatus(request == null ? null : request.status());
String code = required(providerCode, "供应商编码不能为空");
int updated = jdbcTemplate.update("""
update aihr_model_provider
set status = ?, update_time = now()
where tenant_id = ? and provider_code = ?
""", status, TENANT_ID, code);
requireUpdated(updated, "供应商不存在: " + code);
}
public void createConfig(ConfigRequest request) {
ConfigData data = configData(request);
jdbcTemplate.update("""
insert into aihr_model_config
(tenant_id, category, model_name, provider_code, model_describe, model_dimension, model_show, api_host, api_key, enabled, remark, create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
""", TENANT_ID, data.category(), data.modelName(), data.providerCode(), data.modelDescribe(), data.modelDimension(),
data.modelShow(), data.apiHost(), emptyToNull(request.apiKey()), data.enabled(), data.remark());
}
public void updateConfig(Long id, ConfigRequest request) {
if (id == null) {
throw new IllegalArgumentException("模型 ID 不能为空");
}
ConfigData data = configData(request);
int updated = jdbcTemplate.update("""
update aihr_model_config
set category = ?, model_name = ?, provider_code = ?, model_describe = ?, model_dimension = ?, model_show = ?,
api_host = ?, api_key = case when ? is null then api_key else ? end,
enabled = ?, remark = ?, update_time = now()
where tenant_id = ? and id = ?
""", data.category(), data.modelName(), data.providerCode(), data.modelDescribe(), data.modelDimension(), data.modelShow(),
data.apiHost(), emptyToNull(request.apiKey()), emptyToNull(request.apiKey()), data.enabled(), data.remark(), TENANT_ID, id);
requireUpdated(updated, "模型不存在: " + id);
}
public void updateConfigEnabled(Long id, ConfigEnabledRequest request) {
if (id == null) {
throw new IllegalArgumentException("模型 ID 不能为空");
}
int enabled = Boolean.TRUE.equals(request == null ? null : request.enabled()) ? 1 : 0;
int updated = jdbcTemplate.update("""
update aihr_model_config
set enabled = ?, update_time = now()
where tenant_id = ? and id = ?
""", enabled, TENANT_ID, id);
requireUpdated(updated, "模型不存在: " + id);
}
public ChatResponse chat(ChatRequest request) {
RuntimeConfig runtime = runtimeConfig(request == null ? null : request.model());
String prompt = request == null || isBlank(request.prompt()) ? "请给出物业管家处理漏水投诉的第一步。" : request.prompt();
String modelName = request == null || isBlank(request.model()) ? runtime.modelName() : request.model();
if (!runtime.configured()) {
return new ChatResponse(
false,
runtime.providerCode(),
modelName,
"模型运行参数未配置,当前返回 seed fallback:先安抚业主情绪,确认房号、影响范围和现场照片,再创建工单并承诺首次反馈时间。",
"seed-fallback",
null,
List.of(
"在 aihr_model_provider / aihr_model_config 配置 api_host、api_key、model_name 后可切换到真实 OpenAI-compatible 调用。",
"接口不会返回 api_key。"
)
);
}
try {
String content = callOpenAiCompatible(runtime, modelName, prompt, request == null ? null : request.systemPrompt());
return new ChatResponse(true, runtime.providerCode(), modelName, content, "openai-compatible", null, List.of());
} catch (Exception e) {
return new ChatResponse(
true,
runtime.providerCode(),
modelName,
"真实模型调用失败,已回退 seed:先确认事实、同步时限、再生成工单闭环。",
"openai-compatible-failed",
e.getMessage(),
List.of("检查 aihr_model_provider.api_host/api_key 和 aihr_model_config.model_name 是否与供应商一致。")
);
}
}
private List<ProviderResponse> dbProviders() {
try {
return jdbcTemplate.query("""
select id, provider_name, provider_code, provider_desc, api_host, api_key, status, sort_order
from aihr_model_provider
where tenant_id = ?
order by sort_order asc, id asc
""", (rs, rowNum) -> new ProviderResponse(
rs.getLong("id"),
rs.getString("provider_name"),
rs.getString("provider_code"),
rs.getString("provider_desc"),
displayHost(rs.getString("api_host"), "自定义"),
rs.getString("status"),
rs.getObject("sort_order", Integer.class),
"db",
configured(rs.getString("api_host"), "model", rs.getString("api_key"))
), TENANT_ID);
} catch (DataAccessException e) {
log.debug("aihr model provider db fallback: {}", e.getMessage());
return List.of();
}
}
private List<ConfigResponse> dbConfigs() {
try {
return jdbcTemplate.query("""
select c.id, c.category, c.model_name, c.provider_code, c.model_describe, c.model_dimension,
c.model_show, c.api_host, c.enabled,
coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host,
coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key
from aihr_model_config c
left join aihr_model_provider p
on p.tenant_id = c.tenant_id and p.provider_code = c.provider_code
where c.tenant_id = ?
order by case c.category when 'chat' then 0 when 'vector' then 1 when 'rerank' then 2 else 9 end, c.id asc
""", (rs, rowNum) -> new ConfigResponse(
rs.getLong("id"),
rs.getString("category"),
rs.getString("model_name"),
rs.getString("provider_code"),
rs.getString("model_describe"),
rs.getObject("model_dimension", Integer.class),
rs.getString("model_show"),
rs.getString("api_host"),
rs.getInt("enabled") == 1,
!isBlank(rs.getString("resolved_api_key")),
configured(rs.getString("resolved_api_host"), rs.getString("model_name"), rs.getString("resolved_api_key"))
), TENANT_ID);
} catch (DataAccessException e) {
log.debug("aihr model config db fallback: {}", e.getMessage());
return List.of();
}
}
private Optional<RuntimeConfig> dbRuntimeConfig(String requestedModel) {
try {
List<RuntimeConfig> rows = jdbcTemplate.query(runtimeSql(requestedModel), (rs, rowNum) -> {
String baseUrl = rs.getString("resolved_api_host");
String modelName = rs.getString("model_name");
String apiKey = rs.getString("resolved_api_key");
return new RuntimeConfig(
rs.getString("provider_code"),
modelName,
baseUrl,
apiKey,
configured(baseUrl, modelName, apiKey),
"db"
);
}, runtimeArgs(requestedModel));
return rows.stream().findFirst();
} catch (DataAccessException e) {
log.debug("aihr model runtime db fallback: {}", e.getMessage());
return Optional.empty();
}
}
private String callOpenAiCompatible(RuntimeConfig runtime, String modelName, String prompt, String systemPrompt) throws Exception {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", modelName);
body.put("temperature", 0.2);
body.put("stream", false);
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", isBlank(systemPrompt) ? "你是物业 AI 人力资源系统助手,回答要适合 Demo 演示,简洁、可执行。" : systemPrompt);
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", prompt);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
if (!isBlank(runtime.apiKey())) {
builder.header("Authorization", "Bearer " + runtime.apiKey());
}
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build()
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("LLM HTTP " + response.statusCode() + ": " + truncate(response.body(), 240));
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode choices = root.path("choices");
if (!choices.isArray() || choices.size() == 0) {
throw new IllegalStateException("LLM response missing choices");
}
String content = choices.get(0).path("message").path("content").asText();
if (isBlank(content)) {
throw new IllegalStateException("LLM response missing message.content");
}
return content;
}
private RuntimeConfig runtimeConfig(String requestedModel) {
return dbRuntimeConfig(requestedModel)
.orElseGet(() -> new RuntimeConfig(DEFAULT_PROVIDER, fallback(requestedModel, DEFAULT_MODEL), null, null, false, "seed"));
}
private static String runtimeSql(String requestedModel) {
String modelFilter = isBlank(requestedModel) ? "" : " and c.model_name = ?";
return """
select c.model_name, c.provider_code,
coalesce(nullif(c.api_host, ''), nullif(p.api_host, '')) as resolved_api_host,
coalesce(nullif(c.api_key, ''), nullif(p.api_key, '')) as resolved_api_key
from aihr_model_config c
left join aihr_model_provider p
on p.tenant_id = c.tenant_id and p.provider_code = c.provider_code
where c.tenant_id = ?
and c.category = 'chat'
and c.enabled = 1
and (p.status is null or p.status = '0')
""" + modelFilter + """
order by case when c.model_show = 'Y' then 0 else 1 end, c.id asc
limit 1
""";
}
private static Object[] runtimeArgs(String requestedModel) {
return isBlank(requestedModel) ? new Object[]{TENANT_ID} : new Object[]{TENANT_ID, requestedModel};
}
private static String normalizeBaseUrl(String baseUrl) {
String normalized = baseUrl == null ? "" : baseUrl.trim();
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private static String displayHost(String value, String fallback) {
return isBlank(value) ? fallback : value;
}
private static String fallback(String value, String fallback) {
return isBlank(value) ? fallback : value;
}
private static boolean isLocalhost(String baseUrl) {
String value = baseUrl.toLowerCase();
return value.contains("127.0.0.1") || value.contains("localhost");
}
private static boolean configured(String baseUrl, String modelName, String apiKey) {
return !isBlank(baseUrl) && !isBlank(modelName) && (!isBlank(apiKey) || isLocalhost(baseUrl));
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static String required(String value, String message) {
String cleaned = clean(value);
if (isBlank(cleaned)) {
throw new IllegalArgumentException(message);
}
return cleaned;
}
private static String clean(String value) {
return value == null ? null : value.trim();
}
private static String emptyToNull(String value) {
String cleaned = clean(value);
return isBlank(cleaned) ? null : cleaned;
}
private static String providerCode(String value) {
String code = required(value, "供应商编码不能为空");
if (!code.matches("[A-Za-z0-9_-]{2,50}")) {
throw new IllegalArgumentException("供应商编码只能包含字母、数字、下划线和横线");
}
return code;
}
private static String category(String value) {
String category = required(value, "模型类型不能为空");
if (!List.of("chat", "vector", "rerank").contains(category)) {
throw new IllegalArgumentException("模型类型只支持 chat/vector/rerank");
}
return category;
}
private static String validStatus(String value) {
String status = fallback(clean(value), "0");
if (!List.of("0", "1").contains(status)) {
throw new IllegalArgumentException("状态只支持 0/1");
}
return status;
}
private static String modelShow(String value) {
String show = fallback(clean(value), "Y");
if (!List.of("Y", "N").contains(show)) {
throw new IllegalArgumentException("是否显示只支持 Y/N");
}
return show;
}
private static void requireUpdated(int updated, String message) {
if (updated == 0) {
throw new IllegalArgumentException(message);
}
}
private static ProviderData providerData(ProviderRequest request) {
if (request == null) {
throw new IllegalArgumentException("供应商参数不能为空");
}
return new ProviderData(
required(request.providerName(), "供应商名称不能为空"),
providerCode(request.providerCode()),
clean(request.providerDesc()),
emptyToNull(request.apiHost()),
validStatus(request.status()),
request.sortOrder() == null ? 0 : request.sortOrder(),
clean(request.remark())
);
}
private static ConfigData configData(ConfigRequest request) {
if (request == null) {
throw new IllegalArgumentException("模型参数不能为空");
}
return new ConfigData(
category(request.category()),
required(request.modelName(), "模型名称不能为空"),
providerCode(request.providerCode()),
clean(request.modelDescribe()),
request.modelDimension(),
modelShow(request.modelShow()),
emptyToNull(request.apiHost()),
request.enabled() == null || Boolean.TRUE.equals(request.enabled()) ? 1 : 0,
clean(request.remark())
);
}
private static String truncate(String value, int maxLength) {
if (value == null || value.length() <= maxLength) {
return value;
}
return value.substring(0, maxLength);
}
private record RuntimeConfig(String providerCode, String modelName, String baseUrl, String apiKey, boolean configured, String source) {
}
private record ProviderData(
String providerName,
String providerCode,
String providerDesc,
String apiHost,
String status,
Integer sortOrder,
String remark
) {
}
private record ConfigData(
String category,
String modelName,
String providerCode,
String modelDescribe,
Integer modelDimension,
String modelShow,
String apiHost,
int enabled,
String remark
) {
}
}
@@ -0,0 +1,188 @@
package org.dromara.aihr.service;
import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
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.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 三角色对练 seed 编排器。P0 先稳定前后端契约,后续再替换为会话表、ASR 和 LLM 评分。
*/
@Service
public class AihrPracticeSeedService {
private final Map<String, ScenarioSeed> scenarios = buildScenarios();
public StartResponse start(StartRequest request) {
ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null);
RoundSeed firstRound = scenario.rounds().get(0);
String sessionId = "seed-" + scenario.id() + "-" + System.currentTimeMillis();
return new StartResponse(
sessionId,
scenario.id(),
scenario.name(),
scenario.trainee(),
firstRound.customer(),
firstRound.emotion(),
scenario.trustStart(),
"",
new PersonaResponse(scenario.customer(), scenario.project(), scenario.strategy(), scenario.goal())
);
}
public TurnResponse turn(TurnRequest request) {
ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId());
int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size());
RoundSeed currentRound = scenario.rounds().get(roundIndex);
int nextRoundIndex = roundIndex + 1;
boolean finished = nextRoundIndex >= scenario.rounds().size();
RoundSeed nextRound = finished ? currentRound : scenario.rounds().get(nextRoundIndex);
int trust = finished ? scenario.trustEnd() : nextRound.trust();
return new TurnResponse(
finished ? "" : nextRound.customer(),
"",
nextRound.emotion(),
trust,
currentRound.coachHint(),
nextRoundIndex,
finished
);
}
public FinishResponse finish(FinishRequest request) {
ScenarioSeed scenario = resolveScenario(null, request == null ? null : request.sessionId());
return new FinishResponse(
scenario.total(),
scenario.scores(),
scenario.rewrite(),
scenario.summary(),
List.of(scenario.trustStart(), scenario.rounds().get(0).trust(), scenario.trustEnd()),
List.of(new RecordResponse("07-02 10:42", scenario.trainee(), scenario.name(), scenario.total(), "已完成", scenario.summary()))
);
}
private ScenarioSeed resolveScenario(String scenarioId, String sessionId) {
if (scenarioId != null && scenarios.containsKey(scenarioId)) {
return scenarios.get(scenarioId);
}
if (sessionId != null) {
for (Map.Entry<String, ScenarioSeed> entry : scenarios.entrySet()) {
if (sessionId.contains(entry.getKey())) {
return entry.getValue();
}
}
}
return scenarios.get("complaint-water");
}
private int normalizeRoundIndex(Integer roundIndex, int size) {
if (roundIndex == null || roundIndex < 0) {
return 0;
}
return Math.min(roundIndex, size - 1);
}
private static Map<String, ScenarioSeed> buildScenarios() {
Map<String, ScenarioSeed> result = new LinkedHashMap<>();
result.put("complaint-water", new ScenarioSeed(
"complaint-water",
"客服应对投诉",
"张三",
"高情绪、追问进度、质疑服务",
"住宅 / 车库积水",
"仅在漏关键步骤时提示",
"训练学员先安抚、再确认诉求,并给出责任人和首次反馈时间。",
68,
82,
List.of(
new RoundSeed(
"你们说今天处理,到现在都没人联系我,这个车库还怎么停?",
"这轮命中“安抚 + 责任人 + 首次反馈时间”,可以继续追问处理承诺。",
76,
75
),
new RoundSeed(
"我不要流程,我要知道如果今天还处理不好怎么办。",
"补上了升级路径和兜底承诺,结束后可进入评分。",
64,
82
)
),
List.of(
new DimensionResponse("合规", 84, "有升级路径"),
new DimensionResponse("沟通", 88, "承诺清楚"),
new DimensionResponse("情绪", 82, "先承接诉求"),
new DimensionResponse("错题", "1条", "补费用争议")
),
86,
"建议表达为:“我先确认责任人和到场时间,10 分钟内回复您;如果今天无法完成,我同步主管给您替代方案和下一次反馈时间。”",
"能先安抚并明确责任人与反馈时限,升级兜底表达比上一轮更完整。"
));
result.put("fee-collection", new ScenarioSeed(
"fee-collection",
"催缴沟通技巧",
"李四",
"拒绝缴费、质疑服务、要求先整改",
"住宅 / 物业费催缴",
"提示服务改进与缴费并行",
"训练学员把费用沟通和服务整改拆开处理,避免激化争议。",
61,
76,
List.of(
new RoundSeed(
"服务做成这样还让我缴费?你先把问题解决了再说。",
"表达方向正确,但要补“争议项复核”和“责任人”。",
72,
68
),
new RoundSeed(
"那我怎么知道你不是随便记一下?",
"补上主管复核和节点同步,可以进入评分。",
66,
76
)
),
List.of(
new DimensionResponse("合规", 80, "复核明确"),
new DimensionResponse("沟通", 83, "拆分得当"),
new DimensionResponse("情绪", 78, "安抚一般"),
new DimensionResponse("错题", "2条", "补开场安抚")
),
81,
"建议先承接情绪:“您觉得服务不到位,我先把问题负责跟到底。”再说明缴费和整改并行,最后给出主管复核与反馈时间。",
"费用争议拆分较清楚,但开场情绪承接不足。"
));
return result;
}
private record ScenarioSeed(
String id,
String name,
String trainee,
String customer,
String project,
String strategy,
String goal,
Integer trustStart,
Integer trustEnd,
List<RoundSeed> rounds,
List<DimensionResponse> scores,
Integer total,
String rewrite,
String summary
) {
}
private record RoundSeed(String customer, String coachHint, Integer emotion, Integer trust) {
}
}