feat(report): 新增评估报告服务实现和配置更新

- 新增 EvaluationReportServiceImpl 服务实现
- 添加 EvaluationDimensionDataSaveReqVO 字段
- 优化 ReportController 和 ReportService
- 新增 ReportUpdateReqVO 请求对象
- 更新 pom.xml 依赖配置
- 更新 application-local.yaml 开发配置

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tangweijie 2026-01-20 12:12:46 +08:00
parent 0d46e00ba7
commit 751e1be667
10 changed files with 340 additions and 5 deletions

View File

@ -14,6 +14,7 @@ import jakarta.servlet.http.*;
import java.util.*;
import java.util.stream.Collectors;
import java.io.IOException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
@ -214,6 +215,21 @@ public class EvaluationReportController {
return success(data);
}
@GetMapping(value = "/dimension/stream-generate", produces = "text/event-stream;charset=UTF-8")
@Operation(summary = "流式生成维度评估内容SSE")
@Parameter(name = "dimensionId", description = "维度ID", required = true)
@Parameter(name = "prisonerId", description = "罪犯ID", required = true)
@Parameter(name = "customPrompt", description = "自定义提示词(可选)")
@Parameter(name = "systemPrompt", description = "系统提示词(可选)")
@PreAuthorize("@ss.hasPermission('prison:evaluation-report:dimension:create')")
public SseEmitter streamGenerateDimension(
@RequestParam("dimensionId") Long dimensionId,
@RequestParam("prisonerId") Long prisonerId,
@RequestParam(value = "customPrompt", required = false) String customPrompt,
@RequestParam(value = "systemPrompt", required = false) String systemPrompt) {
return evaluationReportService.streamGenerateDimension(dimensionId, prisonerId, customPrompt, systemPrompt);
}
// ========== 评估报告管理 ==========
@PostMapping("/report/create")

View File

