# -*- coding: utf-8 -*- """大模型服务:Qwen、DeepSeek 等轻量模型,结合知识图谱给出严谨回答""" import json import httpx import re from typing import Any, Dict, List, Optional from backend.config import get_settings def expand_with_llm( query: str, kg_context: List[Dict[str, str]], meta: Optional[Dict[str, Any]] = None, ) -> str: """ 大模型扩充回答:结合知识图谱事实与大模型通识能力给出口语化、贴合教材的答案。 设计策略(更侧重“正常可用、隐藏图谱薄弱”): - 若存在知识图谱检索结果(kg_context 非空),将其视为【参考背景】而不是硬约束, 要求回答与这些事实不矛盾,但可以结合自身初中化学知识补全和拓展; - 若当前问题在知识图谱中完全检索不到相关三元组(kg_context 为空), 直接依托大模型自身学科知识作答,避免暴露“图谱太薄弱”的事实。 """ settings = get_settings() has_kg = bool(kg_context) context_str = ( "\n".join( [ f"{c.get('subject','')} {c.get('predicate','')} {c.get('obj','')}" for c in kg_context ] ) if kg_context else "" ) # 若配置了 API,调用 Qwen/DeepSeek(兼容 OpenAI 接口) if settings.LLM_API_KEY and settings.LLM_PROVIDER: try: print( f"[LLM] try call provider={settings.LLM_PROVIDER}, " f"base_url={settings.LLM_BASE_URL}, has_key={bool(settings.LLM_API_KEY)}, " f"has_kg={has_kg}" ) return _call_llm_api(query, context_str, settings, meta=meta, has_kg=has_kg) except Exception as e: # 打印错误方便排查 DeepSeek / Qwen 配置问题,避免静默失败 print(f"[LLM] call failed, fallback to KG only. error={e}") # 兜底:仅在大模型 API 不可用时触发 if context_str: return _normalize_chem_text( "下面是与本题相关的一些基础知识整理:\n\n" f"{context_str}\n\n" "你可以结合教材内容和老师的讲解进一步理解这些知识点。" ) return _normalize_chem_text( "目前系统暂时无法从后台获取更详细的参考资料," "建议先按照教材对应章节的内容进行复习,并向老师或同学请教。" ) def _normalize_chem_text(text: str) -> str: """ 对大模型返回的文本做一次简单的“化学方程式格式清洗”, 主要目标是把常见的 LaTeX / HTML 形式的箭头和条件转换为 初中教材中常见的直观写法,避免学生看到一堆类似 \\xrightarrow{} 的“乱码”。 """ if not text: return text # 去掉最常见的 LaTeX 行内数学定界符和美元号 text = text.replace(r"\(", "").replace(r"\)", "").replace("$", "") # 统一处理常见的“加热”/“三角形”条件写法,避免 \triangle 这类看起来像乱码的片段 # 例如:2KClO₃ \triangle,MnO2→ 2KCl + 3O₂↑ text = re.sub(r"\\(triangle|Triangle|Delta|delta)\b", "△", text) text = text.replace("Δ", "△") # 常见的条件箭头写法:\xrightarrow{\triangle} 或 \xrightarrow{\Delta} text = re.sub(r"\\xrightarrow\{\s*\\?(triangle|Triangle|Delta|delta)\s*\}", " △→ ", text) # 处理形如 \xrightarrow{\text{MnO}_2} 的写法 # 先把 \text{MnO}_2 变成 MnO2 text = re.sub(r"\\text\{([A-Za-z0-9]+)\}_([0-9]+)", r"\1\2", text) # 再把剩余的 \text{...} 去掉外壳 text = re.sub(r"\\text\{([^}]*)\}", r"\1", text) # 处理上下标条件箭头:\xrightarrow[\text{MnO}_2]{\triangle} def _arrow_with_two(m: re.Match) -> str: lower = (m.group(1) or "").strip() upper = (m.group(2) or "").strip() parts = [p for p in [upper, lower] if p] cond = ",".join(parts) if cond: return f"{cond}→" return "→" text = re.sub(r"\\xrightarrow\[(.*?)\]\{(.*?)\}", _arrow_with_two, text) # 剩余的一元条件箭头:\xrightarrow{条件} def _arrow_with_one(m: re.Match) -> str: cond = (m.group(1) or "").strip() return f"{cond}→" if cond else "→" text = re.sub(r"\\xrightarrow\{([^}]*)\}", _arrow_with_one, text) # 处理其他常见 LaTeX 箭头 / 气体符号写法 text = re.sub(r"\\(rightarrow|Rightarrow|to)\b", "→", text) text = re.sub(r"\\uparrow\b", "↑", text) text = re.sub(r"\\downarrow\b", "↓", text) # 一些 HTML 箭头编码统一为常见符号 text = ( text.replace("→", "→") .replace("←", "←") .replace("↑", "↑") .replace("↓", "↓") ) text = text.replace("->", "→").replace("<-", "←") # 将下标/上标数字统一为普通数字,避免在部分设备上显示成“方块”或乱码 sub_map = str.maketrans("₀₁₂₃₄₅₆₇₈₉", "0123456789") sup_map = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻", "0123456789+-") text = text.translate(sub_map).translate(sup_map) # 合并多余空格 text = re.sub(r"[ \t]{2,}", " ", text) return text def _call_llm_api( query: str, context: str, settings, meta: Optional[Dict[str, Any]] = None, has_kg: bool = True, ) -> str: """ 调用 Qwen/DeepSeek 等 OpenAI 兼容接口,采用两种提示词模板: - has_kg = True:有图谱命中时,将其作为“隐形参考资料”,回答时不要向学生暴露图谱, 优先保证与这些事实不矛盾,同时可以补充教材中的通识知识; - has_kg = False:当前问题图谱几乎没有命中时,直接依托大模型自身学科知识作答, 让体验更接近“正常智能问答”,避免强调后台知识图谱的局限。 """ # DeepSeek / OpenAI 兼容:根据 provider 选择合适路径 if settings.LLM_PROVIDER == "deepseek": # 对齐官方示例:base_url + /chat/completions base = settings.LLM_BASE_URL or "https://api.deepseek.com" url = base.rstrip("/") + "/chat/completions" else: # OpenAI 或其他兼容服务:默认 /v1/chat/completions url = "https://api.openai.com/v1/chat/completions" if settings.LLM_BASE_URL: url = settings.LLM_BASE_URL.rstrip("/") + "/v1/chat/completions" q_type = (meta or {}).get("type") or "other" # 根据问题类型给出差异化引导 type_hint_map = { "concept": "这是【概念理解类】问题,请突出定义、本质、条件和常见易错点。", "experiment": "这是【实验操作类】问题,请关注实验目的、装置、安全注意事项和操作原因。", "exercise": "这是【习题求解类】问题,请分步骤展示解题思路和关键知识点。", "equation": "这是【化学方程式类】问题,请在保证守恒和符合实际反应条件的前提下,给出或讲解方程式和配平思路。", "ion_coexist": "这是【离子共存判断类】问题,请结合给定的“离子共存规则”等图谱事实,分析哪些离子能否大量共存,并说明理由。", "other": "请结合上下文给出简明扼要的说明。", } type_hint = type_hint_map.get(str(q_type), type_hint_map["other"]) # 根据是否命中知识图谱,选择不同的提示词风格 if has_kg: system_prompt = ( "你是初中化学数字人教学助手,面向中国初中生,回答要符合人教版等初中化学教材表述。\n" "后台会给你一些与本题相关的“参考知识点”(例如三元组形式的事实)," "它们可能不完整,但通常是可靠的。回答时:\n" "1. 优先保证你的回答不要与这些参考知识点矛盾;\n" "2. 可以在此基础上,结合你掌握的初中化学通识知识补充完整答案;\n" "3. 在回答开头用 1‑2 句话以“知识图谱显示:……”或“根据知识图谱:……”" " 的形式简要点出 1‑3 条关键事实,体现系统使用了知识图谱;\n" "4. 不要强调“图谱不完整/事实不足”等弱点,只需要自然地引用已经给出的事实即可;\n" "5. 不要在回答中出现“后台”“三元组”等实现细节表述;\n" "6. 不要因为参考资料里没有提到某个细节就直接说“无法回答”," " 如果你在学科知识上是确定的,可以直接给出标准答案并适当解释;\n" "7. 若确实超出初中化学范围或你不确定,再说明“目前无法确定”即可,避免胡编;\n" "8. 在书写化学方程式时,使用标准化学符号(例如:→、↑、↓、+ 等)," " 反应条件用“△”“点燃”“MnO2”等直接写在箭头上方或括号中," " 不要使用 LaTeX、HTML 编码或看起来像乱码的奇怪符号;\n" f"9. {type_hint}" ) user_content = ( "学生问题如下,请综合考虑你掌握的初中化学知识和下面给出的“参考知识点”进行回答:\n" f"{query}\n\n" "【供你内部参考的相关知识点】\n" f"{context}\n\n" "请给出逐步、通俗易懂且符合教材的回答,并在开头用“知识图谱显示:……”" "或“根据知识图谱:……”自然地引出 1‑3 条关键事实。" ) else: # 图谱中没有命中时,放宽约束:允许模型结合自身学科知识主导回答 system_prompt = ( "你是初中化学数字人教学助手,面向中国初中生,回答要符合人教版等初中化学教材表述。\n" "要求:\n" "1. 在保证知识准确的前提下,优先用通俗的语言解释,再给出1-2句较严谨的表述;\n" "2. 对于教材中常见的概念、实验、典型化学方程式(例如制取氧气、二氧化碳等)," " 如果你非常确定,可以直接给出标准结论并适当解释;\n" "3. 如果问题明显超出初中化学范围或你不确定,请说明“超出初中化学范围”或“目前无法确定”,不要胡编;\n" "4. 在书写化学方程式时,使用标准化学符号(例如:→、↑、↓、+ 等)," " 反应条件用“△”“点燃”“MnO2”等直接写在箭头上方或括号中," " 不要使用 LaTeX、HTML 编码或看起来像乱码的奇怪符号;\n" "5. 可以点出1-2个常见错误理解或易混淆点;\n" f"6. {type_hint}" ) user_content = ( "学生问题如下,请结合你掌握的初中化学知识进行详细讲解,必要时可以举简单的例子帮助理解:\n" f"{query}" ) payload = { "model": "gpt-3.5-turbo" if settings.LLM_PROVIDER == "openai" else "deepseek-chat", "messages": [ { "role": "system", "content": system_prompt, }, { "role": "user", "content": user_content, }, ], "max_tokens": 500, } print(f"[LLM] request url={url}, model={payload['model']}") with httpx.Client(timeout=30.0) as client: r = client.post( url, headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"}, json=payload, ) r.raise_for_status() data = r.json() raw = data["choices"][0]["message"]["content"].strip() return _normalize_chem_text(raw)