Эх сурвалжийг харах

feat(ai): 舌诊迁移至 LangGraph(glm-5 视觉模型),废弃 Dify 舌诊

- cfc-langgraph: 新增 tongue.py graph + schema + 路由 /api/v1/tongue/diagnose
- llm/client: 新增 get_vision_llm() 复用网关 key,默认 glm-5(实测 MiniMax 后端 DNS 不可达)
- AiGateway: 新增 analyzeTongue(imageBase64) 调 LangGraph 舌诊
- AIService.sendTongueDiagnosis: 改走 AiGateway,删除 Dify 调用
- TongueDiagnosisService: parsePreview 读取 MultipartFile 转 base64
- application.yml: 删除 dify.tongue-api-key 配置
Xiaogang Liao 1 долоо хоног өмнө
parent
commit
f54f7b5767

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

@@ -48,9 +48,6 @@ public class AIService {
     @Value("${dify.nutrition-api-key:}")
     private String nutritionApiKey;
 
-    @Value("${dify.tongue-api-key:}")
-    private String tongueApiKey;
-
     @Value("${dify.emotion-api-key:}")
     private String emotionApiKey;
 
@@ -78,13 +75,6 @@ public class AIService {
         return headers;
     }
 
-    private HttpHeaders tongueAuthHeaders() {
-        HttpHeaders headers = new HttpHeaders();
-        headers.setContentType(MediaType.APPLICATION_JSON);
-        headers.setBearerAuth(tongueApiKey);
-        return headers;
-    }
-
     private HttpHeaders emotionAuthHeaders() {
         HttpHeaders headers = new HttpHeaders();
         headers.setContentType(MediaType.APPLICATION_JSON);
@@ -258,49 +248,18 @@ public class AIService {
 
     /**
      * 舌诊图像分析
-     * 调用 LangGraph 舌诊 Assistant 返回结构化舌诊结果
+     * 调用 LangGraph 舌诊 graph(MiniMax 视觉模型)返回结构化舌诊结果
      */
-    @SuppressWarnings("unchecked")
-    public Map<String, Object> sendTongueDiagnosis(String imageUrl, String userId, Map<String, Object> inputs) {
-        if (tongueApiKey == null || tongueApiKey.isEmpty()) {
+    public Map<String, Object> sendTongueDiagnosis(String imageBase64) {
+        if (imageBase64 == null || imageBase64.isEmpty()) {
             return mockTongueResult();
         }
-
-        // PII脱敏
-        Long uid = null;
-        try { uid = Long.valueOf(userId); } catch (Exception ignored) {}
-        Map<String, Object> sanitizedInputs = piisService.sanitizeContext(inputs, uid);
-
-        String url = difyBaseUrl + "/workflows/run";
-        HttpHeaders headers = tongueAuthHeaders();
-
-        Map<String, Object> body = new LinkedHashMap<>();
-        body.put("inputs", sanitizedInputs != null ? sanitizedInputs : Collections.emptyMap());
-        body.put("user", userId);
-        body.put("response_mode", "blocking");
-
-        // 将图片转为 LangGraph 可接收的格式(URL)
-        Map<String, Object> fileInput = new HashMap<>();
-        fileInput.put("type", "image");
-        fileInput.put("url", imageUrl);
-        sanitizedInputs.put("tongue_image", fileInput);
-
-        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
-        try {
-            ResponseEntity<Map> resp = restTemplate.postForEntity(url, entity, Map.class);
-            Map<String, Object> respBody = resp.getBody();
-            if (respBody != null && respBody.containsKey("data")) {
-                Map<String, Object> data = (Map<String, Object>) respBody.get("data");
-                Map<String, Object> outputs = (Map<String, Object>) data.get("outputs");
-                if (outputs != null) {
-                    return outputs;
-                }
-            }
-            return mockTongueResult();
-        } catch (Exception e) {
-            log.warn("LangGraph tongue diagnosis failed, using mock: {}", e.getMessage());
-            return mockTongueResult();
+        Map<String, Object> result = aiGateway.analyzeTongue(imageBase64);
+        if (result != null && !result.isEmpty()) {
+            return result;
         }
+        log.warn("LangGraph tongue diagnosis 未返回结果, 使用 mock 兜底");
+        return mockTongueResult();
     }
 
     /**

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -541,4 +541,34 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 舌诊分析(舌象图片 base64 → LangGraph MiniMax 视觉模型)
+     * @return 含 overall_assessment / indicators 的 Map;失败返回 null
+     */
+    public Map<String, Object> analyzeTongue(String imageBase64) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("image_base64", imageBase64);
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            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);
+                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());
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway analyzeTongue 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
 }

+ 9 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/TongueDiagnosisService.java

@@ -29,20 +29,23 @@ public class TongueDiagnosisService {
     public Map<String, Object> parsePreview(MultipartFile file, Long memberId) {
         String imageUrl = "/uploads/tongue/" + System.currentTimeMillis() + ".jpg";
 
-        String userId = String.valueOf(memberId);
-        Map<String, Object> difyInputs = new HashMap<>();
-        difyInputs.put("memberId", memberId);
-        Map<String, Object> difyResult = aiService.sendTongueDiagnosis(imageUrl, userId, difyInputs);
+        String imageBase64 = null;
+        try {
+            imageBase64 = java.util.Base64.getEncoder().encodeToString(file.getBytes());
+        } catch (Exception e) {
+            throw new RuntimeException("舌诊图片读取失败: " + e.getMessage());
+        }
+        Map<String, Object> diagnosisResult = aiService.sendTongueDiagnosis(imageBase64);
 
         TongueRecord record = new TongueRecord();
         record.setMemberId(memberId);
         record.setChildId(memberId);
         record.setImageUrl(imageUrl);
-        record.setOverallAssessment((String) difyResult.getOrDefault("overall_assessment", ""));
+        record.setOverallAssessment((String) diagnosisResult.getOrDefault("overall_assessment", ""));
         record.setStatus("draft");
         tongueRecordMapper.insert(record);
 
-        List<Map<String, Object>> indicatorList = (List<Map<String, Object>>) difyResult.getOrDefault("indicators", new ArrayList<>());
+        List<Map<String, Object>> indicatorList = (List<Map<String, Object>>) diagnosisResult.getOrDefault("indicators", new ArrayList<>());
         List<IndicatorValue> values = new ArrayList<>();
         for (Map<String, Object> item : indicatorList) {
             String code = (String) item.get("code");

+ 0 - 1
cfc-backend/src/main/resources/application.yml

@@ -91,7 +91,6 @@ dify:
   base-url: ${DIFY_BASE_URL:http://dify.bianwoyou.cn/v1}
   api-key: ${DIFY_API_KEY:app-dev-only-key}
   nutrition-api-key: ${DIFY_NUTRITION_API_KEY:app-dev-only-nutrition-key}
-  tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
   emotion-api-key: "" # 照片情绪识别,空字符串=mock模式
 bodyfat-scale:
   image-dir: ${BODYFAT_SCALE_IMAGE_DIR:docs/参考资料/体脂秤}

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

@@ -11,6 +11,8 @@ 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"])
 
@@ -87,3 +89,36 @@ 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)}"}
+        )

+ 1 - 0
cfc-langgraph/src/graphs/__init__.py

@@ -1 +1,2 @@
 from .emotion import get_emotion_graph, EMOTION_ZH  # noqa: F401
+from .tongue import get_tongue_graph  # noqa: F401

+ 132 - 0
cfc-langgraph/src/graphs/tongue.py

@@ -0,0 +1,132 @@
+"""
+舌诊 LangGraph — 视觉 LLM 舌象分析
+
+流程:
+  START → load_image → analyze_tongue → END
+
+输出:整体评估 + 7 类结构化舌诊指标
+"""
+import base64
+from typing import TypedDict, Optional
+
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import HumanMessage
+
+from ..llm.client import get_vision_llm
+
+TONGUE_INDICATOR_CODES = [
+    "tongue_color", "coating_color", "coating_texture",
+    "fissure", "teeth_mark", "sublingual_vein", "constitution",
+]
+
+# LLM 可能自创的 code 别名 → 规范 code(容错归一化)
+INDICATOR_ALIASES = {
+    "tongue_shape": "tongue_color",
+    "tongue_coating_color": "coating_color",
+    "tongue_coating_texture": "coating_texture",
+    "coating": "coating_color",
+    "sublingual_veins": "sublingual_vein",
+    "tooth_mark": "teeth_mark",
+    "teeth_marks": "teeth_mark",
+    "body_constitution": "constitution",
+}
+
+SYSTEM_PROMPT = (
+    "你是资深中医舌诊专家。根据用户上传的舌象图片,输出结构化 JSON,"
+    "不要输出任何 JSON 之外的文字。"
+    "indicators 数组的 code 字段必须严格从以下 7 个值中选择,禁止自创或改写:"
+    "tongue_color(舌色)、coating_color(苔色)、coating_texture(苔质)、"
+    "fissure(裂纹)、teeth_mark(齿痕)、sublingual_vein(舌下络脉)、constitution(体质)。"
+    "JSON 格式:"
+    '{"overall_assessment": "整体舌象评估结论", "indicators": ['
+    '{"code": "tongue_color", "value": "淡红"}, '
+    '{"code": "coating_color", "value": "薄白"}]}'
+    "指标值用简短中文描述,如舌色「淡红」、苔色「薄白」、裂纹「无」、齿痕「轻」。"
+)
+
+
+class GraphState(TypedDict):
+    request: dict
+    image_base64: Optional[str]
+    raw_response: str
+    overall_assessment: str
+    indicators: list
+    error: Optional[str]
+
+
+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"}
+    return {**state, "error": "image_url 或 image_base64 至少提供一项"}
+
+
+def analyze_tongue(state: GraphState) -> GraphState:
+    if state.get("error"):
+        return state
+    llm = get_vision_llm()
+    content = [
+        {"type": "text", "text": SYSTEM_PROMPT},
+        {
+            "type": "image_url",
+            "image_url": {"url": f"data:image/jpeg;base64,{state['image_base64']}"},
+        },
+    ]
+    response = llm.invoke([HumanMessage(content=content)])
+    return {**state, "raw_response": response.content}
+
+
+def parse_result(state: GraphState) -> GraphState:
+    raw = state.get("raw_response", "").strip()
+    text = raw
+    if "```json" in text:
+        text = text.split("```json")[1].split("```")[0].strip()
+    elif "```" in text:
+        text = text.split("```")[1].split("```")[0].strip()
+
+    import json
+    try:
+        data = json.loads(text)
+        assessment = data.get("overall_assessment", "")
+        indicators = []
+        for it in data.get("indicators", []):
+            code = it.get("code")
+            if code in INDICATOR_ALIASES:
+                code = INDICATOR_ALIASES[code]
+            if code not in TONGUE_INDICATOR_CODES:
+                continue
+            indicators.append({"code": code, "value": it.get("value")})
+        if not assessment or not indicators:
+            return {**state, "error": "舌诊结果缺少评估或指标"}
+        return {
+            **state,
+            "overall_assessment": assessment,
+            "indicators": indicators,
+            "error": None,
+        }
+    except Exception as e:
+        return {**state, "error": f"舌诊 JSON 解析失败: {e}"}
+
+
+def build_tongue_graph():
+    graph = StateGraph(GraphState)
+    graph.add_node("load_image", load_image)
+    graph.add_node("analyze_tongue", analyze_tongue)
+    graph.add_node("parse_result", parse_result)
+    graph.add_edge(START, "load_image")
+    graph.add_edge("load_image", "analyze_tongue")
+    graph.add_edge("analyze_tongue", "parse_result")
+    graph.add_edge("parse_result", END)
+    return graph.compile()
+
+
+_tongue_graph = None
+
+
+def get_tongue_graph():
+    global _tongue_graph
+    if _tongue_graph is None:
+        _tongue_graph = build_tongue_graph()
+    return _tongue_graph

+ 19 - 4
cfc-langgraph/src/llm/client.py

@@ -2,14 +2,29 @@ import os
 from langchain_openai import ChatOpenAI
 
 
+def _common_kwargs() -> dict:
+    return {
+        "api_key": os.getenv("LLM_API_KEY", os.getenv("OPENAI_API_KEY", "")),
+        "base_url": os.getenv("LLM_BASE_URL", os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")),
+        "timeout": 180,
+        "max_retries": 1,
+    }
+
+
 def get_llm() -> ChatOpenAI:
     # 优先 LLM_* 系列(与 .env.production 一致),兼容 OPENAI_* 系列
     # timeout=180/max_retries=1:问卷推理可达 30-60s,重试叠加会拖垮 gunicorn worker
     return ChatOpenAI(
         model=os.getenv("LLM_MODEL", os.getenv("OPENAI_MODEL", "gpt-4o-mini")),
-        api_key=os.getenv("LLM_API_KEY", os.getenv("OPENAI_API_KEY", "")),
-        base_url=os.getenv("LLM_BASE_URL", os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")),
         temperature=0.7,
-        timeout=180,
-        max_retries=1,
+        **_common_kwargs(),
+    )
+
+
+def get_vision_llm() -> ChatOpenAI:
+    # 视觉模型(舌诊等图像分析),复用 LLM 网关与 key,仅切换模型名
+    return ChatOpenAI(
+        model=os.getenv("LLM_VISION_MODEL", "glm-5"),
+        temperature=0.3,
+        **_common_kwargs(),
     )

+ 1 - 0
cfc-langgraph/src/schemas/__init__.py

@@ -1 +1,2 @@
 from .emotion import EmotionRequest, EmotionResponse, EmotionItem  # noqa: F401
+from .tongue import TongueRequest, TongueResponse, TongueIndicator  # noqa: F401

+ 18 - 0
cfc-langgraph/src/schemas/tongue.py

@@ -0,0 +1,18 @@
+from pydantic import BaseModel, Field
+from typing import List, Optional
+
+
+class TongueIndicator(BaseModel):
+    code: str = Field(description="指标编码,如 tongue_color/coating_color/coating_texture/fissure/teeth_mark/sublingual_vein/constitution")
+    value: str = Field(description="指标值,如「淡红」「薄白」「无」")
+
+
+class TongueRequest(BaseModel):
+    image_url: Optional[str] = Field(default=None, description="舌象图片 URL")
+    image_base64: Optional[str] = Field(default=None, description="base64 编码的舌象图片")
+    member_id: Optional[int] = Field(default=None, description="家庭成员 ID")
+
+
+class TongueResponse(BaseModel):
+    overall_assessment: str = Field(description="整体舌象评估结论")
+    indicators: List[TongueIndicator] = Field(description="结构化舌诊指标列表")