llm_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. # -*- coding: utf-8 -*-
  2. """大模型服务:Qwen、DeepSeek 等轻量模型,结合知识图谱给出严谨回答"""
  3. import json
  4. import httpx
  5. import re
  6. from typing import Any, Dict, List, Optional
  7. from backend.config import get_settings
  8. def expand_with_llm(
  9. query: str,
  10. kg_context: List[Dict[str, str]],
  11. meta: Optional[Dict[str, Any]] = None,
  12. ) -> str:
  13. """
  14. 大模型扩充回答:结合知识图谱事实与大模型通识能力给出口语化、贴合教材的答案。
  15. 设计策略(更侧重“正常可用、隐藏图谱薄弱”):
  16. - 若存在知识图谱检索结果(kg_context 非空),将其视为【参考背景】而不是硬约束,
  17. 要求回答与这些事实不矛盾,但可以结合自身初中化学知识补全和拓展;
  18. - 若当前问题在知识图谱中完全检索不到相关三元组(kg_context 为空),
  19. 直接依托大模型自身学科知识作答,避免暴露“图谱太薄弱”的事实。
  20. """
  21. settings = get_settings()
  22. has_kg = bool(kg_context)
  23. context_str = (
  24. "\n".join(
  25. [
  26. f"{c.get('subject','')} {c.get('predicate','')} {c.get('obj','')}"
  27. for c in kg_context
  28. ]
  29. )
  30. if kg_context
  31. else ""
  32. )
  33. # 若配置了 API,调用 Qwen/DeepSeek(兼容 OpenAI 接口)
  34. if settings.LLM_API_KEY and settings.LLM_PROVIDER:
  35. try:
  36. print(
  37. f"[LLM] try call provider={settings.LLM_PROVIDER}, "
  38. f"base_url={settings.LLM_BASE_URL}, has_key={bool(settings.LLM_API_KEY)}, "
  39. f"has_kg={has_kg}"
  40. )
  41. return _call_llm_api(query, context_str, settings, meta=meta, has_kg=has_kg)
  42. except Exception as e:
  43. # 打印错误方便排查 DeepSeek / Qwen 配置问题,避免静默失败
  44. print(f"[LLM] call failed, fallback to KG only. error={e}")
  45. # 兜底:仅在大模型 API 不可用时触发
  46. if context_str:
  47. return _normalize_chem_text(
  48. "下面是与本题相关的一些基础知识整理:\n\n"
  49. f"{context_str}\n\n"
  50. "你可以结合教材内容和老师的讲解进一步理解这些知识点。"
  51. )
  52. return _normalize_chem_text(
  53. "目前系统暂时无法从后台获取更详细的参考资料,"
  54. "建议先按照教材对应章节的内容进行复习,并向老师或同学请教。"
  55. )
  56. def _normalize_chem_text(text: str) -> str:
  57. """
  58. 对大模型返回的文本做一次简单的“化学方程式格式清洗”,
  59. 主要目标是把常见的 LaTeX / HTML 形式的箭头和条件转换为
  60. 初中教材中常见的直观写法,避免学生看到一堆类似 \\xrightarrow{} 的“乱码”。
  61. """
  62. if not text:
  63. return text
  64. # 去掉最常见的 LaTeX 行内数学定界符和美元号
  65. text = text.replace(r"\(", "").replace(r"\)", "").replace("$", "")
  66. # 统一处理常见的“加热”/“三角形”条件写法,避免 \triangle 这类看起来像乱码的片段
  67. # 例如:2KClO₃ \triangle,MnO2→ 2KCl + 3O₂↑
  68. text = re.sub(r"\\(triangle|Triangle|Delta|delta)\b", "△", text)
  69. text = text.replace("Δ", "△")
  70. # 常见的条件箭头写法:\xrightarrow{\triangle} 或 \xrightarrow{\Delta}
  71. text = re.sub(r"\\xrightarrow\{\s*\\?(triangle|Triangle|Delta|delta)\s*\}", " △→ ", text)
  72. # 处理形如 \xrightarrow{\text{MnO}_2} 的写法
  73. # 先把 \text{MnO}_2 变成 MnO2
  74. text = re.sub(r"\\text\{([A-Za-z0-9]+)\}_([0-9]+)", r"\1\2", text)
  75. # 再把剩余的 \text{...} 去掉外壳
  76. text = re.sub(r"\\text\{([^}]*)\}", r"\1", text)
  77. # 处理上下标条件箭头:\xrightarrow[\text{MnO}_2]{\triangle}
  78. def _arrow_with_two(m: re.Match) -> str:
  79. lower = (m.group(1) or "").strip()
  80. upper = (m.group(2) or "").strip()
  81. parts = [p for p in [upper, lower] if p]
  82. cond = ",".join(parts)
  83. if cond:
  84. return f"{cond}→"
  85. return "→"
  86. text = re.sub(r"\\xrightarrow\[(.*?)\]\{(.*?)\}", _arrow_with_two, text)
  87. # 剩余的一元条件箭头:\xrightarrow{条件}
  88. def _arrow_with_one(m: re.Match) -> str:
  89. cond = (m.group(1) or "").strip()
  90. return f"{cond}→" if cond else "→"
  91. text = re.sub(r"\\xrightarrow\{([^}]*)\}", _arrow_with_one, text)
  92. # 处理其他常见 LaTeX 箭头 / 气体符号写法
  93. text = re.sub(r"\\(rightarrow|Rightarrow|to)\b", "→", text)
  94. text = re.sub(r"\\uparrow\b", "↑", text)
  95. text = re.sub(r"\\downarrow\b", "↓", text)
  96. # 一些 HTML 箭头编码统一为常见符号
  97. text = (
  98. text.replace("→", "→")
  99. .replace("←", "←")
  100. .replace("↑", "↑")
  101. .replace("↓", "↓")
  102. )
  103. text = text.replace("->", "→").replace("<-", "←")
  104. # 将下标/上标数字统一为普通数字,避免在部分设备上显示成“方块”或乱码
  105. sub_map = str.maketrans("₀₁₂₃₄₅₆₇₈₉", "0123456789")
  106. sup_map = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻", "0123456789+-")
  107. text = text.translate(sub_map).translate(sup_map)
  108. # 合并多余空格
  109. text = re.sub(r"[ \t]{2,}", " ", text)
  110. return text
  111. def _call_llm_api(
  112. query: str,
  113. context: str,
  114. settings,
  115. meta: Optional[Dict[str, Any]] = None,
  116. has_kg: bool = True,
  117. ) -> str:
  118. """
  119. 调用 Qwen/DeepSeek 等 OpenAI 兼容接口,采用两种提示词模板:
  120. - has_kg = True:有图谱命中时,将其作为“隐形参考资料”,回答时不要向学生暴露图谱,
  121. 优先保证与这些事实不矛盾,同时可以补充教材中的通识知识;
  122. - has_kg = False:当前问题图谱几乎没有命中时,直接依托大模型自身学科知识作答,
  123. 让体验更接近“正常智能问答”,避免强调后台知识图谱的局限。
  124. """
  125. # DeepSeek / OpenAI 兼容:根据 provider 选择合适路径
  126. if settings.LLM_PROVIDER == "deepseek":
  127. # 对齐官方示例:base_url + /chat/completions
  128. base = settings.LLM_BASE_URL or "https://api.deepseek.com"
  129. url = base.rstrip("/") + "/chat/completions"
  130. else:
  131. # OpenAI 或其他兼容服务:默认 /v1/chat/completions
  132. url = "https://api.openai.com/v1/chat/completions"
  133. if settings.LLM_BASE_URL:
  134. url = settings.LLM_BASE_URL.rstrip("/") + "/v1/chat/completions"
  135. q_type = (meta or {}).get("type") or "other"
  136. # 根据问题类型给出差异化引导
  137. type_hint_map = {
  138. "concept": "这是【概念理解类】问题,请突出定义、本质、条件和常见易错点。",
  139. "experiment": "这是【实验操作类】问题,请关注实验目的、装置、安全注意事项和操作原因。",
  140. "exercise": "这是【习题求解类】问题,请分步骤展示解题思路和关键知识点。",
  141. "equation": "这是【化学方程式类】问题,请在保证守恒和符合实际反应条件的前提下,给出或讲解方程式和配平思路。",
  142. "ion_coexist": "这是【离子共存判断类】问题,请结合给定的“离子共存规则”等图谱事实,分析哪些离子能否大量共存,并说明理由。",
  143. "other": "请结合上下文给出简明扼要的说明。",
  144. }
  145. type_hint = type_hint_map.get(str(q_type), type_hint_map["other"])
  146. # 根据是否命中知识图谱,选择不同的提示词风格
  147. if has_kg:
  148. system_prompt = (
  149. "你是初中化学数字人教学助手,面向中国初中生,回答要符合人教版等初中化学教材表述。\n"
  150. "后台会给你一些与本题相关的“参考知识点”(例如三元组形式的事实),"
  151. "它们可能不完整,但通常是可靠的。回答时:\n"
  152. "1. 优先保证你的回答不要与这些参考知识点矛盾;\n"
  153. "2. 可以在此基础上,结合你掌握的初中化学通识知识补充完整答案;\n"
  154. "3. 在回答开头用 1‑2 句话以“知识图谱显示:……”或“根据知识图谱:……”"
  155. " 的形式简要点出 1‑3 条关键事实,体现系统使用了知识图谱;\n"
  156. "4. 不要强调“图谱不完整/事实不足”等弱点,只需要自然地引用已经给出的事实即可;\n"
  157. "5. 不要在回答中出现“后台”“三元组”等实现细节表述;\n"
  158. "6. 不要因为参考资料里没有提到某个细节就直接说“无法回答”,"
  159. " 如果你在学科知识上是确定的,可以直接给出标准答案并适当解释;\n"
  160. "7. 若确实超出初中化学范围或你不确定,再说明“目前无法确定”即可,避免胡编;\n"
  161. "8. 在书写化学方程式时,使用标准化学符号(例如:→、↑、↓、+ 等),"
  162. " 反应条件用“△”“点燃”“MnO2”等直接写在箭头上方或括号中,"
  163. " 不要使用 LaTeX、HTML 编码或看起来像乱码的奇怪符号;\n"
  164. f"9. {type_hint}"
  165. )
  166. user_content = (
  167. "学生问题如下,请综合考虑你掌握的初中化学知识和下面给出的“参考知识点”进行回答:\n"
  168. f"{query}\n\n"
  169. "【供你内部参考的相关知识点】\n"
  170. f"{context}\n\n"
  171. "请给出逐步、通俗易懂且符合教材的回答,并在开头用“知识图谱显示:……”"
  172. "或“根据知识图谱:……”自然地引出 1‑3 条关键事实。"
  173. )
  174. else:
  175. # 图谱中没有命中时,放宽约束:允许模型结合自身学科知识主导回答
  176. system_prompt = (
  177. "你是初中化学数字人教学助手,面向中国初中生,回答要符合人教版等初中化学教材表述。\n"
  178. "要求:\n"
  179. "1. 在保证知识准确的前提下,优先用通俗的语言解释,再给出1-2句较严谨的表述;\n"
  180. "2. 对于教材中常见的概念、实验、典型化学方程式(例如制取氧气、二氧化碳等),"
  181. " 如果你非常确定,可以直接给出标准结论并适当解释;\n"
  182. "3. 如果问题明显超出初中化学范围或你不确定,请说明“超出初中化学范围”或“目前无法确定”,不要胡编;\n"
  183. "4. 在书写化学方程式时,使用标准化学符号(例如:→、↑、↓、+ 等),"
  184. " 反应条件用“△”“点燃”“MnO2”等直接写在箭头上方或括号中,"
  185. " 不要使用 LaTeX、HTML 编码或看起来像乱码的奇怪符号;\n"
  186. "5. 可以点出1-2个常见错误理解或易混淆点;\n"
  187. f"6. {type_hint}"
  188. )
  189. user_content = (
  190. "学生问题如下,请结合你掌握的初中化学知识进行详细讲解,必要时可以举简单的例子帮助理解:\n"
  191. f"{query}"
  192. )
  193. payload = {
  194. "model": "gpt-3.5-turbo" if settings.LLM_PROVIDER == "openai" else "deepseek-chat",
  195. "messages": [
  196. {
  197. "role": "system",
  198. "content": system_prompt,
  199. },
  200. {
  201. "role": "user",
  202. "content": user_content,
  203. },
  204. ],
  205. "max_tokens": 500,
  206. }
  207. print(f"[LLM] request url={url}, model={payload['model']}")
  208. with httpx.Client(timeout=30.0) as client:
  209. r = client.post(
  210. url,
  211. headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"},
  212. json=payload,
  213. )
  214. r.raise_for_status()
  215. data = r.json()
  216. raw = data["choices"][0]["message"]["content"].strip()
  217. return _normalize_chem_text(raw)