adapter.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  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 DifyChoice(BaseModel):
  35. index: int
  36. message: dict
  37. finish_reason: str = "stop"
  38. class DifyUsage(BaseModel):
  39. prompt_tokens: int = 0
  40. completion_tokens: int = 0
  41. total_tokens: int = 0
  42. class DifyResponse(BaseModel):
  43. id: str
  44. object: str = "chat.completion"
  45. created: int
  46. model: str = "langgraph-cfc"
  47. choices: list[DifyChoice]
  48. usage: DifyUsage = DifyUsage()
  49. metadata: dict = {}
  50. # ---- Helpers ----
  51. def _now_ts() -> int:
  52. return int(time.time())
  53. def _extract_query(req: DifyChatRequest) -> str:
  54. if req.query:
  55. return req.query
  56. # fallback: take last user message
  57. for msg in reversed(req.messages):
  58. if msg.role == "user":
  59. return msg.content
  60. return ""
  61. def _to_langgraph_context(req: DifyChatRequest) -> dict:
  62. ctx = {}
  63. if isinstance(req.inputs, dict):
  64. ctx["child_id"] = req.inputs.get("child_id")
  65. ctx["report_id"] = req.inputs.get("report_id")
  66. ctx["family_id"] = req.inputs.get("family_id")
  67. return ctx
  68. # ---- Dify-compatible endpoints ----
  69. @router.post("/chat/completion", response_model=DifyResponse)
  70. async def chat_completion(req: DifyChatRequest):
  71. query = _extract_query(req)
  72. if not query:
  73. raise HTTPException(status_code=400, detail="query 为空")
  74. graph = create_chat_graph()
  75. initial_state = {
  76. "query": query,
  77. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  78. "conversation_id": req.conversation_id or None,
  79. "intent": None,
  80. "context": _to_langgraph_context(req),
  81. "messages": None,
  82. "answer": None,
  83. "tasks": [],
  84. "sources": [],
  85. }
  86. config = {
  87. "configurable": {"thread_id": req.conversation_id or str(req.user_id or "default")},
  88. }
  89. result = await graph.ainvoke(initial_state, config)
  90. answer = result.get("answer") or ""
  91. return DifyResponse(
  92. id=f"chatcmpl-{uuid.uuid4().hex[:24]}",
  93. created=_now_ts(),
  94. choices=[
  95. DifyChoice(
  96. index=0,
  97. message={"role": "assistant", "content": answer},
  98. finish_reason="stop",
  99. )
  100. ],
  101. usage=DifyUsage(
  102. prompt_tokens=len(query.split()),
  103. completion_tokens=len(answer.split()),
  104. total_tokens=len(query.split()) + len(answer.split()),
  105. ),
  106. )
  107. @router.post("/analysis/run", response_model=DifyResponse)
  108. async def analysis_run(req: DifyAnalysisRequest):
  109. graph = create_analysis_graph()
  110. report_id = req.report_id
  111. if report_id is None and isinstance(req.inputs, dict):
  112. report_id = req.inputs.get("report_id")
  113. initial_state = {
  114. "report_id": report_id,
  115. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  116. "focus": req.focus,
  117. "report_data": None,
  118. "survey_data": None,
  119. "dimension_scores": None,
  120. "analysis": None,
  121. "recommendations": [],
  122. }
  123. result = await graph.ainvoke(initial_state, {})
  124. analysis = result.get("analysis") or ""
  125. recommendations = result.get("recommendations") or []
  126. content = analysis
  127. if recommendations:
  128. content += "\n\n建议:\n" + "\n".join(f"- {r}" for r in recommendations)
  129. return DifyResponse(
  130. id=f"analysis-{uuid.uuid4().hex[:24]}",
  131. created=_now_ts(),
  132. choices=[
  133. DifyChoice(
  134. index=0,
  135. message={"role": "assistant", "content": content},
  136. finish_reason="stop",
  137. )
  138. ],
  139. usage=DifyUsage(
  140. prompt_tokens=len((req.focus or "").split()),
  141. completion_tokens=len(content.split()),
  142. total_tokens=len((req.focus or "").split()) + len(content.split()),
  143. ),
  144. )
  145. @router.post("/health/coach", response_model=DifyResponse)
  146. async def health_coach(req: DifyChatRequest):
  147. query = _extract_query(req)
  148. if not query:
  149. raise HTTPException(status_code=400, detail="query 为空")
  150. graph = create_health_coach_graph()
  151. initial_state = {
  152. "query": query,
  153. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  154. "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
  155. "conversation_id": req.conversation_id or None,
  156. "context": _to_langgraph_context(req),
  157. "answer": None,
  158. "sources": [],
  159. "memory_messages": None,
  160. }
  161. config = {
  162. "configurable": {"thread_id": req.conversation_id or f"health_{req.user_id}"},
  163. }
  164. result = await graph.ainvoke(initial_state, config)
  165. answer = result.get("answer") or ""
  166. sources = result.get("sources") or []
  167. metadata = {}
  168. if sources:
  169. metadata["sources"] = [
  170. {"title": s.get("title", ""), "type": s.get("type", "knowledge")}
  171. for s in sources
  172. ]
  173. return DifyResponse(
  174. id=f"health-{uuid.uuid4().hex[:24]}",
  175. created=_now_ts(),
  176. model="langgraph-health-coach",
  177. choices=[
  178. DifyChoice(
  179. index=0,
  180. message={"role": "assistant", "content": answer},
  181. finish_reason="stop",
  182. )
  183. ],
  184. usage=DifyUsage(
  185. prompt_tokens=len(query.split()),
  186. completion_tokens=len(answer.split()),
  187. total_tokens=len(query.split()) + len(answer.split()),
  188. ),
  189. metadata=metadata,
  190. )
  191. @router.post("/health/butler", response_model=DifyResponse)
  192. async def health_butler(req: DifyChatRequest):
  193. """AI 健康管家 — 基于健康知识库检索 + 个性化建议 + 任务生成"""
  194. query = _extract_query(req)
  195. if not query:
  196. raise HTTPException(status_code=400, detail="query 为空")
  197. graph = create_health_butler_graph()
  198. # 从 inputs 中提取健康管家需要的上下文
  199. inputs = req.inputs or {}
  200. family_id = inputs.get("family_id")
  201. child_id = inputs.get("child_id")
  202. report_id = inputs.get("report_id")
  203. focus = inputs.get("focus")
  204. # conversation_id 用于 checkpointer thread
  205. thread_id = req.conversation_id or f"butler_{req.user_id or 'anon'}"
  206. initial_state = {
  207. "query": query,
  208. "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
  209. "conversation_id": thread_id,
  210. "family_id": int(family_id) if family_id else None,
  211. "child_id": int(child_id) if child_id else None,
  212. "report_id": int(report_id) if report_id else None,
  213. "focus": focus,
  214. "kb_context": None,
  215. "knowledge_results": [],
  216. "answer": None,
  217. "tasks": [],
  218. "sources": [],
  219. "messages": None,
  220. }
  221. config = {
  222. "configurable": {"thread_id": thread_id},
  223. }
  224. result = await graph.ainvoke(initial_state, config)
  225. answer = result.get("answer") or ""
  226. # 从回答中提取 TASK 标记
  227. tasks = result.get("tasks") or []
  228. sources = result.get("sources") or []
  229. metadata = {"tasks": tasks, "sources": sources}
  230. return DifyResponse(
  231. id=f"butler-{uuid.uuid4().hex[:24]}",
  232. created=_now_ts(),
  233. model="langgraph-health-butler",
  234. choices=[
  235. DifyChoice(
  236. index=0,
  237. message={"role": "assistant", "content": answer},
  238. finish_reason="stop",
  239. )
  240. ],
  241. usage=DifyUsage(
  242. prompt_tokens=len(query.split()),
  243. completion_tokens=len(answer.split()),
  244. total_tokens=len(query.split()) + len(answer.split()),
  245. ),
  246. metadata=metadata,
  247. )