|
@@ -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
|