graph.py 4.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. """qna 引擎 - 无状态纯函数(可单测),供 router 调用"""
  2. import json
  3. import re
  4. from typing import Optional
  5. from . import schemas, prompts # noqa: F401
  6. def _extract_keywords(history: list) -> list:
  7. texts = []
  8. for h in history:
  9. texts.append(h.get("question", {}).get("text", ""))
  10. texts.append(h.get("answer", ""))
  11. joined = " ".join(texts)
  12. stopwords = {"请问", "帮我", "怎么", "什么", "如何", "是否", "一个", "这个", "那个"}
  13. words = re.findall(r"[\u4e00-\u9fff]{2,6}", joined)
  14. seen = []
  15. for w in words:
  16. w = w.strip()
  17. if w and w not in stopwords and w not in seen:
  18. seen.append(w)
  19. return seen[:8]
  20. def _extract_json(text: str) -> dict:
  21. t = text.strip()
  22. if "```" in t:
  23. t = t.split("```json")[-1].split("```")[0].strip() if "```json" in t else t.split("```")[1].split("```")[0].strip()
  24. start, end = t.find("{"), t.rfind("}")
  25. if start == -1 or end == -1:
  26. raise ValueError("无 JSON 块")
  27. return json.loads(t[start:end + 1])
  28. def decide_next(llm, scene: dict, history: list, prompt_fn) -> dict:
  29. """根据场景+知识+历史,输出 {action, question?, reason?}。LLM 输出非法自动重试 1 次。"""
  30. max_q = int(scene.get("max_questions", 12))
  31. if len(history) >= max_q:
  32. return {"action": "finish", "reason": "已达最大题目数"}
  33. prompt = prompt_fn(scene, "", history) # 知识在 router 中检索后注入,见 retrieve_and_decide
  34. last_err = None
  35. for attempt in range(2):
  36. try:
  37. resp = llm.invoke([{"role": "human", "content": prompt}])
  38. data = _extract_json(resp.content)
  39. action = data.get("action")
  40. if action == "ask":
  41. q = data.get("question", {})
  42. qobj = schemas.Question(**q)
  43. if not qobj.is_valid():
  44. raise ValueError(f"题目结构非法: {q}")
  45. return {"action": "ask", "question": qobj.model_dump(), "reason": data.get("reason", "")}
  46. if action == "finish":
  47. return {"action": "finish", "reason": data.get("reason", "信息已足够")}
  48. raise ValueError(f"未知 action: {action}")
  49. except Exception as e:
  50. last_err = str(e)
  51. return {"action": "ask", "fallback": True, "reason": f"LLM 输出异常: {last_err}",
  52. "question": {"id": f"fb{len(history)+1}", "type": "text", "text": "请简单描述您最近一周的饮食情况。"}}
  53. def generate_profile(llm, scene: dict, history: list, kb_context: Optional[list], prompt_fn) -> tuple:
  54. kb_text = ""
  55. if kb_context:
  56. kb_text = "\n---\n".join(d.get("content", "") for d in kb_context[:5])
  57. prompt = prompt_fn(scene, kb_text, history)
  58. try:
  59. resp = llm.invoke([{"role": "human", "content": prompt}])
  60. data = _extract_json(resp.content)
  61. up = data.get("user_profile", [])
  62. np = data.get("need_profile", [])
  63. for item in up:
  64. item["score"] = max(0, min(100, int(item.get("score", 0))))
  65. return {"user_profile": up, "need_profile": np}, bool(kb_text)
  66. except Exception:
  67. return {"user_profile": [], "need_profile": []}, False
  68. def retrieve_and_decide(llm, scene, history, kb_context, prompt_fn) -> dict:
  69. """知识注入版出题:把 kb_context 转文本后调用 LLM 决策(供 router 使用)"""
  70. kb_text = ""
  71. if kb_context:
  72. kb_text = "\n---\n".join(d.get("content", "") for d in kb_context[:5])
  73. prompt = prompt_fn(scene, kb_text, history)
  74. try:
  75. resp = llm.invoke([{"role": "human", "content": prompt}])
  76. data = _extract_json(resp.content)
  77. if data.get("action") == "finish":
  78. return {"action": "finish", "reason": data.get("reason", "信息已足够")}
  79. q = schemas.Question(**data["question"])
  80. return {"action": "ask", "question": q.model_dump(), "reason": data.get("reason", "")}
  81. except Exception as e:
  82. return {"fallback": True, "reason": f"出题失败: {e}",
  83. "question": {"id": f"fb{len(history)+1}", "type": "text",
  84. "text": "请简单描述您最近一周的饮食和作息情况。"}}