diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/EvaluationReportController.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/EvaluationReportController.java index e626fc6b11..dd781ee6be 100644 --- a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/EvaluationReportController.java +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/EvaluationReportController.java @@ -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") diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/vo/EvaluationDimensionDataSaveReqVO.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/vo/EvaluationDimensionDataSaveReqVO.java index 49d937a4f3..407aeffab3 100644 --- a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/vo/EvaluationDimensionDataSaveReqVO.java +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/evaluationreport/vo/EvaluationDimensionDataSaveReqVO.java @@ -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 = "满分") diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/report/ReportController.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/report/ReportController.java index 1d5e740ed2..8038b72335 100644 --- a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/report/ReportController.java +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/report/ReportController.java @@ -48,7 +48,7 @@ public class ReportController { @PutMapping("/update") @Operation(summary = "更新评估报告") @PreAuthorize("@ss.hasPermission('prison:report:update')") - public CommonResult updateReport(@Valid @RequestBody ReportSaveReqVO updateReqVO) { + public CommonResult updateReport(@Valid @RequestBody ReportUpdateReqVO updateReqVO) { reportService.updateReport(updateReqVO); return success(true); } diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/report/vo/ReportUpdateReqVO.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/report/vo/ReportUpdateReqVO.java new file mode 100644 index 0000000000..d79b79e718 --- /dev/null +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/controller/admin/report/vo/ReportUpdateReqVO.java @@ -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; + +} diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportService.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportService.java index 5860d8ffb2..d3d980f73f 100644 --- a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportService.java +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportService.java @@ -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 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); + } diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportServiceImpl.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportServiceImpl.java index df950579e6..299ad61ade 100644 --- a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportServiceImpl.java +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/evaluationreport/EvaluationReportServiceImpl.java @@ -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 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 prisoner = dataSources.getPrisoner(); + if (prisoner != null && !prisoner.isEmpty()) { + sendSection(emitter, "analysis", "prisoner", "罪犯基本信息", prisoner); + } + + // 消费数据 + Map consumptionSummary = dataSources.getConsumptionSummary(); + if (consumptionSummary != null && !consumptionSummary.isEmpty()) { + sendSection(emitter, "analysis", "consumption", "消费情况", consumptionSummary); + } + + // 计分考核数据 + Map scoreSummary = dataSources.getScoreSummary(); + if (scoreSummary != null && !scoreSummary.isEmpty()) { + sendSection(emitter, "analysis", "score", "计分考核情况", scoreSummary); + } + + // 风险评估数据 + Map riskAssessment = dataSources.getRiskAssessment(); + if (riskAssessment != null && !riskAssessment.isEmpty()) { + sendSection(emitter, "analysis", "risk", "风险评估情况", riskAssessment); + } + + // 问卷数据 + List> 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 data) throws Exception { + Map 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 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).*?", ""); + // 去除“综合分析建议”标题或前缀 + 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 buildLlmDataSummary(DimensionDataSourcesRespDTO dataSources) { + Map 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 可能已经完成或超时 + } + } + } + } diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/ReportService.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/ReportService.java index 848bf3e2d0..6472c5f9f6 100644 --- a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/ReportService.java +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/ReportService.java @@ -26,7 +26,7 @@ public interface ReportService { * * @param updateReqVO 更新信息 */ - void updateReport(@Valid ReportSaveReqVO updateReqVO); + void updateReport(@Valid ReportUpdateReqVO updateReqVO); /** * 删除评估报告 diff --git a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/impl/ReportServiceImpl.java b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/impl/ReportServiceImpl.java index b8eee7193c..cbc2e1cb17 100644 --- a/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/impl/ReportServiceImpl.java +++ b/yudao-module-prison/src/main/java/cn/iocoder/yudao/module/prison/service/report/impl/ReportServiceImpl.java @@ -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()); // 更新 diff --git a/yudao-server/pom.xml b/yudao-server/pom.xml index 7d03bc6372..b2e1247ba1 100644 --- a/yudao-server/pom.xml +++ b/yudao-server/pom.xml @@ -129,6 +129,13 @@ true + + + org.springframework.boot + spring-boot-devtools + true + + cn.iocoder.boot diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index cfc21241f8..5a1fe2e78f 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -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 【监控】相关的全局配置