adapter.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. from fastapi import APIRouter, HTTPException
  2. from pydantic import BaseModel, ConfigDict
  3. from typing import Optional
  4. from app.graphs.chat_graph import create_chat_graph
  5. from app.graphs.analysis_graph import create_analysis_graph
  6. from app.graphs.health_coach_graph import create_health_coach_graph
  7. from app.graphs.health_butler_graph import create_health_butler_graph
  8. import uuid
  9. import time
  10. router = APIRouter(prefix="/api/v1", tags=["adapter"])
  11. # ---- Dify-compatible request/response models ----
  12. class DifyMessage(BaseModel):
  13. role: str
  14. content: str
  15. class DifyChatRequest(BaseModel):
  16. # 前端 userId/conversationId 可能为数字,允许 int/float 自动转 str,避免 422
  17. model_config = ConfigDict(coerce_numbers_to_str=True)
  18. query: str = ""
  19. user: str = "anonymous"
  20. conversation_id: str = ""
  21. messages: list[DifyMessage] = []
  22. inputs: dict = {}
  23. response_mode: str = "blocking" # blocking / streaming
  24. user_id: str = ""
  25. bot_name: str = "AI管家"
  26. class DifyAnalysisRequest(BaseModel):
  27. # 前端 userId 可能为数字,允许 int/float 自动转 str,避免 422
  28. model_config = ConfigDict(coerce_numbers_to_str=True)
  29. report_id: Optional[int] = None
  30. user_id: Optional[str] = None
  31. focus: Optional[str] = None
  32. messages: list[DifyMessage] = []
  33. inputs: dict = {}
  34. class HealthCoachGenerateRequest(BaseModel):
  35. """Java 后端健康方案生成专用请求体"""
  36. family_id: Optional[int] = None
  37. member_ids: Optional[str] = None
  38. dimensions: Optional[str] = None
  39. goal: str = ""
  40. class DifyChoice(BaseModel):
  41. index: int
  42. message: dict
  43. finish_reason: str = "stop"
  44. class DifyUsage(BaseModel):
  45. prompt_tokens: int = 0
  46. completion_tokens: int = 0
  47. total_tokens: int = 0
  48. class DifyResponse(BaseModel):
  49. id: str
  50. object: str = "chat.completion"
  51. created: int
  52. model: str = "langgraph-cfc"
  53. choices: list[DifyChoice]
  54. usage: DifyUsage = DifyUsage()
  55. metadata: dict = {}
  56. # ---- Helpers ----
  57. def _now_ts() -> int:
  58. return int(time.time())
  59. def _extract_query(req: DifyChatRequest) -> str:
  60. if req.query:
  61. return req.query
  62. # fallback: take last user message
  63. for msg in reversed(req.messages):
  64. if msg.role == "user":
  65. return msg.content
  66. return ""
  67. def _to_langgraph_context(req: DifyChatRequest) -> dict:
  68. ctx = {}
  69. if isinstance(req.inputs, dict):
  70. ctx["child_id"] = req.inputs.get("child_id")
  71. ctx["report_id"] = req.inputs.get("report_id")
  72. ctx["family_id"] = req.inputs.get("family_id")
  73. return ctx
  74. # ---- Dify-compatible endpoints ----
  75. @router.post("/chat/completion", response_model=DifyResponse)
  76. async def chat_completion(req: DifyChatRequest):
  77. query = _extract_query(req)
  78. if not query:
  79. raise HTTPException(status_code=400, detail="query 为空")
  80. graph = create_chat_graph()
  81. initial_state = {
  82. "query": query,
  83. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  84. "conversation_id": req.conversation_id or None,
  85. "intent": None,
  86. "context": _to_langgraph_context(req),
  87. "messages": None,
  88. "answer": None,
  89. "tasks": [],
  90. "sources": [],
  91. }
  92. config = {
  93. "configurable": {"thread_id": req.conversation_id or str(req.user_id or "default")},
  94. }
  95. result = await graph.ainvoke(initial_state, config)
  96. answer = result.get("answer") or ""
  97. return DifyResponse(
  98. id=f"chatcmpl-{uuid.uuid4().hex[:24]}",
  99. created=_now_ts(),
  100. choices=[
  101. DifyChoice(
  102. index=0,
  103. message={"role": "assistant", "content": answer},
  104. finish_reason="stop",
  105. )
  106. ],
  107. usage=DifyUsage(
  108. prompt_tokens=len(query.split()),
  109. completion_tokens=len(answer.split()),
  110. total_tokens=len(query.split()) + len(answer.split()),
  111. ),
  112. )
  113. @router.post("/analysis/run", response_model=DifyResponse)
  114. async def analysis_run(req: DifyAnalysisRequest):
  115. graph = create_analysis_graph()
  116. report_id = req.report_id
  117. if report_id is None and isinstance(req.inputs, dict):
  118. report_id = req.inputs.get("report_id")
  119. initial_state = {
  120. "report_id": report_id,
  121. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  122. "focus": req.focus,
  123. "report_data": None,
  124. "survey_data": None,
  125. "dimension_scores": None,
  126. "analysis": None,
  127. "recommendations": [],
  128. }
  129. result = await graph.ainvoke(initial_state, {})
  130. analysis = result.get("analysis") or ""
  131. recommendations = result.get("recommendations") or []
  132. content = analysis
  133. if recommendations:
  134. content += "\n\n建议:\n" + "\n".join(f"- {r}" for r in recommendations)
  135. return DifyResponse(
  136. id=f"analysis-{uuid.uuid4().hex[:24]}",
  137. created=_now_ts(),
  138. choices=[
  139. DifyChoice(
  140. index=0,
  141. message={"role": "assistant", "content": content},
  142. finish_reason="stop",
  143. )
  144. ],
  145. usage=DifyUsage(
  146. prompt_tokens=len((req.focus or "").split()),
  147. completion_tokens=len(content.split()),
  148. total_tokens=len((req.focus or "").split()) + len(content.split()),
  149. ),
  150. )
  151. @router.post("/health/coach", response_model=DifyResponse)
  152. async def health_coach(req: DifyChatRequest):
  153. query = _extract_query(req)
  154. if not query:
  155. raise HTTPException(status_code=400, detail="query 为空")
  156. graph = create_health_coach_graph()
  157. initial_state = {
  158. "query": query,
  159. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  160. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  161. "conversation_id": req.conversation_id or None,
  162. "context": _to_langgraph_context(req),
  163. "answer": None,
  164. "sources": [],
  165. "memory_messages": None,
  166. }
  167. config = {
  168. "configurable": {"thread_id": req.conversation_id or f"health_{req.user_id}"},
  169. }
  170. result = await graph.ainvoke(initial_state, config)
  171. answer = result.get("answer") or ""
  172. sources = result.get("sources") or []
  173. metadata = {}
  174. if sources:
  175. metadata["sources"] = [
  176. {"title": s.get("title", ""), "type": s.get("type", "knowledge")}
  177. for s in sources
  178. ]
  179. return DifyResponse(
  180. id=f"health-{uuid.uuid4().hex[:24]}",
  181. created=_now_ts(),
  182. model="langgraph-health-coach",
  183. choices=[
  184. DifyChoice(
  185. index=0,
  186. message={"role": "assistant", "content": answer},
  187. finish_reason="stop",
  188. )
  189. ],
  190. usage=DifyUsage(
  191. prompt_tokens=len(query.split()),
  192. completion_tokens=len(answer.split()),
  193. total_tokens=len(query.split()) + len(answer.split()),
  194. ),
  195. metadata=metadata,
  196. )
  197. @router.post("/health/butler", response_model=DifyResponse)
  198. async def health_butler(req: DifyChatRequest):
  199. """AI 健康管家 — 基于健康知识库检索 + 个性化建议 + 任务生成"""
  200. query = _extract_query(req)
  201. if not query:
  202. raise HTTPException(status_code=400, detail="query 为空")
  203. graph = create_health_butler_graph()
  204. # 从 inputs 中提取健康管家需要的上下文
  205. inputs = req.inputs or {}
  206. family_id = inputs.get("family_id")
  207. child_id = inputs.get("child_id")
  208. report_id = inputs.get("report_id")
  209. focus = inputs.get("focus")
  210. # conversation_id 用于 checkpointer thread
  211. thread_id = req.conversation_id or f"butler_{req.user_id or 'anon'}"
  212. initial_state = {
  213. "query": query,
  214. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  215. "conversation_id": thread_id,
  216. "family_id": int(family_id) if family_id else None,
  217. "child_id": int(child_id) if child_id else None,
  218. "report_id": int(report_id) if report_id else None,
  219. "focus": focus,
  220. "kb_context": None,
  221. "knowledge_results": [],
  222. "answer": None,
  223. "tasks": [],
  224. "sources": [],
  225. "messages": None,
  226. }
  227. config = {
  228. "configurable": {"thread_id": thread_id},
  229. }
  230. result = await graph.ainvoke(initial_state, config)
  231. answer = result.get("answer") or ""
  232. # 从回答中提取 TASK 标记
  233. tasks = result.get("tasks") or []
  234. sources = result.get("sources") or []
  235. metadata = {"tasks": tasks, "sources": sources}
  236. return DifyResponse(
  237. id=f"butler-{uuid.uuid4().hex[:24]}",
  238. created=_now_ts(),
  239. model="langgraph-health-butler",
  240. choices=[
  241. DifyChoice(
  242. index=0,
  243. message={"role": "assistant", "content": answer},
  244. finish_reason="stop",
  245. )
  246. ],
  247. usage=DifyUsage(
  248. prompt_tokens=len(query.split()),
  249. completion_tokens=len(answer.split()),
  250. total_tokens=len(query.split()) + len(answer.split()),
  251. ),
  252. metadata=metadata,
  253. )
  254. @router.post("/health/coach/generate")
  255. async def health_coach_generate(req: HealthCoachGenerateRequest):
  256. """Java 后端健康方案生成端点 — 接收 familyId/memberIds/dimensions/goal,返回方案文本"""
  257. graph = create_health_coach_graph()
  258. goal = req.goal or "生成健康方案"
  259. family_id = req.family_id
  260. member_ids = req.member_ids
  261. dimensions = req.dimensions
  262. # 构造用户查询:将目标和维度信息注入 prompt
  263. query_parts = [goal]
  264. if dimensions:
  265. query_parts.append(f"重点关注维度:{dimensions}")
  266. if member_ids:
  267. query_parts.append(f"目标成员IDs:{member_ids}")
  268. query = ";".join(query_parts)
  269. initial_state = {
  270. "query": query,
  271. "user_id": family_id or 0,
  272. "child_id": None,
  273. "conversation_id": None,
  274. "context": {
  275. "family_id": family_id,
  276. "member_ids": member_ids,
  277. "dimensions": dimensions,
  278. },
  279. "answer": None,
  280. "sources": [],
  281. "memory_messages": None,
  282. }
  283. result = await graph.ainvoke(initial_state, {})
  284. answer = result.get("answer") or ""
  285. return answer