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