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

feat(langgraph): 健康方案生成 Pydantic 校验 + 正则兜底提取 tasks

iwt пре 2 месеци
родитељ
комит
59ac9be0ef
2 измењених фајлова са 131 додато и 11 уклоњено
  1. 103 11
      cfc-langgraph/app/api/adapter.py
  2. 28 0
      cfc-langgraph/tests/test_health_plan.py

+ 103 - 11
cfc-langgraph/app/api/adapter.py

@@ -11,6 +11,7 @@ from app.rag.retriever import RagRetriever
 from app.config import settings
 from langchain_openai import ChatOpenAI
 from langchain_core.messages import SystemMessage, HumanMessage
+from app.models.health_plan import HealthPlanResponse, PlanTask
 import uuid
 import time
 import re
@@ -723,6 +724,104 @@ async def _collect_plan_data(java: JavaClient, retriever: RagRetriever, member_i
     return members_info, all_indicators, kb_results
 
 
+# 从 LLM 原始输出中提取 tasks 的正则兜底:在 content 文本里找形如
+# "1. 动作(动词/名词)..." 的行。以可识别的动作词开头视为潜在任务。
+_TASK_FALLBACK_RE = re.compile(r"^\s*(?:\d+[\.、)]|\-\s*)\s*"
+                               r"(?:(?:购买|购置|阅读|看|运动|锻炼|跑步|散步|打卡|记录|饮食|吃|少|多|活动|参加|亲子).*)$")
+
+
+def _parse_plan_response(answer: str) -> dict:
+    """解析 LLM 原始输出为 HealthPlanResponse;非法 JSON 或校验失败时用正则兜底提取 tasks。
+
+    返回 dict:{"success": bool, "data": {...}, "error": str|None}
+    """
+    start = answer.find("{")
+    end = answer.rfind("}") + 1
+    if start >= 0 and end > start:
+        try:
+            parsed = json.loads(answer[start:end])
+            resp = HealthPlanResponse.model_validate(parsed)
+            return {"success": True, "data": resp.model_dump(), "error": None}
+        except Exception as e:
+            # 校验失败:尝试正则兜底
+            fallback = _fallback_extract_tasks(parsed if isinstance(parsed, dict) else {})
+            if fallback is not None:
+                return {"success": True, "data": fallback, "error": str(e)}
+            return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
+                    "error": str(e)}
+    return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
+            "error": "no JSON found"}
+
+
+def _fallback_extract_tasks(parsed: dict) -> Optional[dict]:
+    """当 LLM 输出缺 tasks 或校验失败时,从各 section.content 用正则提取任务并回填。
+    任一 section 无 tasks 才触发;全部已含 tasks 则返回 None(表示无需兜底)。"""
+    if not isinstance(parsed, dict):
+        return None
+    sections = parsed.get("sections")
+    if not isinstance(sections, list) or not sections:
+        return None
+    changed = False
+    for sec in sections:
+        if not isinstance(sec, dict):
+            continue
+        tasks = sec.get("tasks")
+        if isinstance(tasks, list) and tasks:
+            # 检查 tasks 是否全部有效;如有无效项则视为缺失,触发 fallback
+            try:
+                for t in tasks:
+                    PlanTask.model_validate(t)
+                continue  # 全部有效,跳过
+            except Exception:
+                pass  # 有无效项,继续执行 fallback
+        content = sec.get("content", "")
+        extracted = []
+        for raw in content.split("\n"):
+            line = raw.strip()
+            if not line:
+                continue
+            m = _TASK_FALLBACK_RE.match(line)
+            if not m:
+                continue
+            # 去掉行首编号/项目符号
+            title = re.sub(r"^\s*(?:\d+[\.、)]|\-\s*)\s*", "", line).strip()
+            if not title:
+                continue
+            action = _classify_action(title)
+            if action is None:
+                continue
+            extracted.append({
+                "action_type": action["action_type"],
+                "title": title,
+                "dimension": action["dimension"],
+                "frequency": action["frequency"],
+                "notes": "",
+            })
+        if extracted:
+            sec["tasks"] = extracted
+            changed = True
+    if changed:
+        return parsed
+    return None
+
+
+def _classify_action(title: str) -> Optional[dict]:
+    """按动作词分类,映射到 action_type + 五维维度 + 频率(与 Java classifyTaskLine 对齐)。"""
+    if re.search(r"购买|购置|采购|下单|买入|囤|选购", title):
+        return {"action_type": "buy", "dimension": "wealth", "frequency": "once"}
+    if re.search(r"阅读|看|读书", title):
+        return {"action_type": "read", "dimension": "wisdom", "frequency": "daily"}
+    if re.search(r"运动|锻炼|跑步|散步|健身|瑜伽|拉伸", title):
+        return {"action_type": "exercise", "dimension": "body", "frequency": "daily"}
+    if re.search(r"打卡|记录|复盘|记", title):
+        return {"action_type": "checkin", "dimension": "mind", "frequency": "daily"}
+    if re.search(r"饮食|吃|少|多|餐|营养|水", title):
+        return {"action_type": "diet", "dimension": "body", "frequency": "daily"}
+    if re.search(r"活动|参加|亲子|社交|户外|游戏", title):
+        return {"action_type": "activity", "dimension": "action", "frequency": "daily"}
+    return None
+
+
 @router.post("/health/plan/generate", response_model=dict)
 async def health_plan_generate(req: HealthPlanRequest):
     """健康方案生成 — 返回结构化 JSON(总览+营养+饮食+运动)"""
