nex_math/backend/seed/knowledge_graph.py

513 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""初等 / 高等数学标准知识图谱(v1)。
知识图谱作为全局地基:
- 每个知识点是唯一 Knowledge 节点;
- 不同教材/章节通过 ChapterKnowledge 引用节点;
- 题目通过 QuestionKnowledge 引用节点;
- 用户掌握度 UserKnowledge 绑定 knowledge_id;
- 节点间关系(包含 / 前置 / 相关)存于 knowledge_relations,
供后续图谱展示、推荐与路径规划使用。
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from models import Knowledge
from services.knowledge_service import (
ensure_knowledge_relation,
)
LEGACY_KNOWLEDGE_MAP = {
"直角三角形": "三角比",
"同角关系": "同角三角函数关系",
"弧长与扇形": "弧度制",
"和角公式": "和角与差角公式",
"差角公式": "和角与差角公式",
"周期函数": "三角函数图像",
"斜率": "直线方程",
"一次函数图像": "一次函数",
"图像平移": "函数图像与变换",
}
# 常用知识点的定义;仅在节点描述为空时写入,不覆盖管理员维护的内容。
KNOWLEDGE_DEFINITIONS = {
"函数与图像": "以图像直观呈现自变量与函数值对应关系的分支:先“看见”图像,再回到公式与性质。",
"函数概念": "设 x 取数集 D 中的值,若按确定的对应法则,每个 x 都有唯一确定的 y 与之对应,则称 y 是 x 的函数,记作 y=f(x),x∈D。",
"函数值": "当自变量取定 x=a 时,按对应法则算出的 f(a) 就是函数在 a 处的函数值,它是图像上点 (a, f(a)) 的纵坐标。",
"定义域": "使函数表达式有意义的所有自变量取值组成的集合,常见限制有分母不为零、偶次根号下非负、对数的真数为正等。",
"值域": "自变量取遍定义域时,所有函数值组成的集合,即 {f(x) | x∈D}。",
"零点": "使 f(x)=0 的自变量 x 的值,几何上对应函数图像与 x 轴交点的横坐标。",
"正负性": "函数在区间上取正值或负值的性质,由 f(x)>0 与 f(x)<0 的解集刻画,对应图像位于 x 轴上方或下方。",
"单调性": "在区间 I 上,若 x₁<x₂ 时恒有 f(x₁)<f(x₂)(或恒有 f(x₁)>f(x₂)),则称函数在 I 上单调递增(或递减)。",
"最值": "函数在给定区间上取得的最大值与最小值,可结合单调性、图像或配方等方法求得。",
"奇偶性": "若定义域关于原点对称,且对任意 x 有 f(-x)=f(x),则为偶函数;若恒有 f(-x)=-f(x),则为奇函数。",
"偶函数": "满足 f(-x)=f(x) 的函数,其图像关于 y 轴对称。",
"奇函数": "满足 f(-x)=-f(x) 的函数,其图像关于原点中心对称;若 0 在定义域内,则 f(0)=0。",
"图像对称性": "函数图像的轴对称与中心对称性质,与奇偶性、平移伸缩等变换密切相关。",
"一次函数": "形如 y=kx+b(k≠0)的函数,图像是一条直线,k 为斜率、b 为纵截距;k>0 时单调递增。",
"二次函数": "形如 y=ax²+bx+c(a≠0)的函数,图像是抛物线,可通过配方求顶点、对称轴与最值。",
"抛物线": "二次函数图像的几何形状,具有对称轴与顶点,开口方向由二次项系数的正负决定。",
"幂函数": "形如 y=x^α 的函数,其定义域、图像与单调性随指数 α 的不同而明显变化。",
"函数图像与变换": "由基本函数的图像出发,经平移(左加右减、上加下减)、伸缩与对称得到新图像的方法。",
"直线方程": "用方程表示直线的形式,如斜截式 y=kx+b、点斜式 y-y₀=k(x-x₀);两直线平行则斜率相等。",
"三角函数": "以角为自变量、以三角比为函数值的函数,包括正弦、余弦、正切等,具有周期性与有界性。",
"任意角": "由射线绕顶点旋转生成的角,按旋转方向分为正角、负角与零角,并用终边位置统一刻画。",
"弧度制": "用弧长与半径之比度量角的大小,π 弧度 = 180°,使弧长与扇形面积公式的形式更简洁。",
"三角比": "直角三角形中边与边的比值(正弦、余弦、正切),并可借助单位圆推广到任意角。",
"同角三角函数关系": "同一个角的正弦、余弦、正切之间的基本关系,如 sin²α+cos²α=1、tanα=sinα/cosα。",
"和角与差角公式": "描述两角和或差的三角函数公式,如 sin(α±β)=sinαcosβ±cosαsinβ,是推导倍角公式的基础。",
"三角恒等式": "对定义域内任意角都成立的三角关系式,包括平方关系、和差角、倍角与半角公式等。",
"三角函数图像": "正弦、余弦、正切函数的图像及其周期、振幅、相位与图像变换规律。",
"反三角函数": "三角函数在限定单调区间上的反函数,如反正弦 arcsin、反余弦 arccos、反正切 arctan。",
"三角方程": "含有未知角的三角函数的方程,通常先化为基本三角方程,再结合周期性写出通解。",
"正弦定理": "在 △ABC 中 a/sinA = b/sinB = c/sinC = 2R(R 为外接圆半径),用于已知边角组合解三角形。",
"余弦定理": "在 △ABC 中 a²=b²+c²-2bc·cosA,用于已知两边及夹角或三边求解三角形。",
"解三角形": "利用正弦定理、余弦定理与内角和关系,由已知元素求三角形其余边角的过程。",
}
GRAPH = [
{
"name": "初等数学",
"domain": "初等",
"description": "中学与竞赛基础的数学知识总图",
"children": [
{
"name": "数与式",
"children": [
"自然数与整数",
"有理数",
"实数",
"数的整除",
"代数式",
"因式分解",
"根式与无理式",
"幂与指数",
],
},
{
"name": "方程与不等式",
"children": [
"一元一次方程",
"一元二次方程",
"二元一次方程组",
"分式方程",
"一元一次不等式",
"一元二次不等式",
"不等式的性质与证明",
],
},
{
"name": "函数与图像",
"children": [
"函数概念",
"函数值",
"定义域",
"值域",
"零点",
"正负性",
"单调性",
"最值",
"奇偶性",
"偶函数",
"奇函数",
"图像对称性",
"一次函数",
"二次函数",
"幂函数",
"指数函数",
"对数函数",
"函数图像与变换",
],
},
{
"name": "三角函数",
"children": [
"任意角",
"弧度制",
"三角比",
"同角三角函数关系",
"诱导公式",
"和角与差角公式",
"倍角公式",
"三角恒等式",
"三角函数图像",
"反三角函数",
"三角方程",
"正弦定理",
"余弦定理",
"解三角形",
],
},
{
"name": "平面几何",
"children": [
"点线面的基本关系",
"平行与垂直",
"三角形",
"全等",
"相似",
"四边形",
"圆",
"面积与周长",
"几何变换",
],
},
{
"name": "坐标与解析几何",
"children": [
"数轴",
"平面直角坐标系",
"两点间距离",
"直线方程",
"圆的标准方程",
"椭圆",
"双曲线",
"抛物线",
"曲线与方程",
],
},
{
"name": "数列与证明",
"children": [
"等差数列",
"等比数列",
"数列通项与求和",
"递推数列",
"数学归纳法",
"逻辑与证明方法",
],
},
{
"name": "概率统计",
"children": [
"计数原理",
"排列组合",
"古典概型",
"条件概率",
"随机变量",
"统计图表",
"均值与方差",
],
},
],
},
{
"name": "高等数学",
"domain": "高等",
"description": "大学基础数学知识总图",
"children": [
{
"name": "分析与微积分",
"children": [
"集合与映射",
"极限与连续",
"导数与微分",
"微分中值定理",
"一元函数积分",
"无穷级数",
"多元函数微分",
"重积分与曲线曲面积分",
"微分方程",
],
},
{
"name": "线性代数",
"children": [
"行列式",
"矩阵",
"线性方程组",
"向量空间",
"特征值与特征向量",
"二次型",
],
},
{
"name": "概率论与数理统计",
"children": [
"概率空间",
"随机变量及其分布",
"数字特征",
"大数定律与中心极限定理",
"参数估计",
"假设检验",
"回归分析",
],
},
{
"name": "离散与优化",
"children": [
"图论基础",
"组合优化",
"最优化方法",
],
},
],
},
]
PREREQUISITES: list[tuple[str, str]] = [
("自然数与整数", "有理数"),
("有理数", "实数"),
("自然数与整数", "数的整除"),
("实数", "代数式"),
("代数式", "因式分解"),
("一元一次方程", "一元二次方程"),
("一元二次方程", "二次函数"),
("幂与指数", "指数函数"),
("幂与指数", "幂函数"),
("函数概念", "定义域"),
("函数概念", "函数值"),
("函数概念", "偶函数"),
("函数概念", "奇函数"),
("偶函数", "奇函数"),
("定义域", "零点"),
("一次函数", "二次函数"),
("二次函数", "函数图像与变换"),
("三角比", "任意角"),
("任意角", "弧度制"),
("弧度制", "三角函数图像"),
("和角与差角公式", "倍角公式"),
("倍角公式", "三角恒等式"),
("函数与图像", "三角函数"),
("平面直角坐标系", "直线方程"),
("直线方程", "圆的标准方程"),
("圆的标准方程", "抛物线"),
("极限与连续", "导数与微分"),
("导数与微分", "微分中值定理"),
("导数与微分", "一元函数积分"),
("一元函数积分", "多元函数微分"),
("矩阵", "行列式"),
("矩阵", "线性方程组"),
("线性方程组", "特征值与特征向量"),
("随机变量及其分布", "数字特征"),
("概率空间", "随机变量及其分布"),
]
def _create(
db: Session,
name: str,
domain: str,
category: str = "",
description: str = "",
) -> Knowledge:
row = db.query(Knowledge).filter(Knowledge.name == name).first()
if row is None:
row = Knowledge(
name=name,
domain=domain,
category=category,
description=description,
)
db.add(row)
db.flush()
else:
if row.domain != domain:
row.domain = domain
if category and not row.category:
row.category = category
if description and not row.description:
row.description = description
return row
def _insert_tree(db: Session, node: dict, parent: Knowledge | None = None) -> None:
if isinstance(node, str):
current = _create(
db,
str(node),
parent.domain if parent else "初等",
parent.category if parent else "",
)
if parent is not None:
ensure_knowledge_relation(db, parent, current, "包含")
return
name = node["name"]
if parent is None:
category = "总纲"
elif parent.name in {"初等数学", "高等数学"}:
category = name
else:
category = node.get("category") or (parent.category or "")
current = _create(
db,
name,
node.get("domain") or (parent.domain if parent else "初等"),
category,
node.get("description", ""),
)
if parent is not None:
ensure_knowledge_relation(db, parent, current, "包含")
for child in node.get("children", []):
_insert_tree(db, child, current)
def ensure_knowledge_graph(db: Session) -> None:
if db.query(Knowledge).count() == 0:
for node in GRAPH:
_insert_tree(db, node)
else:
_repair_existing_categories(db)
_cleanup_legacy_knowledge(db)
for name, text in KNOWLEDGE_DEFINITIONS.items():
row = db.query(Knowledge).filter(Knowledge.name == name).first()
if row is not None and not (row.description or "").strip():
row.description = text
for source_name, target_name in PREREQUISITES:
source = db.query(Knowledge).filter(Knowledge.name == source_name).first()
target = db.query(Knowledge).filter(Knowledge.name == target_name).first()
if source is None or target is None:
continue
ensure_knowledge_relation(db, source, target, "前置")
db.commit()
def _cleanup_legacy_knowledge(db: Session) -> None:
"""把早期自由生成的节点合并到标准图谱,并重挂章节/题目/用户掌握度。"""
from models import (
ChapterKnowledge,
KnowledgeRelation,
QuestionKnowledge,
UserKnowledge,
)
for legacy_name, canonical_name in LEGACY_KNOWLEDGE_MAP.items():
legacy = db.query(Knowledge).filter(Knowledge.name == legacy_name).first()
if legacy is None:
continue
canonical = db.query(Knowledge).filter(Knowledge.name == canonical_name).first()
if canonical is None:
canonical = Knowledge(
name=canonical_name,
domain=legacy.domain,
category=legacy.category or "三角函数",
)
db.add(canonical)
db.flush()
old_chapter_links = (
db.query(ChapterKnowledge)
.filter(ChapterKnowledge.knowledge_id == legacy.id)
.all()
)
old_question_links = (
db.query(QuestionKnowledge)
.filter(QuestionKnowledge.knowledge_id == legacy.id)
.all()
)
old_user_links = (
db.query(UserKnowledge)
.filter(UserKnowledge.knowledge_id == legacy.id)
.all()
)
db.query(ChapterKnowledge).filter(
ChapterKnowledge.knowledge_id == legacy.id
).delete(synchronize_session=False)
db.query(QuestionKnowledge).filter(
QuestionKnowledge.knowledge_id == legacy.id
).delete(synchronize_session=False)
db.query(UserKnowledge).filter(
UserKnowledge.knowledge_id == legacy.id
).delete(synchronize_session=False)
for link in old_chapter_links:
exists = (
db.query(ChapterKnowledge)
.filter(
ChapterKnowledge.chapter_id == link.chapter_id,
ChapterKnowledge.knowledge_id == canonical.id,
)
.first()
)
if exists is None:
db.add(
ChapterKnowledge(
chapter_id=link.chapter_id,
knowledge_id=canonical.id,
)
)
for link in old_question_links:
exists = (
db.query(QuestionKnowledge)
.filter(
QuestionKnowledge.question_id == link.question_id,
QuestionKnowledge.knowledge_id == canonical.id,
)
.first()
)
if exists is None:
db.add(
QuestionKnowledge(
question_id=link.question_id,
knowledge_id=canonical.id,
)
)
for link in old_user_links:
exists = (
db.query(UserKnowledge)
.filter(
UserKnowledge.user_id == link.user_id,
UserKnowledge.knowledge_id == canonical.id,
)
.first()
)
if exists is None:
db.add(
UserKnowledge(
user_id=link.user_id,
knowledge_id=canonical.id,
mastery=link.mastery,
position=link.position,
)
)
db.query(KnowledgeRelation).filter(
(KnowledgeRelation.source_id == legacy.id)
| (KnowledgeRelation.target_id == legacy.id)
).delete(synchronize_session=False)
db.delete(legacy)
db.flush()
def _repair_existing_categories(db: Session) -> None:
"""存量库按种子树回填 domain/category(不删除已有节点)。"""
def visit(node: dict, parent: Knowledge | None = None) -> None:
if isinstance(node, str):
row = db.query(Knowledge).filter(Knowledge.name == node).first()
if row is not None:
row.domain = parent.domain if parent else row.domain
if parent and parent.category:
row.category = parent.category
return
domain = node.get("domain") or (parent.domain if parent else "初等")
if parent is None:
category = "总纲"
elif parent.name in {"初等数学", "高等数学"}:
category = node["name"]
else:
category = parent.category or ""
row = db.query(Knowledge).filter(Knowledge.name == node["name"]).first()
if row is None:
row = Knowledge(
name=node["name"],
domain=domain,
category=category,
description=node.get("description", ""),
)
db.add(row)
db.flush()
else:
row.domain = domain
row.category = category
if node.get("description") and not row.description:
row.description = node["description"]
for child in node.get("children", []):
visit(child, row)
for top in GRAPH:
visit(top)
db.commit()