| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- # -*- 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)
|