@@ -789,17 +888,10 @@ async def health_plan_generate(req: HealthPlanRequest):
     try:
         response = await llm.ainvoke(messages)
         answer = response.content
-        # 解析 JSON
-        import json
-        try:
-            start = answer.find("{")
-            end = answer.rfind("}") + 1
-            if start >= 0 and end > start:
-                parsed = json.loads(answer[start:end])
-                return {"success": True, "data": parsed}
-        except Exception as e:
-            logger.warning("解析方案 JSON 失败: %s", e)
-        return {"success": True, "data": {"raw": answer, "overview": answer[:200]}, "parse_error": str(e)}
+        result = _parse_plan_response(answer)
+        if result["success"]:
+            return {"success": True, "data": result["data"], "parse_error": result["error"]}
+        return {"success": True, "data": result["data"], "parse_error": result["error"]}
     except Exception as e:
         logger.error("方案生成失败: %s", e)
         return {"success": False, "error": str(e)}

+ 28 - 0
cfc-langgraph/tests/test_health_plan.py

@@ -1,4 +1,5 @@
 from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse
+from app.api.adapter import _parse_plan_response, _fallback_extract_tasks
 
 
 def test_plan_task_valid():
@@ -38,3 +39,30 @@ def test_plan_response_with_tasks():
     # 序列化后 tasks 字段存在
     data = resp.model_dump()
     assert "tasks" in data["sections"][0]
+
+
+def test_parse_plan_response_valid():
+    answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"c","items":[],"tasks":[{"action_type":"diet","title":"少油少盐","dimension":"body","frequency":"daily"}]}],"abnormal_indicators":[]}'
+    res = _parse_plan_response(answer)
+    assert res["success"] is True
+    assert res["data"]["sections"][0]["tasks"][0]["action_type"] == "diet"
+
+
+def test_parse_plan_response_invalid_action_falls_back():
+    # LLM 输出含非法 action_type("cook"),校验失败但可被 fallback 处理
+    answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"1. 少油少盐\\n2. 多吃蔬菜\\n3. 纯原理描述无动作","items":[],"tasks":[{"action_type":"cook","title":"做饭","dimension":"body"}]}],"abnormal_indicators":[]}'
+    res = _parse_plan_response(answer)
+    # fallback 从 content 提取动作行
+    assert res["success"] is True
+    tasks = res["data"]["sections"][0].get("tasks", [])
+    assert len(tasks) >= 2
+
+
+def test_parse_plan_response_no_json():
+    res = _parse_plan_response("这是纯文本没有 JSON")
+    assert res["success"] is False
+
+
+def test_fallback_skips_when_tasks_exist():
+    parsed = {"sections": [{"key": "diet", "title": "t", "content": "1. 少油少盐", "tasks": [{"action_type": "diet", "title": "已有", "dimension": "body"}]}]}
+    assert _fallback_extract_tasks(parsed) is None