|
|
@@ -5,8 +5,18 @@ from app.graphs.chat_graph import create_chat_graph
|
|
|
from app.graphs.analysis_graph import create_analysis_graph
|
|
|
from app.graphs.health_coach_graph import create_health_coach_graph
|
|
|
from app.graphs.health_butler_graph import create_health_butler_graph
|
|
|
+from app.tools.java_client import JavaClient
|
|
|
+from app.rag.retriever import RagRetriever
|
|
|
+from app.config import settings
|
|
|
+from langchain_openai import ChatOpenAI
|
|
|
+from langchain_core.messages import SystemMessage, HumanMessage
|
|
|
import uuid
|
|
|
import time
|
|
|
+import re
|
|
|
+import logging
|
|
|
+import json
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["adapter"])
|
|
|
|
|
|
@@ -284,37 +294,270 @@ async def health_butler(req: DifyChatRequest):
|
|
|
)
|
|
|
|
|
|
|
|
|
+def _parse_normal_range(ref_range: str) -> tuple[float | None, float | None]:
|
|
|
+ """解析正常范围字符串,返回 (下限, 上限),如 '30-100' → (30, 100), '<5' → (None, 5)"""
|
|
|
+ if not ref_range:
|
|
|
+ return None, None
|
|
|
+ ref_range = ref_range.strip()
|
|
|
+ m = re.match(r'([<>]=?)\s*([\d.]+)', ref_range)
|
|
|
+ if m:
|
|
|
+ op, val = m.group(1), float(m.group(2))
|
|
|
+ if op.startswith('>'):
|
|
|
+ return (val, None)
|
|
|
+ else:
|
|
|
+ return (None, val)
|
|
|
+ m = re.match(r'([\d.]+)\s*[-~]\s*([\d.]+)', ref_range)
|
|
|
+ if m:
|
|
|
+ return (float(m.group(1)), float(m.group(2)))
|
|
|
+ return None, None
|
|
|
+
|
|
|
+
|
|
|
+def _is_abnormal(status: str, value: float | None, low: float | None, high: float | None) -> bool:
|
|
|
+ """判断指标是否异常:优先用 status 字段,否则用数值与范围比较"""
|
|
|
+ if status and status not in ("正常", "正常范围", "未检出", ""):
|
|
|
+ return True
|
|
|
+ if value is not None and low is not None and high is not None:
|
|
|
+ return value < low or value > high
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def _try_parse_value(raw: str) -> float | None:
|
|
|
+ if not raw:
|
|
|
+ return None
|
|
|
+ raw = raw.strip().replace(",", "").replace(" ", "")
|
|
|
+ try:
|
|
|
+ return float(raw)
|
|
|
+ except ValueError:
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+async def _search_knowledge(retriever: RagRetriever, query: str, k: int = 3) -> list[dict]:
|
|
|
+ """从知识库检索相关内容"""
|
|
|
+ try:
|
|
|
+ return await retriever.retrieve(query, k=k)
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning("知识库检索失败: %s", e)
|
|
|
+ return []
|
|
|
+
|
|
|
+
|
|
|
@router.post("/health/coach/generate")
|
|
|
async def health_coach_generate(req: HealthCoachGenerateRequest):
|
|
|
- """Java 后端健康方案生成端点 — 接收 familyId/memberIds/dimensions/goal,返回方案文本"""
|
|
|
- graph = create_health_coach_graph()
|
|
|
- goal = req.goal or "生成健康方案"
|
|
|
- family_id = req.family_id
|
|
|
- member_ids = req.member_ids
|
|
|
- dimensions = req.dimensions
|
|
|
+ """健康方案生成 — 选人→拉指标→查知识库→LLM"""
|
|
|
+ goal = req.goal or "改善健康状况"
|
|
|
+ member_ids_str = req.member_ids or ""
|
|
|
+ dimensions = req.dimensions or ""
|
|
|
+ member_ids = [m.strip() for m in member_ids_str.split(",") if m.strip()]
|
|
|
+
|
|
|
+ java = JavaClient()
|
|
|
+ retriever = RagRetriever(collection_name="cfc_knowledge")
|
|
|
+ llm = ChatOpenAI(
|
|
|
+ model=settings.llm_model,
|
|
|
+ api_key=settings.llm_api_key,
|
|
|
+ base_url=settings.llm_base_url,
|
|
|
+ temperature=0.3,
|
|
|
+ )
|
|
|
|
|
|
- # 构造用户查询:将目标和维度信息注入 prompt
|
|
|
- query_parts = [goal]
|
|
|
- if dimensions:
|
|
|
- query_parts.append(f"重点关注维度:{dimensions}")
|
|
|
+ # ====== 1. 获取家庭成员信息 ======
|
|
|
+ members_info = []
|
|
|
if member_ids:
|
|
|
- query_parts.append(f"目标成员IDs:{member_ids}")
|
|
|
- query = ";".join(query_parts)
|
|
|
+ # 从 context 获取家庭信息
|
|
|
+ for uid_str in member_ids:
|
|
|
+ uid = int(uid_str)
|
|
|
+ ctx = await java.get_family_context(uid, "child_info")
|
|
|
+ children = ctx.get("children", []) if isinstance(ctx, dict) else []
|
|
|
+ for child in children:
|
|
|
+ child_id = str(child.get("用户ID", ""))
|
|
|
+ if child_id in member_ids:
|
|
|
+ members_info.append({
|
|
|
+ "id": child_id,
|
|
|
+ "name": child.get("姓名", f"成员{child_id}"),
|
|
|
+ "age": child.get("年龄", "未知"),
|
|
|
+ "energy": child.get("能量", 0),
|
|
|
+ })
|
|
|
+ break
|
|
|
+ # 如果 context 没找到,用基本信息兜底
|
|
|
+ if not any(m["id"] == uid_str for m in members_info):
|
|
|
+ members_info.append({"id": uid_str, "name": f"成员{uid_str}", "age": "未知", "energy": 0})
|
|
|
+ else:
|
|
|
+ members_info.append({"id": "0", "name": "用户", "age": "未知", "energy": 0})
|
|
|
+
|
|
|
+ # ====== 2. 获取每个成员的指标数据 ======
|
|
|
+ all_indicators = []
|
|
|
+ for member in members_info:
|
|
|
+ uid = int(member["id"])
|
|
|
+ reports = await java.get_member_reports(uid)
|
|
|
+ if not reports:
|
|
|
+ logger.info("成员 %s 无健康报告", member["id"])
|
|
|
+ continue
|
|
|
+ latest = max(reports, key=lambda r: r.get("reportDate", ""))
|
|
|
+ report_id = latest.get("id")
|
|
|
+ member["latest_report_id"] = report_id
|
|
|
+ member["report_date"] = latest.get("reportDate", "")
|
|
|
+ member["overall_score"] = latest.get("overallScore", "未知")
|
|
|
+
|
|
|
+ indicators = await java.get_report_indicators(report_id)
|
|
|
+ for ind in indicators:
|
|
|
+ ind["_member_id"] = member["id"]
|
|
|
+ ind["_member_name"] = member["name"]
|
|
|
+ all_indicators.append(ind)
|
|
|
+
|
|
|
+ # ====== 3. 获取指标定义+正常范围 ======
|
|
|
+ known_indicators = {}
|
|
|
+ for ind in all_indicators:
|
|
|
+ name = ind.get("indicatorName", "").strip()
|
|
|
+ if not name or name in known_indicators:
|
|
|
+ continue
|
|
|
+ kb = await java.query_health_knowledge("indicator", name)
|
|
|
+ if not kb:
|
|
|
+ kb = await java.query_health_knowledge("bacteria", name)
|
|
|
+ if not kb:
|
|
|
+ kb = await java.query_health_knowledge("nutrient", name)
|
|
|
+ if kb:
|
|
|
+ known_indicators[name] = kb
|
|
|
+
|
|
|
+ # ====== 4. 识别异常指标 ======
|
|
|
+ abnormal_list = []
|
|
|
+ normal_list = []
|
|
|
+ for ind in all_indicators:
|
|
|
+ name = ind.get("indicatorName", "")
|
|
|
+ raw_val = ind.get("indicatorValue", "")
|
|
|
+ status = ind.get("status", "")
|
|
|
+ unit = ind.get("unit", "")
|
|
|
+ ref_range = ind.get("refRange", "")
|
|
|
+
|
|
|
+ # 优先用知识库中的正常范围
|
|
|
+ kb = known_indicators.get(name)
|
|
|
+ if kb and kb.get("normalRange"):
|
|
|
+ ref_range = kb.get("normalRange", ref_range)
|
|
|
+
|
|
|
+ low, high = _parse_normal_range(ref_range)
|
|
|
+ value = _try_parse_value(raw_val)
|
|
|
+ is_abnormal = _is_abnormal(status, value, low, high)
|
|
|
+
|
|
|
+ entry = {
|
|
|
+ "member": ind.get("_member_name", ""),
|
|
|
+ "indicator": name,
|
|
|
+ "value": raw_val,
|
|
|
+ "unit": unit,
|
|
|
+ "ref_range": ref_range,
|
|
|
+ "status": status,
|
|
|
+ "is_abnormal": is_abnormal,
|
|
|
+ "description": kb.get("description", "") if kb else "",
|
|
|
+ "suggestion": kb.get("suggestion", "") if kb else "",
|
|
|
+ }
|
|
|
+ if is_abnormal:
|
|
|
+ abnormal_list.append(entry)
|
|
|
+ else:
|
|
|
+ normal_list.append(entry)
|
|
|
+
|
|
|
+ # ====== 5. 检索知识库 ======
|
|
|
+ kb_results = []
|
|
|
+
|
|
|
+ # 5a. 异常指标检索
|
|
|
+ abnormal_queries = set()
|
|
|
+ for ind in abnormal_list:
|
|
|
+ abnormal_queries.add(ind["indicator"])
|
|
|
+ for q in list(abnormal_queries)[:5]:
|
|
|
+ results = await _search_knowledge(retriever, f"{q} 改善建议", k=3)
|
|
|
+ kb_results.extend(results)
|
|
|
+
|
|
|
+ # 5b. 用户需求检索
|
|
|
+ goal_results = await _search_knowledge(retriever, goal, k=5)
|
|
|
+ kb_results.extend(goal_results)
|
|
|
+
|
|
|
+ # 5c. 维度检索
|
|
|
+ if dimensions:
|
|
|
+ dim_results = await _search_knowledge(retriever, dimensions, k=3)
|
|
|
+ kb_results.extend(dim_results)
|
|
|
+
|
|
|
+ # 去重
|
|
|
+ seen_content = set()
|
|
|
+ deduped_kb = []
|
|
|
+ for r in kb_results:
|
|
|
+ h = r.get("content", "")[:100]
|
|
|
+ if h not in seen_content:
|
|
|
+ seen_content.add(h)
|
|
|
+ deduped_kb.append(r)
|
|
|
+
|
|
|
+ # ====== 6. 组装结构化 Prompt ======
|
|
|
+ prompt_parts = []
|
|
|
+
|
|
|
+ # 系统提示
|
|
|
+ prompt_parts.append("""你是一个专业的家庭健康方案生成器。请根据用户提供的健康数据,生成一份结构化的健康改善方案。
|
|
|
+
|
|
|
+输出格式要求:
|
|
|
+## 方案概述
|
|
|
+[简要说明方案的总体目标和适用对象]
|
|
|
+
|
|
|
+## 成员健康概况
|
|
|
+[每个成员的关键指标摘要]
|
|
|
+
|
|
|
+## 需要关注的异常指标
|
|
|
+[列出异常指标及对应的知识库建议]
|
|
|
+
|
|
|
+## 改善方案
|
|
|
+### 1. 饮食调整
|
|
|
+[具体、可执行的饮食建议]
|
|
|
+
|
|
|
+### 2. 生活习惯
|
|
|
+[具体、可执行的生活习惯建议]
|
|
|
+
|
|
|
+### 3. 补充建议
|
|
|
+[如需补充营养素或益生菌,给出具体建议]
|
|
|
+
|
|
|
+### 4. 跟踪建议
|
|
|
+[建议定期复查的指标和频率]
|
|
|
+
|
|
|
+## 注意事项
|
|
|
+[禁忌、提醒等]
|
|
|
+
|
|
|
+请基于实际数据给出建议,不要编造科学依据。引用知识库内容时标注来源。""")
|
|
|
+
|
|
|
+ # 目标与维度
|
|
|
+ prompt_parts.append(f"\n## 用户目标\n{goal}")
|
|
|
+ if dimensions:
|
|
|
+ prompt_parts.append(f"\n## 重点关注维度\n{dimensions}")
|
|
|
+
|
|
|
+ # 成员信息
|
|
|
+ prompt_parts.append("\n## 家庭成员")
|
|
|
+ for m in members_info:
|
|
|
+ scores = f"健康评分: {m.get('overall_score', '未知')}" if m.get('overall_score') else ""
|
|
|
+ report = f"最近报告: {m.get('report_date', '无')}" if m.get('report_date') else ""
|
|
|
+ prompt_parts.append(f"- {m['name']} (年龄: {m['age']}) {scores} {report}")
|
|
|
+
|
|
|
+ # 异常指标
|
|
|
+ if abnormal_list:
|
|
|
+ prompt_parts.append("\n## 异常指标")
|
|
|
+ for ind in abnormal_list:
|
|
|
+ parts = [f"- {ind['member']} - {ind['indicator']}: {ind['value']}{ind['unit']} (参考范围: {ind['ref_range']})"]
|
|
|
+ if ind['description']:
|
|
|
+ parts.append(f" 说明: {ind['description']}")
|
|
|
+ if ind['suggestion']:
|
|
|
+ parts.append(f" 建议: {ind['suggestion']}")
|
|
|
+ prompt_parts.append("\n".join(parts))
|
|
|
+
|
|
|
+ # 正常指标
|
|
|
+ if normal_list:
|
|
|
+ prompt_parts.append("\n## 正常指标(参考)")
|
|
|
+ normal_summary = [f"- {ind['indicator']}: {ind['value']}{ind['unit']} (正常)" for ind in normal_list[:10]]
|
|
|
+ prompt_parts.extend(normal_summary)
|
|
|
+
|
|
|
+ # 知识库参考
|
|
|
+ if deduped_kb:
|
|
|
+ prompt_parts.append("\n## 知识库参考(可引用)")
|
|
|
+ for r in deduped_kb[:8]:
|
|
|
+ title = r.get("metadata", {}).get("title", "")
|
|
|
+ content = r.get("content", "")[:300]
|
|
|
+ prompt_parts.append(f"---\n{title}\n{content}")
|
|
|
+
|
|
|
+ full_prompt = "\n".join(prompt_parts)
|
|
|
+
|
|
|
+ # ====== 7. 调用 LLM ======
|
|
|
+ messages = [
|
|
|
+ SystemMessage(content=full_prompt),
|
|
|
+ HumanMessage(content=f"请基于以上数据,生成一份针对{goal}的健康改善方案。"),
|
|
|
+ ]
|
|
|
+ response = await llm.ainvoke(messages)
|
|
|
+ answer = response.content
|
|
|
|
|
|
- initial_state = {
|
|
|
- "query": query,
|
|
|
- "user_id": family_id or 0,
|
|
|
- "child_id": None,
|
|
|
- "conversation_id": None,
|
|
|
- "context": {
|
|
|
- "family_id": family_id,
|
|
|
- "member_ids": member_ids,
|
|
|
- "dimensions": dimensions,
|
|
|
- },
|
|
|
- "answer": None,
|
|
|
- "sources": [],
|
|
|
- "memory_messages": None,
|
|
|
- }
|
|
|
- result = await graph.ainvoke(initial_state, {})
|
|
|
- answer = result.get("answer") or ""
|
|
|
return answer
|