Преглед изворни кода

feat(tongue): 复用现有 TongueDiagnosisAgent 路由,补全视觉 LLM 实现走 LangGraph

- multimodal_agent: 移除 Dify 调用,_via_llm 走 src.graphs.tongue(glm-5 视觉)
- src/graphs/tongue: load_image 支持 data URL
- src/app.py: 移除与 app/api/tongue.py 冲突的死路由
- AiGateway.analyzeTongue: 改 multipart 表单提交(file+user_id),解包 data
- AIService/TongueDiagnosisService: 传字节而非 base64
- compose: image v7
Xiaogang Liao пре 1 недеља
родитељ
комит
2152ab289f

+ 4 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -248,13 +248,13 @@ public class AIService {
 
     /**
      * 舌诊图像分析
-     * 调用 LangGraph 舌诊 graph(MiniMax 视觉模型)返回结构化舌诊结果
+     * 调用 LangGraph TongueDiagnosisAgent(glm-5 视觉模型)返回结构化舌诊结果
      */
-    public Map<String, Object> sendTongueDiagnosis(String imageBase64) {
-        if (imageBase64 == null || imageBase64.isEmpty()) {
+    public Map<String, Object> sendTongueDiagnosis(byte[] imageBytes, String filename, Long userId) {
+        if (imageBytes == null || imageBytes.length == 0) {
             return mockTongueResult();
         }
-        Map<String, Object> result = aiGateway.analyzeTongue(imageBase64);
+        Map<String, Object> result = aiGateway.analyzeTongue(imageBytes, filename, userId);
         if (result != null && !result.isEmpty()) {
             return result;
         }

+ 15 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -542,25 +542,33 @@ public class AiGateway {
     }
 
     /**
-     * 舌诊分析(舌象图片 base64 → LangGraph MiniMax 视觉模型)
+     * 舌诊分析(舌象图片 → LangGraph TongueDiagnosisAgent 视觉模型)
+     * 以 multipart/form-data 提交 file + user_id
      * @return 含 overall_assessment / indicators 的 Map;失败返回 null
      */
-    public Map<String, Object> analyzeTongue(String imageBase64) {
+    public Map<String, Object> analyzeTongue(byte[] imageBytes, String filename, Long userId) {
         if (!enabled || isCircuitOpen()) return null;
         try {
-            ObjectNode body = objectMapper.createObjectNode();
-            body.put("image_base64", imageBase64);
+            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; }
+            });
+            parts.add("user_id", String.valueOf(userId));
 
-            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            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/diagnose";
 
             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;
                 Map<String, Object> result = new LinkedHashMap<>();
-                result.put("overall_assessment", root.has("overall_assessment") ? root.get("overall_assessment").asText() : "");
-                result.put("indicators", root.has("indicators") ? objectMapper.convertValue(root.get("indicators"), List.class) : Collections.emptyList());
+                result.put("overall_assessment", data.has("overall_assessment") ? data.get("overall_assessment").asText() : "");
+                result.put("indicators", data.has("indicators") ? objectMapper.convertValue(data.get("indicators"), List.class) : Collections.emptyList());
                 return result;
             }
             return null;

+ 4 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/TongueDiagnosisService.java

@@ -29,13 +29,14 @@ public class TongueDiagnosisService {
     public Map<String, Object> parsePreview(MultipartFile file, Long memberId) {
         String imageUrl = "/uploads/tongue/" + System.currentTimeMillis() + ".jpg";
 
-        String imageBase64 = null;
+        byte[] imageBytes;
         try {
-            imageBase64 = java.util.Base64.getEncoder().encodeToString(file.getBytes());
+            imageBytes = file.getBytes();
         } catch (Exception e) {
             throw new RuntimeException("舌诊图片读取失败: " + e.getMessage());
         }
-        Map<String, Object> diagnosisResult = aiService.sendTongueDiagnosis(imageBase64);
+        String filename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "tongue.jpg";
+        Map<String, Object> diagnosisResult = aiService.sendTongueDiagnosis(imageBytes, filename, memberId);
 
         TongueRecord record = new TongueRecord();
         record.setMemberId(memberId);

+ 23 - 48
cfc-langgraph/app/agents/multimodal_agent.py

@@ -1,21 +1,13 @@
-import httpx
 from typing import Optional
-from app.config import settings
 import logging
 
+from src.graphs.tongue import get_tongue_graph
+
 logger = logging.getLogger(__name__)
 
 
 class TongueDiagnosisAgent:
-    """舌诊分析 Agent
-
-    当前实现: 代理到 Dify Workflow (多模态最成熟)
-    后续可替换: 直接调用多模态 LLM API
-    """
-
-    def __init__(self):
-        self.dify_base = settings.dify_base_url or ""
-        self.dify_api_key = settings.dify_tongue_api_key or ""
+    """舌诊分析 Agent — 调用 LangGraph 舌诊 graph(glm-5 视觉模型)"""
 
     async def diagnose(
         self,
@@ -23,46 +15,29 @@ class TongueDiagnosisAgent:
         user_id: int,
         additional_context: Optional[dict] = None,
     ) -> dict:
-        """舌诊分析: 调用 Dify Workflow 或直接 LLM"""
-        if self.dify_base and self.dify_api_key:
-            return await self._via_dify(image_url, user_id, additional_context)
-        else:
-            return await self._via_llm(image_url)
-
-    async def _via_dify(
-        self, image_url: str, user_id: int, context: Optional[dict]
-    ) -> dict:
-        """通过 Dify Workflow 执行舌诊"""
-        url = f"{self.dify_base}/workflows/run"
-        headers = {
-            "Authorization": f"Bearer {self.dify_api_key}",
-            "Content-Type": "application/json",
-        }
-        inputs = {"tongue_image": {"type": "image", "url": image_url}}
-        if context:
-            inputs.update(context)
-
-        body = {
-            "inputs": inputs,
-            "user": str(user_id),
-            "response_mode": "blocking",
-        }
+        return await self._via_llm(image_url)
 
+    async def _via_llm(self, image_url: str) -> dict:
         try:
-            async with httpx.AsyncClient(timeout=30) as client:
-                resp = await client.post(url, json=body, headers=headers)
-                data = resp.json()
-                if "data" in data and "outputs" in data["data"]:
-                    return data["data"]["outputs"]
+            graph = get_tongue_graph()
+            result = graph.invoke({
+                "request": {"image_url": image_url},
+                "image_base64": None,
+                "raw_response": "",
+                "overall_assessment": "",
+                "indicators": [],
+                "error": None,
+            })
+            if result.get("error"):
+                logger.warning("舌诊 graph 失败: %s", result["error"])
+                return self._mock_result()
+            return {
+                "overall_assessment": result["overall_assessment"],
+                "indicators": result["indicators"],
+            }
         except Exception as e:
-            logger.warning("Dify 舌诊失败: %s", e)
-
-        return self._mock_result()
-
-    async def _via_llm(self, image_url: str) -> dict:
-        """直接调用多模态 LLM (预留)"""
-        logger.warning("多模态 LLM 未配置, 返回模拟数据")
-        return self._mock_result()
+            logger.warning("舌诊 graph 执行异常: %s", e)
+            return self._mock_result()
 
     def _mock_result(self) -> dict:
         return {

+ 1 - 1
cfc-langgraph/docker-compose.yml

@@ -21,7 +21,7 @@ services:
       retries: 3
 
   langgraph-svc:
-    image: cfc-langgraph-langgraph-svc:v6
+    image: cfc-langgraph-langgraph-svc:v7
     build:
       context: .
       dockerfile: Dockerfile

+ 0 - 35
cfc-langgraph/src/app.py

@@ -11,8 +11,6 @@ from .schemas.questionnaire import GenerateRequest, GenerateResponse
 from .graphs.questionnaire import get_questionnaire_graph
 from .schemas.emotion import EmotionRequest, EmotionResponse, EmotionItem
 from .graphs.emotion import get_emotion_graph
-from .schemas.tongue import TongueRequest, TongueResponse, TongueIndicator
-from .graphs.tongue import get_tongue_graph
 
 router = APIRouter(prefix="/api/v1", tags=["questionnaire"])
 
@@ -89,36 +87,3 @@ async def recognize_emotion(req: EmotionRequest):
             status_code=500,
             content={"error": f"graph 执行失败: {str(e)}"}
         )
-
-
-# ── 舌诊 ─────────────────────────────────────────────────────
-
-@router.post("/tongue/diagnose")
-async def tongue_diagnose(req: TongueRequest):
-    graph = get_tongue_graph()
-    try:
-        result = graph.invoke({
-            "request": req.model_dump(),
-            "image_base64": None,
-            "raw_response": "",
-            "overall_assessment": "",
-            "indicators": [],
-            "error": None,
-        })
-        if result.get("error"):
-            return JSONResponse(
-                status_code=400,
-                content={"error": result["error"]}
-            )
-        return TongueResponse(
-            overall_assessment=result["overall_assessment"],
-            indicators=[
-                TongueIndicator(code=it["code"], value=it["value"])
-                for it in result["indicators"]
-            ],
-        )
-    except Exception as e:
-        return JSONResponse(
-            status_code=500,
-            content={"error": f"graph 执行失败: {str(e)}"}
-        )

+ 7 - 2
cfc-langgraph/src/graphs/tongue.py

@@ -58,8 +58,13 @@ def load_image(state: GraphState) -> GraphState:
     req = state["request"]
     if req.get("image_base64"):
         return {**state, "image_base64": req["image_base64"]}
-    if req.get("image_url"):
-        return {**state, "error": "舌诊暂不支持 image_url,请传 image_base64"}
+    # 支持 data URL 格式 (data:image/jpeg;base64,xxx) — TongueDiagnosisAgent 调用场景
+    url = req.get("image_url", "")
+    if url and url.startswith("data:image"):
+        payload = url.split(",", 1)[1]
+        return {**state, "image_base64": payload}
+    if url:
+        return {**state, "error": "舌诊暂不支持外部 URL,请传 image_base64 或 data URL"}
     return {**state, "error": "image_url 或 image_base64 至少提供一项"}