@ -30,8 +30,7 @@ public class EvaluationDimensionDataSaveReqVO {
@Schema(description = "维度类型1-心理测评 2-行为表现 3-教育改造 4-劳动表现 5-人际交往 6-自评/他评")
private Integer dimensionType;
@Schema(description = "得分", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "得分不能为空")
@Schema(description = "得分")
private BigDecimal score;
@Schema(description = "满分")

View File

@ -48,7 +48,7 @@ public class ReportController {
@PutMapping("/update")
@Operation(summary = "更新评估报告")
@PreAuthorize("@ss.hasPermission('prison:report:update')")
public CommonResult<Boolean> updateReport(@Valid @RequestBody ReportSaveReqVO updateReqVO) {
public CommonResult<Boolean> updateReport(@Valid @RequestBody ReportUpdateReqVO updateReqVO) {
reportService.updateReport(updateReqVO);
return success(true);
}

View File

@ -0,0 +1,37 @@
package cn.iocoder.yudao.module.prison.controller.admin.report.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.util.*;
@Schema(description = "管理后台 - 评估报告更新 Request VO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReportUpdateReqVO {
@Schema(description = "报告ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "报告ID不能为空")
private Long id;
@Schema(description = "维度内容JSON格式")
private String dimensions;
@Schema(description = "综合结论")
private String conclusion;
@Schema(description = "改造建议")
private String suggestions;
@Schema(description = "风险等级1-低风险 2-中风险 3-高风险 4-极高风险")
private Integer riskLevel;
@Schema(description = "状态1-草稿 2-待审核 3-已通过 4-已退回")
private Integer status;
@Schema(description = "备注")
private String remark;
}

View File

@ -5,6 +5,7 @@ import jakarta.validation.*;
import cn.iocoder.yudao.module.prison.controller.admin.evaluationreport.vo.*;
import cn.iocoder.yudao.module.prison.dal.dataobject.evaluationreport.*;
import cn.iocoder.yudao.module.prison.service.evaluationreport.dto.DimensionDataSourcesRespDTO;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
@ -220,4 +221,15 @@ public interface EvaluationReportService {
*/
List<ReportCommentDO> getCommentsByDimensionId(Long dimensionId);
// ========== 流式生成 ==========
/**
* 流式生成维度评估内容SSE
* @param dimensionId 维度ID
* @param prisonerId 罪犯ID
* @param customPrompt 自定义提示词可选
* @return SseEmitter 用于 SSE 流式响应
*/
SseEmitter streamGenerateDimension(Long dimensionId, Long prisonerId, String customPrompt, String systemPrompt);
}

View File

@ -16,6 +16,8 @@ import java.util.stream.Collectors;
import cn.iocoder.yudao.module.prison.controller.admin.evaluationreport.vo.*;
import cn.iocoder.yudao.module.prison.dal.dataobject.evaluationreport.*;
import cn.iocoder.yudao.module.prison.service.evaluationreport.dto.DimensionDataSourcesRespDTO;
import cn.iocoder.yudao.module.prison.service.riskassessment.llm.LlmClient;
import cn.iocoder.yudao.module.prison.service.riskassessment.llm.LlmClientFactory;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@ -28,6 +30,8 @@ import cn.iocoder.yudao.module.prison.dal.mysql.evaluationreport.EvaluationDimen
import cn.iocoder.yudao.module.prison.dal.mysql.evaluationreport.ReportCommentMapper;
import cn.iocoder.yudao.module.prison.dal.mysql.PrisonerMapper;
import org.springframework.http.MediaType;
import java.nio.charset.StandardCharsets;
import cn.iocoder.yudao.module.prison.dal.mysql.area.AreaMapper;
import cn.iocoder.yudao.module.prison.dal.mysql.consumption.ConsumptionMapper;
import cn.iocoder.yudao.module.prison.dal.mysql.score.ScoreMapper;
@ -43,6 +47,9 @@ import cn.iocoder.yudao.module.prison.dal.dataobject.questionnairerecord.Questio
import cn.iocoder.yudao.module.prison.dal.dataobject.riskassessment.RiskAssessmentDO;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import cn.hutool.core.thread.ThreadUtil;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.prison.enums.ErrorCodeConstants.*;
@ -60,6 +67,11 @@ import static cn.iocoder.yudao.module.prison.enums.EvaluationAiStatusEnum.PENDIN
@Validated
public class EvaluationReportServiceImpl implements EvaluationReportService {
/**
* UTF-8 编码的 TEXT_PLAIN MediaType用于 SSE 中文内容发送
*/
private static final MediaType UTF8_TEXT_PLAIN = new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8);
@Resource
private EvaluationTemplateMapper templateMapper;
@ -93,6 +105,9 @@ public class EvaluationReportServiceImpl implements EvaluationReportService {
@Resource
private RiskAssessmentMapper riskAssessmentMapper;
@Resource
private LlmClientFactory llmClientFactory;
// ========== 模板管理 ==========
@Override
@ -623,4 +638,245 @@ public class EvaluationReportServiceImpl implements EvaluationReportService {
}
}
// ========== 流式生成 ==========
@Override
public SseEmitter streamGenerateDimension(Long dimensionId, Long prisonerId, String customPrompt, String systemPrompt) {
// 创建 SSE 发射器设置超时时间为 5 分钟
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
// 获取维度配置和数据源
EvaluationDimensionDO dimension = dimensionMapper.selectById(dimensionId);
if (dimension == null) {
sendError(emitter, "维度不存在");
return emitter;
}
// 获取数据源
DimensionDataSourcesRespDTO dataSources = getDimensionDataSources(dimensionId, prisonerId);
// 在独立线程中执行流式生成避免阻塞主线程
ThreadUtil.execute(() -> {
try {
// 发送开始事件 - 包含维度基本信息
Map<String, Object> startData = new LinkedHashMap<>();
startData.put("status", "started");
startData.put("dimensionId", dimensionId);
startData.put("dimensionName", dimension.getName());
startData.put("description", dimension.getDescription());
emitter.send(SseEmitter.event()
.name("start")
.data(cn.hutool.json.JSONUtil.toJsonStr(startData), MediaType.APPLICATION_JSON));
// 流式发送结构化数据段落
streamSendStructuredContent(emitter, dimension, dataSources, customPrompt, systemPrompt);
// 发送完成事件
emitter.send(SseEmitter.event()
.name("complete")
.data("{\"status\":\"completed\"}", MediaType.APPLICATION_JSON));
emitter.complete();
} catch (Exception e) {
sendError(emitter, "生成失败: " + e.getMessage());
}
});
// 设置超时和错误处理
emitter.onTimeout(() -> sendError(emitter, "生成超时"));
emitter.onError(e -> sendError(emitter, "生成错误: " + e.getMessage()));
return emitter;
}
/**
* 流式发送结构化内容
* 每个数据段落作为独立的 section 事件发送前端可自行决定渲染方式
*/
private void streamSendStructuredContent(SseEmitter emitter,
EvaluationDimensionDO dimension,
DimensionDataSourcesRespDTO dataSources,
String customPrompt,
String systemPrompt) throws Exception {
// 如果有自定义提示词发送
if (StrUtil.isNotBlank(customPrompt)) {
sendSection(emitter, "analysis", "custom", "自定义评估要求", Map.of("content", customPrompt));
}
// 罪犯基本信息
Map<String, Object> prisoner = dataSources.getPrisoner();
if (prisoner != null && !prisoner.isEmpty()) {
sendSection(emitter, "analysis", "prisoner", "罪犯基本信息", prisoner);
}
// 消费数据
Map<String, Object> consumptionSummary = dataSources.getConsumptionSummary();
if (consumptionSummary != null && !consumptionSummary.isEmpty()) {
sendSection(emitter, "analysis", "consumption", "消费情况", consumptionSummary);
}
// 计分考核数据
Map<String, Object> scoreSummary = dataSources.getScoreSummary();
if (scoreSummary != null && !scoreSummary.isEmpty()) {
sendSection(emitter, "analysis", "score", "计分考核情况", scoreSummary);
}
// 风险评估数据
Map<String, Object> riskAssessment = dataSources.getRiskAssessment();
if (riskAssessment != null && !riskAssessment.isEmpty()) {
sendSection(emitter, "analysis", "risk", "风险评估情况", riskAssessment);
}
// 问卷数据
List<Map<String, Object>> questionnaireRecords = dataSources.getQuestionnaireRecords();
if (questionnaireRecords != null && !questionnaireRecords.isEmpty()) {
sendSection(emitter, "analysis", "questionnaire", "问卷测评情况", Map.of("records", questionnaireRecords));
}
// 生成最终内容优先调用 LLM失败则降级为规则生成
String summary = generateSummaryByLlm(dimension, dataSources, customPrompt, systemPrompt);
if (StrUtil.isBlank(summary)) {
summary = generateSummary(dimension, dataSources);
}
String finalTitle = StrUtil.isNotBlank(dimension.getName()) ? dimension.getName() : "生成结果";
sendSection(emitter, "final", "summary", finalTitle, Map.of("content", summary));
}
/**
* 发送单个数据段落
*/
private void sendSection(SseEmitter emitter, String type, String key, String title, Map<String, Object> data) throws Exception {
Map<String, Object> section = new LinkedHashMap<>();
section.put("type", type);
section.put("key", key);
section.put("title", title);
section.put("data", data);
emitter.send(SseEmitter.event()
.name("section")
.data(cn.hutool.json.JSONUtil.toJsonStr(section), MediaType.APPLICATION_JSON));
// 模拟流式延迟
ThreadUtil.sleep(100);
}
/**
* 生成综合分析建议
* TODO: 后续可接入AI模型生成更智能的分析
*/
private String generateSummary(EvaluationDimensionDO dimension, DimensionDataSourcesRespDTO dataSources) {
StringBuilder summary = new StringBuilder();
summary.append("根据以上数据分析,");
// 根据数据情况生成不同的建议
Map<String, Object> scoreSummary = dataSources.getScoreSummary();
if (scoreSummary != null) {
Object totalScore = scoreSummary.get("totalScore");
if (totalScore != null) {
double score = Double.parseDouble(totalScore.toString());
if (score >= 1500) {
summary.append("该罪犯计分考核表现优秀,");
} else if (score >= 1000) {
summary.append("该罪犯计分考核表现良好,");
} else {
summary.append("该罪犯计分考核表现一般,需加强关注,");
}
}
}
summary.append("建议继续保持良好的改造状态,积极参与各项劳动和学习活动。");
return summary.toString();
}
/**
* 调用 LLM 生成综合分析建议
*/
private String generateSummaryByLlm(EvaluationDimensionDO dimension,
DimensionDataSourcesRespDTO dataSources,
String customPrompt,
String systemPrompt) {
try {
LlmClient client = llmClientFactory.getAssessmentClient();
LlmClient.LlmOptions options = LlmClient.LlmOptions.assessmentOptions();
options.setJsonMode(false);
options.setTemperature(0.3f);
options.setMaxTokens(1024);
if (StrUtil.isNotBlank(systemPrompt)) {
options.setSystemPrompt(systemPrompt);
}
String prompt = buildLlmPrompt(dimension, dataSources, customPrompt, systemPrompt);
String result = client.complete(prompt, options);
result = sanitizeLlmOutput(result);
return StrUtil.isBlank(result) ? null : result;
} catch (Exception e) {
return null;
}
}
private String buildLlmPrompt(EvaluationDimensionDO dimension,
DimensionDataSourcesRespDTO dataSources,
String customPrompt,
String systemPrompt) {
StringBuilder prompt = new StringBuilder();
if (StrUtil.isNotBlank(customPrompt)) {
prompt.append(customPrompt).append("\n");
}
// 仅追加数据本身提示词由前端完全控制
prompt.append(cn.hutool.json.JSONUtil.toJsonStr(buildLlmDataSummary(dataSources)));
return prompt.toString();
}
private String sanitizeLlmOutput(String text) {
if (text == null) {
return null;
}
// 去除模型的思考过程
String cleaned = text.replaceAll("(?s)<think>.*?</think>", "");
// 去除综合分析建议标题或前缀
cleaned = cleaned.replaceAll("(?m)^\\s*#+\\s*综合分析建议\\s*$", "");
cleaned = cleaned.replaceAll("(?m)^\\s*综合分析建议\\s*[:]\\s*", "");
// 去除多余空行
cleaned = cleaned.replaceAll("(?m)^\\s*$\\n", "");
return cleaned.trim();
}
private Map<String, Object> buildLlmDataSummary(DimensionDataSourcesRespDTO dataSources) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("prisoner", dataSources.getPrisoner());
summary.put("consumptionSummary", dataSources.getConsumptionSummary());
summary.put("scoreSummary", dataSources.getScoreSummary());
summary.put("riskAssessment", dataSources.getRiskAssessment());
summary.put("laborData", dataSources.getLaborData());
summary.put("familyData", dataSources.getFamilyData());
summary.put("psychologyData", dataSources.getPsychologyData());
summary.put("questionnaireCount", dataSources.getQuestionnaireRecords() == null ? 0 : dataSources.getQuestionnaireRecords().size());
summary.put("violationCount", dataSources.getViolationRecords() == null ? 0 : dataSources.getViolationRecords().size());
summary.put("rewardCount", dataSources.getRewardRecords() == null ? 0 : dataSources.getRewardRecords().size());
summary.put("visitCount", dataSources.getVisitRecords() == null ? 0 : dataSources.getVisitRecords().size());
return summary;
}
/**
* 发送错误事件
* 注意不使用 completeWithError因为它会抛出异常导致 Spring Security 的异步处理出错
*/
private void sendError(SseEmitter emitter, String errorMessage) {
try {
emitter.send(SseEmitter.event()
.name("error")
.data("{\"status\":\"error\",\"message\":\"" + errorMessage + "\"}", MediaType.APPLICATION_JSON));
// 使用 complete() 而不是 completeWithError()避免抛出异常
emitter.complete();
} catch (Exception e) {
// 如果发送失败静默完成
try {
emitter.complete();
} catch (Exception ignored) {
// 忽略emitter 可能已经完成或超时
}
}
}
}

View File

@ -26,7 +26,7 @@ public interface ReportService {
*
* @param updateReqVO 更新信息
*/
void updateReport(@Valid ReportSaveReqVO updateReqVO);
void updateReport(@Valid ReportUpdateReqVO updateReqVO);
/**
* 删除评估报告

View File

@ -42,7 +42,7 @@ public class ReportServiceImpl implements ReportService {
@Override
@Transactional(rollbackFor = Exception.class)
public void updateReport(ReportSaveReqVO updateReqVO) {
public void updateReport(ReportUpdateReqVO updateReqVO) {
// 校验存在
validateReportExists(updateReqVO.getId());
// 更新

View File

@ -129,6 +129,13 @@
<optional>true</optional>
</dependency>
<!-- 开发工具 - 热启动 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<!-- 服务保障相关 -->
<dependency>
<groupId>cn.iocoder.boot</groupId>

View File

@ -9,6 +9,14 @@ spring:
- org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration # 默认 local 环境,不开启 Quartz 的自动配置
- org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreAutoConfiguration # 禁用 AI 模块的 Qdrant手动创建
- org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus手动创建
# DevTools 配置 - 热启动优化
devtools:
restart:
enabled: true # 启用热启动
additional-exclude: static/**,public/**,templates/**,logs/** # 排除静态资源目录,提高重启速度
additional-paths: src/main # 只监控源码目录
livereload:
enabled: true # 启用浏览器自动刷新
# 数据源配置项
datasource:
druid: # Druid 【监控】相关的全局配置