Răsfoiți Sursa

feat(report): 舌诊照片上传走专用舌诊流程,自动识别舌头图片分流

Xiaogang Liao 1 lună în urmă
părinte
comite
b2e773ac5a

+ 25 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -171,6 +171,9 @@ public class HealthReportController {
     @Resource
     private ReportCollectService reportCollectService;
 
+    @Resource
+    private com.etotem.cfc.service.AiGateway aiGateway;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -635,8 +638,14 @@ public class HealthReportController {
             @RequestAttribute(value = "familyId", required = false) Long familyId) {
 
         if ("tongue".equals(type)) {
-            if (memberId == null) {
-                return Result.error("memberId is required for tongue diagnosis");
+            // 舌诊照片:直接走舌诊流程(AI 视觉分析 + 存舌诊记录),不走 PDF 采集链
+            try {
+                Map<String, Object> tongueResult = tongueDiagnosisService.parsePreview(file, memberId);
+                tongueResult.put("type", "tongue");
+                return Result.success(tongueResult);
+            } catch (Exception e) {
+                log.error("舌诊解析失败", e);
+                return Result.error("舌诊解析失败: " + e.getMessage());
             }
         }
 
@@ -657,6 +666,20 @@ public class HealthReportController {
                 if (!wechatService.checkImage(fileBytes)) {
                     return Result.error("图片内容包含敏感信息,上传失败");
                 }
+                // auto 模式下的图片先判断是否为舌象照片:是 → 直接走舌诊流程,
+                // 避免舌诊照片被当作 PDF 报告走采集链导致 EOF 错误
+                if ("auto".equals(type) || "image".equals(type)) {
+                    try {
+                        boolean isTongue = aiGateway.detectTongue(fileBytes, originalFilename);
+                        if (isTongue) {
+                            Map<String, Object> tongueResult = tongueDiagnosisService.parsePreview(file, memberId);
+                            tongueResult.put("type", "tongue");
+                            return Result.success(tongueResult);
+                        }
+                    } catch (Exception e) {
+                        log.warn("舌象照片分类失败,按普通图片处理: {}", e.getMessage());
+                    }
+                }
             } else {
                 // PDF 类型校验
                 String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";

+ 35 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -596,7 +596,7 @@ public class AiGateway {
                 @Override
                 public String getFilename() { return filename; }
             });
-            parts.add("user_id", String.valueOf(userId));
+            parts.add("user_id", String.valueOf(userId != null ? userId : 0L));
             // 注入 Java 后端配置的舌诊提示词
             String tonguePrompt = promptConfig.getTongueSystem();
             if (tonguePrompt != null && !tonguePrompt.isEmpty()) {
@@ -626,4 +626,38 @@ public class AiGateway {
         }
     }
 
+
+    /**
+     * 舌象照片分类:判断上传图片是否为舌头(舌象)照片。
+     * 调 LangGraph /api/v1/tongue/detect(glm-5 视觉模型)。
+     * @return true=舌象照片;false/异常=非舌象(安全降级,走通用报告流程)
+     */
+    public boolean detectTongue(byte[] imageBytes, String filename) {
+        if (!enabled || isCircuitOpen()) return false;
+        try {
+            org.springframework.util.LinkedMultiValueMap<String, Object> parts = new org.springframework.util.LinkedMultiValueMap<>();
+            parts.add("file", new org.springframework.core.io.ByteArrayResource(imageBytes) {
+                @Override
+                public String getFilename() { return filename; }
+            });
+            org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+            headers.setContentType(org.springframework.http.MediaType.MULTIPART_FORM_DATA);
+            HttpEntity<org.springframework.util.LinkedMultiValueMap<String, Object>> entity = new HttpEntity<>(parts, headers);
+            String url = baseUrl + "/api/v1/tongue/detect";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                consecutiveFailures.set(0);
+                JsonNode data = root.has("data") ? root.get("data") : root;
+                return data.has("is_tongue") && data.get("is_tongue").asBoolean(false);
+            }
+            return false;
+        } catch (Exception e) {
+            log.warn("AiGateway detectTongue 调用失败: {}", e.getMessage());
+            recordFailure();
+            return false;
+        }
+    }
+
 }

+ 10 - 4
cfc-frontend/pages/health/report-confirm.vue

@@ -904,9 +904,14 @@ export default {
       self.confirming = true
       uni.showLoading({ title: '确认入库并生成方案...' })
 
-      var postData = {
-        draftId: self.draftId,
-        payload: self.payload
+      var postData = {}
+      if (self.payload.type === 'tongue') {
+        // 舌诊:后端 /confirm?type=tongue 期望顶层 recordId + indicators,非 draftId
+        postData.recordId = self.payload.recordId
+        postData.indicators = self.payload.indicators
+      } else {
+        postData.draftId = self.draftId
+        postData.payload = self.payload
       }
       // 优先级: 已选成员 > 新建成员 > 自动匹配(高置信度) > 子ID
       if (self.selectedMemberId) {
@@ -981,7 +986,8 @@ export default {
         success: function(res) {
           if (res.confirm) {
             var discardFn = self.payload.type === 'tongue' ? discardTongue : discardReportDraft
-            discardFn(self.draftId).then(function() {
+            var discardArg = self.payload.type === 'tongue' ? {recordId: self.payload.recordId} : self.draftId
+            discardFn(discardArg).then(function() {
               uni.showToast({ title: '已放弃', icon: 'none' })
               setTimeout(function() { uni.navigateBack() }, 500)
             }).catch(function() {

+ 18 - 1
cfc-frontend/pages/health/report-upload.vue

@@ -167,6 +167,7 @@ export default {
     return {
       memberId: null,
       reportKind: '',
+      uploadType: null, // 'tongue' | null;从 URL query reportType 读取
       familyMembers: [],
       uploading: false,
       agreedToTerms: false,
@@ -197,6 +198,9 @@ export default {
     } else if (options.type === 'gut' || options.reportKind === 'gut') {
       this.reportKind = 'gut'
     }
+    if (options.reportType === 'tongue' || options.type === 'tongue') {
+      this.uploadType = 'tongue'
+    }
     this.loadFamilyMembers()
   },
   onUnload: function() {
@@ -361,10 +365,23 @@ export default {
       uni.showLoading({ title: '上传中...' })
       
       // 菌群检测:走健康报告异步采集
-      uploadReportOnly(filePath).then(function(res) {
+      uploadReportOnly(filePath, self.uploadType).then(function(res) {
         uni.hideLoading()
         self.uploading = false
         var data = res.data || {}
+        // 舌诊照片:后端直接返回 recordId + 舌诊指标,不走 draft/collect 流程
+        if (data.type === 'tongue' || data.recordId) {
+          var tonguePayload = {
+            type: 'tongue',
+            recordId: data.recordId,
+            indicators: data.indicators || [],
+            summary: { overallAssessment: data.overallAssessment || '' }
+          }
+          var url = '/pages/health/report-confirm?payload=' + encodeURIComponent(JSON.stringify(tonguePayload))
+          if (self.memberId) url += '&memberId=' + self.memberId
+          uni.redirectTo({ url: url })
+          return
+        }
         // 处理重复上传:duplicate=true
         if (data.duplicate) {
           if (data.collecting) {

+ 42 - 0
cfc-langgraph/app/api/tongue.py

@@ -44,3 +44,45 @@ async def tongue_diagnose(
         return {"code": 500, "message": "舌诊分析失败"}
     finally:
         os.unlink(tmp.name)
+
+
+@router.post("/tongue/detect")
+async def tongue_detect(file: UploadFile = File(...)):
+    """舌象照片分类: 判断上传图片是否为舌头(舌象)照片,用于图片上传分流。
+
+    返回 {"is_tongue": bool}。非舌象(报告截图/文档/其他部位)返回 false。
+    分类失败时返回 false(安全降级:走通用报告流程,不误伤正常报告)。
+    """
+    import base64, json, re, os
+    content = await file.read()
+    try:
+        ext = os.path.splitext(file.filename or "tongue.jpg")[1] or ".jpg"
+        b64 = base64.b64encode(content).decode()
+        data_url = f"data:image/{ext[1:]};base64,{b64}"
+
+        from src.llm.client import get_vision_llm
+        from langchain_core.messages import HumanMessage
+
+        prompt = (
+            "你是一个医疗图片分类助手。请判断这张图片是否是人的舌头(舌象/舌诊)照片。"
+            "舌象照片特征:舌体清晰可见、呈舌形、舌面颜色偏红/粉、可能带有舌苔。"
+            "以下都不是舌象照片:纸质报告/体检报告截图、文档、身体其他部位、食物、物体。"
+            "只输出 JSON,不要任何额外文字或 Markdown 包裹:{\"is_tongue\": true 或 false}"
+        )
+        llm = get_vision_llm()
+        resp = llm.invoke([HumanMessage(content=[
+            {"type": "text", "text": prompt},
+            {"type": "image_url", "image_url": {"url": data_url}},
+        ])])
+        raw = (resp.content or "").strip()
+        m = re.search(r"\{.*\}", raw, re.DOTALL)
+        parsed = json.loads(m.group(0)) if m else {}
+        val = parsed.get("is_tongue")
+        if isinstance(val, bool):
+            is_tongue = val
+        else:
+            is_tongue = str(val).strip().lower() in ("true", "yes", "1")
+        return {"code": 200, "data": {"is_tongue": is_tongue}}
+    except Exception as e:
+        logger.error("舌象分类失败: %s", e, exc_info=True)
+        return {"code": 200, "data": {"is_tongue": False}}