qa.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. # backend/api/v1/qa.py
  2. import json
  3. from fastapi import APIRouter, Depends, HTTPException, status
  4. from pydantic import BaseModel, Field
  5. from sse_starlette.sse import EventSourceResponse
  6. from langchain_core.messages import HumanMessage
  7. from backend.agents.qa.graph import build_qa_graph
  8. from backend.core.memory import build_thread_id
  9. from backend.dependencies import get_current_user
  10. from backend.core.logger import get_logger
  11. router = APIRouter()
  12. logger = get_logger(__name__)
  13. # ── 请求 / 响应模型 ───────────────────────────────────────────
  14. class ChatRequest(BaseModel):
  15. session_id: str = Field(..., description="会话 ID")
  16. tender_document_id: str | None = Field(None, description="招标资料 ID(可选,限定检索范围)")
  17. message: str = Field(..., min_length=1, max_length=2000)
  18. enable_web_search: bool = Field(False, description="低置信度时是否先走 Web Search 再给 LLM")
  19. class ChatResponse(BaseModel):
  20. session_id: str
  21. answer: str
  22. answer_mode: str # "rag" / "web_augmented" / "llm_direct" / "general"
  23. confidence: float
  24. sources: list[str]
  25. fallback_used: bool
  26. class SessionMessage(BaseModel):
  27. role: str # "user" / "assistant"
  28. content: str
  29. created_at: str
  30. class HistoryResponse(BaseModel):
  31. session_id: str
  32. messages: list[SessionMessage]
  33. summary: str | None
  34. total_turns: int
  35. @router.post("/chat", response_model=ChatResponse)
  36. async def chat(
  37. req: ChatRequest,
  38. current_user: dict = Depends(get_current_user),
  39. ):
  40. """智能问答:发送消息,获取 RAG 或 LLM 直答(非流式)"""
  41. graph = build_qa_graph()
  42. thread_id = build_thread_id(current_user["user_id"], req.session_id)
  43. initial_state = {
  44. "messages": [HumanMessage(content=req.message)],
  45. "bidder_id": current_user["user_id"],
  46. "tenant_id": current_user["tenant_id"],
  47. "session_id": req.session_id,
  48. "tender_document_id": req.tender_document_id,
  49. "query_type": "PRECISE", # 占位初始值,classify_query_node 内部会动态覆盖
  50. "enable_web_search": req.enable_web_search,
  51. "web_search_results": [], # 每轮重置,防止上轮搜索结果污染本轮 sources
  52. }
  53. config: dict = {"configurable": {"thread_id": thread_id}}
  54. try:
  55. result = await graph.ainvoke(initial_state, config=config)
  56. except Exception as e:
  57. logger.error("chat.invoke_error", error=str(e), exc_info=True)
  58. raise HTTPException(
  59. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  60. detail={"code": "AGENT_ERROR", "message": str(e)},
  61. )
  62. print(f'response: {result}')
  63. return ChatResponse(
  64. session_id=req.session_id,
  65. answer=result.get("answer", ""),
  66. answer_mode=result.get("answer_mode", "llm_direct"),
  67. confidence=result.get("confidence", 0.0),
  68. sources=result.get("sources", []),
  69. fallback_used=result.get("fallback_used", False),
  70. )
  71. @router.post("/chat/stream")
  72. async def chat_stream(
  73. req: ChatRequest,
  74. current_user: dict = Depends(get_current_user),
  75. ):
  76. """智能问答流式接口(SSE),与 /chat 入参相同,响应改为流式推送"""
  77. graph = build_qa_graph()
  78. thread_id = build_thread_id(current_user["user_id"], req.session_id)
  79. initial_state = {
  80. "messages": [HumanMessage(content=req.message)],
  81. "bidder_id": current_user["user_id"],
  82. "tenant_id": current_user["tenant_id"],
  83. "session_id": req.session_id,
  84. "tender_document_id": req.tender_document_id,
  85. "query_type": "PRECISE",
  86. "enable_web_search": req.enable_web_search,
  87. "web_search_results": [], # 每轮重置,防止上轮搜索结果污染本轮 sources
  88. }
  89. config: dict = {"configurable": {"thread_id": thread_id}}
  90. # 只对这三个节点做 token 级流式推送
  91. _GENERATE_NODES = {"generate_rag", "generate_direct", "generate_general"}
  92. # 节点开始时推送的进度文案
  93. _PROGRESS_LABELS = {
  94. "classify_query": "理解问题中...",
  95. "hyde_generate": "理解问题中...",
  96. "multi_query_rewrite": "改写查询中...",
  97. "retrieve": "召回相关文档...",
  98. "web_search": "搜索互联网...",
  99. "generate_general": "思考中...",
  100. }
  101. async def event_generator():
  102. answer_mode = "llm_direct"
  103. confidence = 0.0
  104. sources: list[str] = []
  105. try:
  106. async for event in graph.astream_events(
  107. initial_state, config=config, version="v2"
  108. ):
  109. evt = event["event"]
  110. node = event.get("metadata", {}).get("langgraph_node", "")
  111. # ── 节点开始:推送进度提示 ──────────────────────
  112. if evt == "on_chain_start" and node in _PROGRESS_LABELS:
  113. yield {
  114. "data": json.dumps(
  115. {"type": "progress", "stage": _PROGRESS_LABELS[node]},
  116. ensure_ascii=False,
  117. )
  118. }
  119. # ── LLM 实时 token ──────────────────────────────
  120. elif evt == "on_chat_model_stream" and node in _GENERATE_NODES:
  121. chunk = event["data"].get("chunk")
  122. if chunk and chunk.content:
  123. yield {
  124. "data": json.dumps(
  125. {"type": "token", "content": chunk.content},
  126. ensure_ascii=False,
  127. )
  128. }
  129. # ── 节点结束:捕获元数据 ────────────────────────
  130. elif evt == "on_chain_end" and node in _GENERATE_NODES:
  131. output = event["data"].get("output", {})
  132. if isinstance(output, dict):
  133. _mode = output.get("answer_mode")
  134. if _mode:
  135. answer_mode = _mode
  136. _srcs = output.get("sources")
  137. if _srcs is not None:
  138. sources = _srcs
  139. _conf = (output.get("structured_output") or {}).get("confidence")
  140. if _conf is not None:
  141. confidence = _conf
  142. except Exception as e:
  143. logger.error("chat_stream.error", error=str(e), exc_info=True)
  144. yield {
  145. "data": json.dumps(
  146. {"type": "error", "message": "流式输出异常,请使用普通接口重试"},
  147. ensure_ascii=False,
  148. )
  149. }
  150. return
  151. # ── 元数据帧(流结束后一次性推送)──────────────────────
  152. yield {
  153. "data": json.dumps(
  154. {
  155. "type": "meta",
  156. "session_id": req.session_id,
  157. "answer_mode": answer_mode,
  158. "confidence": confidence,
  159. "sources": sources,
  160. },
  161. ensure_ascii=False,
  162. )
  163. }
  164. yield {"data": json.dumps({"type": "done"})}
  165. return EventSourceResponse(event_generator())
  166. @router.get("/sessions/{session_id}/history", response_model=HistoryResponse)
  167. async def get_session_history(
  168. session_id: str,
  169. current_user: dict = Depends(get_current_user),
  170. ):
  171. """获取会话历史消息与摘要"""
  172. from sqlalchemy import text as sa_text
  173. from langchain_core.messages import HumanMessage as LCHuman, AIMessage as LCAi
  174. from backend.dependencies import AsyncSessionLocal
  175. bidder_id = current_user["user_id"]
  176. thread_id = build_thread_id(bidder_id, session_id)
  177. # ── ① 从 DB 读摘要 ────────────────────────────────────────
  178. summary = None
  179. try:
  180. async with AsyncSessionLocal() as db_session:
  181. result = await db_session.execute(
  182. sa_text(
  183. "SELECT summary FROM qa_sessions "
  184. "WHERE thread_id = :tid AND bidder_id = :sid"
  185. ),
  186. {"tid": thread_id, "sid": bidder_id},
  187. )
  188. row = result.fetchone()
  189. if row:
  190. summary = row[0]
  191. except Exception as e:
  192. logger.warning("get_history.db_error", error=str(e))
  193. # ── ② 从 MemorySaver 读消息历史 ──────────────────────────
  194. messages: list[SessionMessage] = []
  195. total_turns = 0
  196. try:
  197. graph = build_qa_graph()
  198. config = {"configurable": {"thread_id": thread_id}}
  199. state = await graph.aget_state(config)
  200. if state and state.values:
  201. for msg in state.values.get("messages", []):
  202. content = (
  203. msg.text
  204. if hasattr(msg, "text") and not callable(msg.text)
  205. else str(msg.content)
  206. )
  207. if isinstance(msg, LCHuman):
  208. messages.append(SessionMessage(role="user", content=content, created_at=""))
  209. elif isinstance(msg, LCAi):
  210. messages.append(SessionMessage(role="assistant", content=content, created_at=""))
  211. total_turns = sum(1 for m in messages if m.role == "user")
  212. except Exception as e:
  213. logger.warning("get_history.checkpoint_error", error=str(e))
  214. return HistoryResponse(
  215. session_id=session_id,
  216. messages=messages,
  217. summary=summary,
  218. total_turns=total_turns,
  219. )