|
|
@@ -3,6 +3,7 @@ package org.ruoyi.chat.service;
|
|
|
import com.alibaba.fastjson.JSON;
|
|
|
import com.alibaba.fastjson.JSONArray;
|
|
|
import com.alibaba.fastjson.JSONObject;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
import org.ruoyi.chat.util.HttpUtils;
|
|
|
import org.ruoyi.domain.MedicalRecordResult;
|
|
|
@@ -117,8 +118,24 @@ public class MedicalRecordQc123Service {
|
|
|
Map.entry("lab_reports", "检查检验规则清单"), // 检验报告单
|
|
|
Map.entry("exam_reports", "检查检验规则清单"), // 检查报告单
|
|
|
Map.entry("orders", "医嘱规则清单"), // 医嘱记录
|
|
|
- Map.entry("discharge_summary", "出院(死亡)记录规则清单"), // 出院小结
|
|
|
- Map.entry("consent_forms", "知情同意书") // 知情同意书
|
|
|
+ Map.entry("discharge_summary", "出院(死亡)记录规则清单") // 出院小结
|
|
|
+ // Map.entry("consent_forms", "知情同意书") // 知情同意书 - 暂时注释掉
|
|
|
+ );
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 质控规则名称 → 病历部分名称的映射
|
|
|
+ */
|
|
|
+ private static final Map<String, String> RULE_TO_SECTION_MAP = Map.ofEntries(
|
|
|
+ Map.entry("主诉记录不完整,不能支持第一诊断", "入院志"),
|
|
|
+ Map.entry("首次病程记录规则清单", "首次病程记录"),
|
|
|
+ Map.entry("病程记录规则清单", "病程记录"),
|
|
|
+ Map.entry("阶段小结规则清单", "阶段小结"),
|
|
|
+ Map.entry("查房记录", "查房记录"),
|
|
|
+ Map.entry("病程记录", "病程记录"),
|
|
|
+ Map.entry("其他", "手术记录"),
|
|
|
+ Map.entry("检查检验规则清单", "检查检验报告"),
|
|
|
+ Map.entry("医嘱规则清单", "医嘱记录"),
|
|
|
+ Map.entry("出院(死亡)记录规则清单", "出院小结")
|
|
|
);
|
|
|
|
|
|
/**
|
|
|
@@ -160,11 +177,29 @@ public class MedicalRecordQc123Service {
|
|
|
// 3.1 调用知识库
|
|
|
String ragResult = fetchMatchingRules(step.label);
|
|
|
|
|
|
- // 3.2 构造提示词
|
|
|
+ // 3.2 针对医嘱(orders,type=13)分段质控(按10条一批)
|
|
|
+ if (step.type == 13) {
|
|
|
+ batchQcAggregate(recordId, step.type, step.label, step.content, 30, ragResult);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3.2 针对病程记录(type=2)与查房记录(type=4) 三条一批质控并聚合
|
|
|
+ if (step.type == 2 || step.type == 4) {
|
|
|
+ batchQcAggregate(recordId, step.type, step.label, step.content, 5, ragResult);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (step.type == 11 || step.type == 12) {
|
|
|
+ batchQcAggregate(recordId, step.type, step.label, step.content, 5, ragResult);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3.2 构造提示词(非 orders 默认整段质控)
|
|
|
String prompt = buildPrompt(step.label, step.content, ragResult);
|
|
|
|
|
|
// 3.3 调用大模型
|
|
|
String modelOutput = callLLM(prompt);
|
|
|
+ // 3.3.1 清理模型返回内容(去掉<think>之前的部分)
|
|
|
+ String processedOutput = processModelOutput(modelOutput);
|
|
|
|
|
|
// 3.4 解析结果
|
|
|
int qcResult = parseQcResult(modelOutput);
|
|
|
@@ -177,7 +212,7 @@ public class MedicalRecordQc123Service {
|
|
|
result.setQcEndTime(LocalDateTime.now());
|
|
|
result.setQcResult(qcResult);
|
|
|
result.setQcScore("85"); // 可以改成模型评分
|
|
|
- result.setQcComments(modelOutput);
|
|
|
+ result.setQcComments(processedOutput);
|
|
|
result.setCreateDate(LocalDateTime.now());
|
|
|
resultMapper.insert(result);
|
|
|
|
|
|
@@ -216,8 +251,8 @@ public class MedicalRecordQc123Service {
|
|
|
new QcStep(11, SECTION_LABEL_MAP.get("lab_reports"), record.getLabReports() != null ? record.getLabReports().toString() : null),
|
|
|
new QcStep(12, SECTION_LABEL_MAP.get("exam_reports"), record.getExamReports() != null ? record.getExamReports().toString() : null),
|
|
|
new QcStep(13, SECTION_LABEL_MAP.get("orders"), record.getOrders() != null ? record.getOrders().toString() : null),
|
|
|
- new QcStep(14, SECTION_LABEL_MAP.get("discharge_summary"), record.getDischargeSummary()),
|
|
|
- new QcStep(15, SECTION_LABEL_MAP.get("consent_forms"), record.getConsentForms() != null ? record.getConsentForms().toString() : null)
|
|
|
+ new QcStep(14, SECTION_LABEL_MAP.get("discharge_summary"), record.getDischargeSummary())
|
|
|
+ // new QcStep(15, SECTION_LABEL_MAP.get("consent_forms"), record.getConsentForms() != null ? record.getConsentForms().toString() : null) // 暂时注释掉知情同意书质控
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -262,6 +297,13 @@ public class MedicalRecordQc123Service {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 根据质控规则名称获取病历部分名称
|
|
|
+ */
|
|
|
+ private String getSectionName(String sectionLabel) {
|
|
|
+ return RULE_TO_SECTION_MAP.getOrDefault(sectionLabel, sectionLabel);
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 构造大模型提示词
|
|
|
*/
|
|
|
@@ -313,8 +355,8 @@ public class MedicalRecordQc123Service {
|
|
|
private String buildPrompt(String sectionLabel, String content, String ragResult) {
|
|
|
StringBuilder sb = new StringBuilder();
|
|
|
|
|
|
- sb.append("你是一名医疗病历质控员,需要依据【质控规则】对【")
|
|
|
- .append(sectionLabel).append("】进行审查。\n\n");
|
|
|
+ sb.append("你是一名医疗病历质控员,需要依据【质控规则】对病历的【")
|
|
|
+ .append(getSectionName(sectionLabel)).append("】部分进行审查。\n\n");
|
|
|
|
|
|
sb.append("【病历原文】:\n")
|
|
|
.append(content).append("\n\n");
|
|
|
@@ -328,11 +370,11 @@ public class MedicalRecordQc123Service {
|
|
|
|
|
|
sb.append("如果存在不符合规则:逐条输出 JSON 对象,数组形式,每个对象包含以下字段:\n");
|
|
|
sb.append("- result: 固定为 \"fail\"\n");
|
|
|
- sb.append("- detail: 违规位置及内容(如“首次病程记录-主诉”)\n");
|
|
|
- sb.append("- 从病历原文中提取 违规内容所在的一小段(约10字左右),保持完整病历内容,便于定位。\n");
|
|
|
+ sb.append("- detail: 违规位置及内容(如“入院记录-主诉”)\n");
|
|
|
+ sb.append("- content:从病历原文中提取 违规内容所在的一小段(约10字左右),保持完整病历内容,便于定位。\n");
|
|
|
sb.append("- ruleCode: 对应规则编号(如 \"BCJL001\")\n");
|
|
|
sb.append("- ruleContent: 对应规则详情,必须与质控规则完全一致,不要显示()里面的内容,如XXX(YYYYYYYYYY) 只显示XXX\n");
|
|
|
- sb.append("- qcResult: 结论,给出违反规则的原因,简要描述。\n\n");
|
|
|
+ sb.append("- qcResult: 结论,给出违反本条规则的原因,不要牵扯其他规则,简要描述。禁止在本条结论中关联或提及其他规则。\n\n");
|
|
|
|
|
|
sb.append("示例:\n");
|
|
|
sb.append("[\n");
|
|
|
@@ -341,11 +383,18 @@ public class MedicalRecordQc123Service {
|
|
|
sb.append("]\n\n");
|
|
|
|
|
|
sb.append("2.违规情况:\n");
|
|
|
+ sb.append("规则详情括号中的内容是对规则的补充解释,如XXX(YYYYYYYYYY),YYYYYYYYYY是对XXX补充解释,\n");
|
|
|
sb.append("每条违规单独输出一个 JSON 对象 \n");
|
|
|
sb.append("字段必须取自病历原文,且保持完整语义(不必限制为固定字数) \n");
|
|
|
sb.append("3.禁止事项:\n");
|
|
|
sb.append("禁止输出 Markdown 代码块符号(如 ```json)\n");
|
|
|
sb.append("禁止生成与质控规则不一致的规则编号\n");
|
|
|
+ sb.append("3.注意事项:\n");
|
|
|
+ sb.append("质控时仅针对规则本身对病历内容进行判断。所有结论必须严格基于病历原文记载,不得依赖病历外的推测、常识或假设。不做超出规则范围的额外临床分析或诊断。\n");
|
|
|
+ sb.append("语义理解原则(最重要):严禁进行僵化的关键词匹配。判断一项内容是否被记录,核心是审查病历原文是否描述了该事项的实质内容。只要语义相同或高度相近,即使未使用规则中的原词,也应判定为已记录。\n");
|
|
|
+ sb.append("4.宽松原则:\n");
|
|
|
+ sb.append("对于规则中涉及\"是否存在\"、\"是否包含\"、\"缺***\"的判断,采取宽松标准。只要病历中有所体现或间接包含相关信息,即使不够完整详细,也应酌情通过。仅在完全缺失或明显违反核心要求时判定为不通过。\n");
|
|
|
+ sb.append("对于规则中涉及\"是否存在\"、\"是否包含\"、\"缺***\"的判断,采取宽松标准。只要病历中有所体现或间接包含相关信息,即使不够完整详细,也应酌情通过。仅在完全缺失或明显违反核心要求时判定为不通过。\n");
|
|
|
|
|
|
return sb.toString();
|
|
|
}
|
|
|
@@ -367,9 +416,21 @@ public class MedicalRecordQc123Service {
|
|
|
put("stream", false);
|
|
|
put("temperature", 0.0);
|
|
|
put("messages", messages);
|
|
|
- put("max_tokens", 5120); // ✅ 限制最大输出token数
|
|
|
+ put("max_tokens", 10240); // ✅ 限制最大输出token数
|
|
|
put("chat_template_kwargs", "{\"enable_thinking\": false}");
|
|
|
+// new HashMap<String, Object>() {{
|
|
|
+// put("model", "deepseek-r1-70B"); // ✅ 改成 R1-70B
|
|
|
+// put("stream", false);
|
|
|
+// put("temperature", 0.0);
|
|
|
+// put("max_tokens", 8192);
|
|
|
+// put("messages", messages);
|
|
|
+// put("chat_template_kwargs", "{\"enable_thinking\": false}");
|
|
|
+//
|
|
|
+// // ✅ 禁用思考输出
|
|
|
+//// put("reasoning_mode", "disabled");
|
|
|
}}
|
|
|
+
|
|
|
+
|
|
|
);
|
|
|
String modelUrl = getLlmModelUrl();
|
|
|
System.out.println("模型路径:"+ modelUrl);
|
|
|
@@ -434,4 +495,119 @@ public class MedicalRecordQc123Service {
|
|
|
String str = obj.toString().trim();
|
|
|
return str.isEmpty() || str.equals("[]") || str.equalsIgnoreCase("null");
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 处理模型返回的内容,去掉 <think> 或 </think> 之前的推理部分,只保留 JSON 结果。
|
|
|
+ */
|
|
|
+ private String processModelOutput(String modelOutput) {
|
|
|
+ if (modelOutput == null) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+
|
|
|
+ // 正则匹配:去掉 </think> 之前的所有内容(包括 <think>... </think>)
|
|
|
+ // 匹配 <think> 开头、</think> 结束的内容,以及前面的任何文字
|
|
|
+ String cleaned = modelOutput.replaceAll("(?s)^.*?</think>\\s*", "");
|
|
|
+
|
|
|
+ // 如果模型没有输出 </think>,则直接返回原始内容
|
|
|
+ if (cleaned.isEmpty()) {
|
|
|
+ cleaned = modelOutput;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 去掉前后多余空白符
|
|
|
+ return cleaned.trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通用分段质控聚合:按 batchSize 批量质控,仅聚合 fail 项,清理旧记录后写入一条结果。
|
|
|
+ */
|
|
|
+ private void batchQcAggregate(String recordId,
|
|
|
+ int qcType,
|
|
|
+ String sectionLabel,
|
|
|
+ String jsonArrayContent,
|
|
|
+ int batchSize,
|
|
|
+ String ragResult) {
|
|
|
+ LocalDateTime start = LocalDateTime.now();
|
|
|
+
|
|
|
+ try {
|
|
|
+ JSONArray array = JSON.parseArray(jsonArrayContent);
|
|
|
+ if (array == null || array.isEmpty()) {
|
|
|
+ log.info("⚠️ 病历 {} 的 [{}] 数组为空,跳过", recordId, sectionLabel);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Object> aggregated = new ArrayList<>();
|
|
|
+ int total = array.size();
|
|
|
+ int totalBatches = (total + batchSize - 1) / batchSize;
|
|
|
+ log.info("➡️ 开始批量质控 病历={} [{}],总条数={},batchSize={},批次数={}", recordId, sectionLabel, total, batchSize, totalBatches);
|
|
|
+ for (int startIdx = 0; startIdx < total; startIdx += batchSize) {
|
|
|
+ int endIdx = Math.min(startIdx + batchSize, total);
|
|
|
+ List<String> batchItems = new ArrayList<>();
|
|
|
+ for (int k = startIdx; k < endIdx; k++) {
|
|
|
+ String item = array.getString(k);
|
|
|
+ if (item != null && !item.trim().isEmpty()) {
|
|
|
+ batchItems.add((k + 1) + ". " + item);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (batchItems.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ int batchNo = (startIdx / batchSize) + 1;
|
|
|
+ log.info("🧪 执行批次 {}/{}(索引 {}..{},实际条数={})", batchNo, totalBatches, startIdx, endIdx - 1, batchItems.size());
|
|
|
+ String batchContent = String.join("\n\n", batchItems);
|
|
|
+ String batchPrompt = buildPrompt(sectionLabel, batchContent, ragResult);
|
|
|
+ String batchOutput = callLLM(batchPrompt);
|
|
|
+ String processedOutput = processModelOutput(batchOutput);
|
|
|
+
|
|
|
+ try {
|
|
|
+ JSONArray arr = JSON.parseArray(processedOutput);
|
|
|
+ if (arr != null && !arr.isEmpty()) {
|
|
|
+ int before = aggregated.size();
|
|
|
+ for (int j = 0; j < arr.size(); j++) {
|
|
|
+ JSONObject obj = arr.getJSONObject(j);
|
|
|
+ if (obj != null && !"pass".equalsIgnoreCase(obj.getString("result"))) {
|
|
|
+ aggregated.add(obj);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ int added = aggregated.size() - before;
|
|
|
+ log.info("📌 批次 {}/{} 解析成功:新增不通过 {} 条", batchNo, totalBatches, added);
|
|
|
+ }
|
|
|
+ } catch (Exception ignore) {
|
|
|
+ aggregated.add(processedOutput);
|
|
|
+ log.warn("⚠️ 批次 {}/{} 返回非JSON格式,已按文本纳入聚合", batchNo, totalBatches);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理旧记录,仅保留一条最新聚合
|
|
|
+ try {
|
|
|
+ int rows = resultMapper.delete(new LambdaQueryWrapper<MedicalRecordResult>()
|
|
|
+ .eq(MedicalRecordResult::getPatientRecordId, recordId)
|
|
|
+ .eq(MedicalRecordResult::getQcType, qcType));
|
|
|
+ log.info("🧹 清理旧记录完成:patientRecordId={}, qcType={}, 删除条数={}", recordId, qcType, rows);
|
|
|
+ } catch (Exception ignore) { }
|
|
|
+
|
|
|
+ MedicalRecordResult agg = new MedicalRecordResult();
|
|
|
+ agg.setPatientRecordId(recordId);
|
|
|
+ agg.setQcType(qcType);
|
|
|
+ agg.setQcStartTime(start);
|
|
|
+ agg.setQcEndTime(LocalDateTime.now());
|
|
|
+ agg.setQcScore("85");
|
|
|
+ agg.setQcComments(aggregated.isEmpty()
|
|
|
+ ? "[{\"result\":\"pass\",\"message\":\"病例校验通过\"}]"
|
|
|
+ : JSON.toJSONString(aggregated));
|
|
|
+ agg.setQcResult(aggregated.isEmpty() ? 1 : 0);
|
|
|
+ agg.setCreateDate(LocalDateTime.now());
|
|
|
+ resultMapper.insert(agg);
|
|
|
+
|
|
|
+ if (aggregated.isEmpty()) {
|
|
|
+ log.info("✅ 病历 {} [{}] 批量质控完成:全部通过,已存PASS记录(batchSize={})", recordId, sectionLabel, batchSize);
|
|
|
+ } else {
|
|
|
+ log.info("✅ 病历 {} [{}] 批量质控完成:共有不通过 {} 条,已聚合入库(batchSize={})", recordId, sectionLabel, aggregated.size(), batchSize);
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (Exception ex) {
|
|
|
+ log.error("❌ 病历 {} [{}] 批量质控失败", recordId, sectionLabel, ex);
|
|
|
+ throw ex;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|