main
mula.liu 2026-09-18 13:28:05 +08:00
commit 92bdb20741
143 changed files with 24010 additions and 0 deletions

12
.env.example 100644
View File

@ -0,0 +1,12 @@
# 数据库连接。留空时后端自动使用仓库根目录下的 data/math.db
# 例:sqlite:////Users/you/nex_math/data/math.db
DATABASE_URL=
# 允许跨域访问的前端地址,逗号分隔
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# 可选:为将来接入真实大模型讲解预留(当前本地规则版无需配置)
OPENAI_API_KEY=
# JWT 签名密钥(生产环境务必改成随机长字符串)
JWT_SECRET=

9
.gitignore vendored 100644
View File

@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
backend/.venv/
node_modules/
frontend/dist/
.DS_Store
data/*.db
.env

247
README.md 100644
View File

@ -0,0 +1,247 @@
# 数学学习系统(前后端分离版)
把原单文件静态页 `math_learning_dashboard_v3.html` 重构为可运行的前后端分离系统,
前端采用 Ant Design,包含用户体系与标准 RBAC。
页面结构、题库与学习流程在此基础上重设计:知识点以“初等/高等数学知识图谱”
为全局地基,章节与题目通过关系表挂到图谱节点,不再使用文本标签匹配。
学习状态从浏览器 localStorage 改为 SQLite 持久化;教材目录以
《盖尔范德中学生数学思维丛书》为基础,并支持大模型配置与按章节生成题目。
## 技术栈
- 后端:FastAPI + SQLAlchemy + SQLite
- 前端:React + TypeScript + Vite + Ant Design
- 公式渲染:KaTeX(支持 `$...$` / `$$...$$` 与 Markdown 插图)
- 认证:JWT(HS256)+ PBKDF2 密码散列
- RBAC:user / role / user_role / permission / role_permission
- 部署:支持本机直接运行或 `docker compose`
## 目录结构
```text
nex_math/
├── backend/ # FastAPI 应用
│ ├── main.py # 入口(CORS、路由注册)
│ ├── database.py # SQLite 连接与默认库路径
│ ├── models/ # ORM:教材 / 章节 / 知识点 / 题目 / 记录 / 错题 / 模型配置
│ ├── schemas/ # Pydantic 请求响应模型
│ ├── routers/ # overview / practice / data / analytics / archive / catalog / question-bank / llm
│ ├── services/ # llm_client / question_generator / ai_teacher / question_engine / mastery_engine / recommendation
│ └── seed/ # 目录与题库种子数据
├── frontend/ # Vite + React 前端
│ └── src/
│ ├── pages/ # 九个页面
│ ├── components/
│ ├── api/ # API 客户端
│ └── types/
├── data/math.db # SQLite 数据库(首次初始化后生成)
└── scripts/ # init.sh / start.sh
```
## 界面与导航
- 左侧菜单栏支持**展开 / 收拢**两种模式:底部“收拢菜单”按钮或顶栏折叠按钮
一键切换,偏好写入 localStorage 下次进入自动恢复;收拢态只显示图标,
悬停出现文字提示,功能分组以分隔线保留。
- 窄屏(<768px)自动切换为覆盖式抽屉导航,顶栏汉堡按钮开合,不再挤压内容区。
- 顶栏统一展示“分区 · 页面说明”、角色标识与头像菜单(设置 / 退出登录),
与侧栏底部用户卡片入口一致。
- 全站样式与控件收敛到三层:**theme.ts(设计令牌)→ ui.tsx(Page / Panel /
Toolbar / DataTable / RowActions / MetricGrid / CountTag / EmptyState 等)
→ 页面**,新页面只组合统一控件,不各写一套尺寸与颜色。
## 快速开始(本机)
要求:Python 3.10+(推荐使用 [uv](https://docs.astral.sh/uv/))、Node.js 18+。
```bash
./scripts/init.sh # 创建虚拟环境、安装依赖、写入种子数据
./scripts/start.sh # 同时启动后端 :8000 与前端 :5173
```
打开 http://localhost:5173。
默认账号:
| 账号 | 密码 | 角色 | 说明 |
| --- | --- | --- | --- |
| admin | admin123 | 管理员 | 用户、模型、教材课程、题库管理 |
| student | student123 | 普通用户 | 学习总览、练习、错题、分析等 |
首次登录后请通过左侧栏底部用户卡片的“个人设置”修改默认密码,
并在生产环境设置 JWT_SECRET(环境变量)。管理员可在“模型配置”维护多通道模型。
管理员是纯管理账号:不创建学习档案,学习总览/知识地图等学习功能不展示,
学习类接口对管理员返回无权限。
## 快速开始(Docker)
```bash
docker compose up --build
```
前端 http://localhost:5173,后端文档 http://localhost:8000/docs。
数据库会以卷形式挂载在宿主机的 `./data/math.db`。
## 热加载
- 前端:Vite 开发服务器自带 HMR,保存代码页面即时更新;
- 后端:`./scripts/start.sh` 与 `docker compose` 均以
`uvicorn --reload` 启动,Python 代码变更自动重载。
## 功能对照
| 静态版模块 | 新系统位置 | 说明 |
| --- | --- | --- |
| 学习总览 | `/` 学习总览 | 指标卡 + 掌握度 + 推荐计划 |
| 知识地图 | 知识地图 | 知识点掌握度;点击卡片查看定义、掌握度、相关知识点,以及讲了它的教材章节与视频课程(可一键进入本章测试) |
| 章节测试 | 章节测试 | 选书→选章→按掌握度动态抽取 5 题,可“换一套”;唯一的组卷与练习入口 |
| 错题本 | 错题本 | 记录每道错题的题干、你的错误回答、正确答案与解析 |
| 练习记录 | 练习记录 | 展开记录可回看每题作答与详细解析 |
| 今日任务 | 今日任务 | 推荐今日章节 → 做章节测试 → 对章节提交掌握反馈 |
| 学习分析 | 学习分析 | 正确率、错题、诊断排序 |
| 导出 / 导入档案 | 个人设置 → 学习档案 | `/api/archive/export`、`/api/archive/import` |
| 教材 / 课程 | 教材 / 课程 | 盖尔范德丛书书目(ISBN)+ 在线课程链接 |
| 教材详情 | 点击教材卡片 | 查看章节目录、每章知识点、题目数量并直接开始本章测试 |
| 题库管理 | 题库管理 | 列出/筛选/删除题目,按教材章节调用模型生成 |
| 模型配置 | 模型配置 | 多通道模型配置、通道命名、默认通道与连通测试 |
| 用户管理 | 用户管理(管理员) | 创建用户、启停账号、切换角色、重置密码 |
| 个人设置 | 左侧栏底部用户卡片 → 个人设置(主内容区整页) | 修改昵称/密码,导出与导入学习档案 |
| 教材课程管理 | 教材课程管理(管理员) | 教材/课程维护;章节可新增/编辑/删除,也可按教材 AI 自动生成目录,章节可直接跳转到对应题库 |
| 题库生成 | 题库管理 | 先选教材,再选章节与数量,按章节调用大模型生成新题 |
学习数据(掌握度、练习记录、错题、每日完成)按用户隔离,同一账号的数据互相独立。
练习提交时会保存每题题目快照与作答(错误选项、正确答案、解析),因此题库后续
删题也不影响错题本与练习记录的逐题回看;所有明细均归属当前用户,用户之间不可见。
## RBAC 权限
| 权限码 | 说明 | 管理员 | 普通用户 |
| --- | --- | --- | --- |
| learning:use | 学习总览/练习/错题/分析/档案 | ✘ | ✔ |
| users:manage | 用户管理 | ✔ | ✘ |
| llm:manage | 模型配置 | ✔ | ✘ |
| catalog:manage | 教材/课程管理 | ✔ | ✘ |
| question-bank:manage | 题库管理与生成 | ✔ | ✘ |
| knowledge:manage | 知识图谱管理 | ✔ | ✘ |
除登录、健康检查外,所有 `/api/*` 接口都要求 `Authorization: Bearer <token>`;
管理接口在后端通过权限依赖二次校验(`backend/dependencies.py`)。
知识点状态由掌握度自动计算:`≥85 已掌握`、`75–84 基本掌握`、`60–74 待加强`、`<60 重点`。
## 教材目录
当前教材基线为《盖尔范德中学生数学思维丛书》(中国科学技术大学出版社):
| 分册 | ISBN |
| --- | --- |
| 函数和图像(主教材,已挂章节与题库) | 978-7-312-05005-3 |
| 代数 | 978-7-312-04893-7 |
| 三角函数 | 978-7-312-04695-7 |
| 坐标方法 | 978-7-312-05006-0 |
| 几何 | 978-7-312-05779-3 |
《函数和图像》的章节(引言、第1章 例子 … 第7章 有理函数)已写入数据库,
可据此按章节生成新题;其余分册目录可在 `backend/seed/catalog.py` 中扩充后重启生效
(幂等同步,不清空学习记录)。
## 模型配置与题目生成
1. 打开“模型配置”→“新增通道”,选择 OpenAI / DeepSeek / 阿里千问(百炼)/
OpenAI 兼容;通道名称建议按“服务商 · 模型”填写(例如“阿里千问 · qwen-plus”),
系统会随服务商选择自动带出默认 Base URL 与模型名。
2. 填写 API Key 并点击该通道的“测试”;Key 仅保存在本地 `data/math.db`,
回显只显示末四位。可新增多个通道并用“设为默认”指定生成题目时使用的通道。
3. 打开“题库管理”,选择教材、章节、数量、难度、该章节知识点(可多选)与
生成使用的模型通道(默认取“默认通道”),
点击“开始生成”。返回结果会做 JSON 结构校验(4 个选项、唯一答案、非空题干)后
入库并挂到所选教材章节下,可在该章“章节测试”中直接使用;题库页支持筛选与删除。
大模型调用统一走 OpenAI Chat Completions 协议(`services/llm_client.py`),
不需要额外 SDK;生成的题若带新知识点,首次作答会自动建立该知识点并参与掌握度计算。
生成提示词要求模型把数学公式写成 LaTeX(`$...$` 或 `$$...$$`)、插图写成
`![图注](图片地址)`;题库、练习、错题本与练习记录中的题目内容均由前端 KaTeX
渲染公式并展示图片。
### 教材章节的 AI 生成与题库跳转
在“教材课程管理”中打开某本教材的“章节”抽屉后:
1. “添加章节”仍可手动输入名称与内容提示;
2. “AI 生成章节”按钮位于“添加章节”之后;无需选择数量,选择模型通道与可选说明后,
系统会先查找内置官方目录(函数与图像、三角函数、坐标方法、几何等正式章节),
再按 ISBN 查询 Open Library / Google Books;查到正式目录时直接按正式章节补齐,
不会让模型自由发挥;如果无法核实正式目录,接口会明确拒绝生成并提示手动添加,
避免产生与原书无关的章节。新章节入库后可继续编辑或删除;
3. 每个章节行都有“题库”入口,点击后跳转到题库管理并自动筛选该书与章节。
题库管理每道题会标注“使用状态”(已使用次数 / 未使用)。使用过的题目和被
章节挂载的题目不允许删除,只能编辑;题目编辑会保留历史答题快照。
### AI 出题配图机制(自动生成,不使用图库/上传)
生成新题弹窗中有一个“需要配图(AI 根据题目自动生成图像)”开关:
- 不勾选:题目正常生成,不携带图像;
- 勾选后:提示词要求模型为这道题生成与题目语义一致的原创 SVG(函数图/几何示意图,
含坐标轴与关键标注),模型把它放在 JSON 的 `image_svg` 字段;
- 后端把每张 SVG 独立保存为 `/api/question-images/generated_*.svg` 并自动写入题干,
因此每道题都有自己独立的图,互不共享。
### 知识点与章节、多知识点
- 每个章节可维护多个“本章知识点”(教材课程管理 → 章节 → 编辑);
- 生成新题先选教材和章节后,知识点下拉自动展示该章节的知识点,可多选;
- 题目支持多个知识点:列表、练习记录、错题、掌握度与推荐都会按全部知识点统计;
- 练习提交时如果出现未见过的新知识点,会自动为用户建立并参与掌握度计算。
## 主要 API
所有接口前缀 `/api`,交互文档见 http://localhost:8000/docs。
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/overview` | 学习总览指标与推荐计划 |
| GET | `/knowledge` | 知识点掌握度 |
| GET | `/knowledge/detail/{id}` | 知识点详情:定义、相关知识点、关联章节与视频资源 |
| POST/DELETE | `/knowledge/nodes/{id}/resources`、`/knowledge/resources/{id}` | 知识点关联视频/课程(管理员) |
| GET | `/practice/chapter/{id}` | 章节测试组卷(不含答案) |
| POST | `/practice/submit` | 提交答案并判分、更新掌握度与错题 |
| GET/POST | `/daily`、`/daily/complete` | 今日完成状态 |
| GET | `/errors`、`/records`、`/records/{id}`、`/analytics` | 错题(含逐题详情)、记录、记录详情、分析 |
| GET/POST | `/archive/export`、`/archive/import` | 学习档案导出 / 恢复 |
| GET | `/textbooks` | 教材/课程目录(含章节,登录可用) |
| POST/PUT/DELETE | `/textbooks` 与 `/textbooks/{id}` | 教材/课程增删改(管理员) |
| POST/PUT/DELETE | `/textbooks/.../chapters` | 章节管理(管理员) |
| POST | `/textbooks/{id}/chapters/generate` | 按教材 AI 生成章节(管理员) |
| GET/POST/PUT/DELETE | `/question-bank/questions`、`…/generate` | 题库管理:列表/生成/编辑/删除(管理员),已使用题禁止删除 |
| GET | `/llm/settings` | 模型通道列表与服务商预设(管理员) |
| POST/PUT/DELETE | `/llm/channels` 与 `/llm/channels/{id}` | 模型通道增删改(管理员) |
| PUT | `/llm/channels/{id}/default` | 设置默认通道(管理员) |
| POST | `/llm/channels/{id}/test`、`/llm/test` | 通道连通测试(管理员) |
| PUT | `/auth/profile` | 修改自己的用户昵称 |
| POST | `/auth/login`、`/auth/change-password` | 登录、修改自己的密码 |
| GET | `/auth/me` | 当前用户与角色 |
| GET/POST/PUT | `/users`、`/users/{id}`、`/users/roles` | 用户管理(管理员) |
## 配置
复制 `.env.example` 并按需修改(本机直接运行时使用环境变量):
- `DATABASE_URL`:留空默认使用 `<项目根>/data/math.db`
- `CORS_ORIGINS`:允许访问的前端地址
- `OPENAI_API_KEY`:可选。数据库还没有任何模型通道时,启动会自动创建
“OpenAI · gpt-4o-mini”默认通道并使用该 Key;
`ai_teacher.py` 的逐题讲解仍为本地规则实现
- `JWT_SECRET`:JWT 签名密钥,生产环境必须设置
## 重置演示数据
```bash
cd backend
.venv/bin/python -m seed --force
```
会清空全部用户的学习数据与题库(含 AI 生成题)并重新写入种子数据;
用户、角色、教材目录与模型配置保留。演示账号的学习历史(练习记录、错题本)
现在包含逐题明细,可直接展开查看每题作答。

185
backend/database.py 100644
View File

@ -0,0 +1,185 @@
"""SQLite 数据库连接与会话管理。"""
from __future__ import annotations
import os
from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy import inspect, text
from sqlalchemy.orm import DeclarativeBase, sessionmaker
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DB_PATH = REPO_ROOT / "data" / "math.db"
class Base(DeclarativeBase):
pass
def _engine_url() -> str:
url = os.getenv("DATABASE_URL", "").strip()
if url:
return url
DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{DEFAULT_DB_PATH}"
DATABASE_URL = _engine_url()
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
if DATABASE_URL.startswith("sqlite"):
@event.listens_for(engine, "connect")
def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def ensure_schema() -> None:
"""建表并为旧库补充新增列(SQLite 不会由 create_all 自动加列)。"""
Base.metadata.create_all(bind=engine)
inspector = inspect(engine)
additions: dict[str, list[tuple[str, str]]] = {
"questions": [
("chapter_id", "INTEGER"),
("difficulty", "INTEGER NOT NULL DEFAULT 1"),
("is_generated", "BOOLEAN NOT NULL DEFAULT 0"),
("used_count", "INTEGER NOT NULL DEFAULT 0"),
],
"llm_settings": [
("name", "VARCHAR(128) NOT NULL DEFAULT ''"),
("is_default", "BOOLEAN NOT NULL DEFAULT 0"),
("created_at", "DATETIME"),
("max_tokens", "INTEGER"),
],
"attempt_items": [
("knowledge_names", "TEXT NOT NULL DEFAULT '[]'"),
],
"knowledge": [
("domain", "VARCHAR(16) NOT NULL DEFAULT '初等'"),
("category", "VARCHAR(64) NOT NULL DEFAULT ''"),
],
"error_entries": [
("attempt_id", "INTEGER"),
("question_id", "INTEGER"),
("stem", "TEXT NOT NULL DEFAULT ''"),
("options", "TEXT NOT NULL DEFAULT ''"),
("selected", "INTEGER"),
("correct_index", "INTEGER"),
("explanation", "TEXT NOT NULL DEFAULT ''"),
("knowledge_names", "TEXT NOT NULL DEFAULT '[]'"),
],
"attempt_sessions": [
("chapter_id", "INTEGER"),
],
"chapters": [
("ebook_page", "INTEGER NOT NULL DEFAULT 0"),
],
"textbooks": [
("ebook_file", "VARCHAR(256) NOT NULL DEFAULT ''"),
("ebook_name", "VARCHAR(256) NOT NULL DEFAULT ''"),
("ebook_format", "VARCHAR(8) NOT NULL DEFAULT ''"),
("ebook_size", "INTEGER NOT NULL DEFAULT 0"),
("ebook_pages", "INTEGER NOT NULL DEFAULT 0"),
("ebook_uploaded_at", "DATETIME"),
],
"knowledge_resources": [
("course_id", "INTEGER"),
],
"daily_completions": [
("chapter_id", "INTEGER"),
("rating", "VARCHAR(16) NOT NULL DEFAULT ''"),
],
}
for table_name, columns in additions.items():
if table_name not in inspector.get_table_names():
continue
existing = {
column["name"] for column in inspector.get_columns(table_name)
}
for name, definition in columns:
if name in existing:
continue
with engine.begin() as connection:
connection.execute(
text(
f"ALTER TABLE {table_name} "
f"ADD COLUMN {name} {definition}"
)
)
# 补列之后再搬数据:迁移要写 knowledge_resources.course_id
_split_courses()
def _split_courses() -> None:
"""把旧 textbooks 表里的“在线课程”行搬到 courses 表,并去掉 kind 列。
教材与课程从此各用一张表:教材带章节目录与电子书,课程只有外链。
幂等:kind 列不存在(已迁移过)时直接返回。
"""
inspector = inspect(engine)
tables = inspector.get_table_names()
if "textbooks" not in tables or "courses" not in tables:
return
columns = {column["name"] for column in inspector.get_columns("textbooks")}
if "kind" not in columns:
return
with engine.begin() as connection:
rows = connection.execute(
text(
"SELECT id, name, publisher, link, grade, description, position "
"FROM textbooks WHERE kind = 'course'"
)
).fetchall()
for row in rows:
exists = connection.execute(
text("SELECT id FROM courses WHERE name = :name"),
{"name": row[1]},
).first()
if exists is None:
connection.execute(
text(
"INSERT INTO courses "
"(name, provider, url, grade, description, position) "
"VALUES (:name, :provider, :url, :grade, :description, :position)"
),
{
"name": row[1],
"provider": row[2] or "",
"url": row[3] or "",
"grade": row[4] or "",
"description": row[5] or "",
"position": row[6] or 0,
},
)
exists = connection.execute(
text("SELECT id FROM courses WHERE name = :name"),
{"name": row[1]},
).first()
# 知识点上的视频资源改挂课程,删教材行时不会被级联删掉
connection.execute(
text(
"UPDATE knowledge_resources "
"SET course_id = :course_id, textbook_id = NULL "
"WHERE textbook_id = :textbook_id"
),
{"course_id": exists[0], "textbook_id": row[0]},
)
connection.execute(text("DELETE FROM textbooks WHERE kind = 'course'"))
connection.execute(text("ALTER TABLE textbooks DROP COLUMN kind"))
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@ -0,0 +1,62 @@
"""认证与 RBAC 依赖。"""
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from database import get_db
from models import Permission, Role, RolePermission, User, UserRole
from services.security import decode_access_token
bearer = HTTPBearer(auto_error=False)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = decode_access_token(credentials.credentials)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效")
user = db.get(User, user_id)
if user is None or not user.active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不可用")
return user
def user_permissions(db: Session, user: User) -> set[str]:
rows = (
db.query(Permission.code)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.join(Role, Role.id == RolePermission.role_id)
.join(UserRole, UserRole.role_id == Role.id)
.filter(UserRole.user_id == user.id)
.all()
)
return {code for (code,) in rows}
def require_permission(code: str):
def checker(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> User:
if code not in user_permissions(db, user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="没有权限执行该操作",
)
return user
return checker
require_learning_user = require_permission("learning:use")

141
backend/main.py 100644
View File

@ -0,0 +1,141 @@
"""数学学习系统后端入口。"""
from __future__ import annotations
import json
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from database import REPO_ROOT, SessionLocal, ensure_schema
from routers import (
analytics,
archive,
auth,
catalog,
courses,
data,
ebooks,
llm,
knowledge_graph,
overview,
practice,
question_bank,
users,
)
from seed.catalog import ensure_catalog
from services import ebooks as ebook_store
from seed.ebooks import ensure_ebooks
from seed.knowledge_graph import ensure_knowledge_graph
from seed.rbac import ensure_rbac
from services.learning_profile import (
ensure_default_profiles,
remove_unused_admin_profile,
user_has_admin_role,
)
from services.llm_config import ensure_llm_channels
from models import AttemptItem, ErrorEntry, Question, User
from services.knowledge_service import set_question_knowledge_names
ensure_schema()
db = SessionLocal()
try:
ensure_rbac(db)
ensure_knowledge_graph(db)
ensure_default_profiles(db)
for user in db.query(User).all():
if user_has_admin_role(db, user.id):
remove_unused_admin_profile(db, user.id)
ensure_catalog(db)
ensure_ebooks(db)
ebook_store.backfill_pages(db)
ensure_llm_channels(db)
db.execute(
text(
"UPDATE questions SET used_count = ("
"SELECT COUNT(*) FROM attempt_items "
"WHERE attempt_items.question_id = questions.id"
") WHERE id IN (SELECT DISTINCT question_id FROM attempt_items "
"WHERE question_id IS NOT NULL)"
)
)
for question in db.query(Question).all():
has_links = db.execute(
text(
"SELECT 1 FROM question_knowledge "
"WHERE question_id = :qid LIMIT 1"
),
{"qid": question.id},
).first()
if has_links is None:
set_question_knowledge_names(
db,
question,
[question.knowledge_name],
attach_to_chapter=True,
)
for row in db.query(ErrorEntry).all():
if not row.knowledge_names or row.knowledge_names == "[]":
row.knowledge_names = json.dumps(
[row.knowledge_name], ensure_ascii=False
)
for row in db.query(AttemptItem).all():
if not row.knowledge_names or row.knowledge_names == "[]":
row.knowledge_names = json.dumps(
[row.knowledge_name], ensure_ascii=False
)
db.commit()
finally:
db.close()
def _cors_origins() -> list[str]:
raw = os.getenv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
return [origin.strip() for origin in raw.split(",") if origin.strip()]
app = FastAPI(
title="数学学习系统 API",
description="个人数学学习系统后端:章节组卷判分、掌握度引擎、错题与学习档案。",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# 阅读器依赖 Range 分段加载,需要能读到这几个响应头
expose_headers=["Accept-Ranges", "Content-Range", "Content-Length"],
)
app.include_router(overview.router, prefix="/api")
app.include_router(data.router, prefix="/api")
app.include_router(analytics.router, prefix="/api")
app.include_router(practice.router, prefix="/api")
app.include_router(archive.router, prefix="/api")
app.include_router(catalog.router, prefix="/api")
app.include_router(courses.router, prefix="/api")
app.include_router(ebooks.router, prefix="/api")
app.include_router(ebooks.progress_router, prefix="/api")
app.include_router(question_bank.router, prefix="/api")
app.include_router(llm.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(users.router, prefix="/api")
app.include_router(knowledge_graph.router, prefix="/api")
image_dir = REPO_ROOT / "data" / "question_images"
image_dir.mkdir(parents=True, exist_ok=True)
app.mount(
"/api/question-images",
StaticFiles(directory=image_dir),
name="question-images",
)
@app.get("/api/health")
def health():
return {"status": "ok", "service": "math-learning-backend"}

View File

@ -0,0 +1,399 @@
"""ORM 模型:知识点、题目、练习记录、错题与每日完成状态。"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
Float,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
DateTime,
)
from sqlalchemy.orm import Mapped, mapped_column
from database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(Text)
display_name: Mapped[str] = mapped_column(String(64), default="")
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(32), unique=True)
name: Mapped[str] = mapped_column(String(64))
description: Mapped[str] = mapped_column(Text, default="")
class Permission(Base):
__tablename__ = "permissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
code: Mapped[str] = mapped_column(String(64), unique=True)
name: Mapped[str] = mapped_column(String(64))
group: Mapped[str] = mapped_column(String(32), default="system")
class UserRole(Base):
__tablename__ = "user_roles"
__table_args__ = (UniqueConstraint("user_id", "role_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
role_id: Mapped[int] = mapped_column(
ForeignKey("roles.id", ondelete="CASCADE"), index=True
)
class RolePermission(Base):
__tablename__ = "role_permissions"
__table_args__ = (UniqueConstraint("role_id", "permission_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
role_id: Mapped[int] = mapped_column(
ForeignKey("roles.id", ondelete="CASCADE"), index=True
)
permission_id: Mapped[int] = mapped_column(
ForeignKey("permissions.id", ondelete="CASCADE"), index=True
)
class Knowledge(Base):
"""全局知识点(同一知识点可被多个章节复用,用关系表关联)。"""
__tablename__ = "knowledge"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
domain: Mapped[str] = mapped_column(String(16), default="初等") # 初等 / 高等
category: Mapped[str] = mapped_column(String(64), default="")
description: Mapped[str] = mapped_column(Text, default="")
class KnowledgeRelation(Base):
"""知识点间关系,支持未来知识图谱(前置/包含/相关等)。"""
__tablename__ = "knowledge_relations"
__table_args__ = (
UniqueConstraint("source_id", "target_id", "relation_type"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_id: Mapped[int] = mapped_column(
ForeignKey("knowledge.id", ondelete="CASCADE"), index=True
)
target_id: Mapped[int] = mapped_column(
ForeignKey("knowledge.id", ondelete="CASCADE"), index=True
)
relation_type: Mapped[str] = mapped_column(
String(16), default="前置"
) # 前置 / 包含 / 相关
class KnowledgeResource(Base):
"""知识点对外资源链接(视频 / 在线课程 / 电子教材)。
教材与章节不在此登记:章节通过 ChapterKnowledge 反向引用知识点,
知识点详情据此反查“哪本书的哪一章讲了它”。
"""
__tablename__ = "knowledge_resources"
__table_args__ = (UniqueConstraint("knowledge_id", "kind", "url"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
knowledge_id: Mapped[int] = mapped_column(
ForeignKey("knowledge.id", ondelete="CASCADE"), index=True
)
kind: Mapped[str] = mapped_column(String(16), default="video") # video | book
title: Mapped[str] = mapped_column(String(128), default="")
url: Mapped[str] = mapped_column(String(256), default="")
textbook_id: Mapped[int | None] = mapped_column(
ForeignKey("textbooks.id", ondelete="CASCADE"),
nullable=True,
)
course_id: Mapped[int | None] = mapped_column(
ForeignKey("courses.id", ondelete="CASCADE"),
nullable=True,
)
position: Mapped[int] = mapped_column(Integer, default=0)
class ChapterKnowledge(Base):
__tablename__ = "chapter_knowledge"
__table_args__ = (UniqueConstraint("chapter_id", "knowledge_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
chapter_id: Mapped[int] = mapped_column(
ForeignKey("chapters.id", ondelete="CASCADE"), index=True
)
knowledge_id: Mapped[int] = mapped_column(
ForeignKey("knowledge.id", ondelete="CASCADE"), index=True
)
class QuestionKnowledge(Base):
__tablename__ = "question_knowledge"
__table_args__ = (UniqueConstraint("question_id", "knowledge_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
question_id: Mapped[int] = mapped_column(
ForeignKey("questions.id", ondelete="CASCADE"), index=True
)
knowledge_id: Mapped[int] = mapped_column(
ForeignKey("knowledge.id", ondelete="CASCADE"), index=True
)
class UserKnowledge(Base):
"""用户对某全局知识点的掌握度(知识点经章节/题目引用,不靠名称匹配)。"""
__tablename__ = "user_knowledge"
__table_args__ = (UniqueConstraint("user_id", "knowledge_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
knowledge_id: Mapped[int] = mapped_column(
ForeignKey("knowledge.id", ondelete="CASCADE"), index=True
)
mastery: Mapped[int] = mapped_column(Integer, default=60)
position: Mapped[int] = mapped_column(Integer, default=0)
class Textbook(Base):
__tablename__ = "textbooks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True)
author: Mapped[str] = mapped_column(String(128), default="")
publisher: Mapped[str] = mapped_column(String(128), default="")
isbn: Mapped[str] = mapped_column(String(32), default="")
edition: Mapped[str] = mapped_column(String(64), default="")
grade: Mapped[str] = mapped_column(String(32), default="")
link: Mapped[str] = mapped_column(String(256), default="")
description: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0)
# 电子书:文件放在 data/ebooks/{textbook_id}/ 下,表里只存文件名与元信息
ebook_file: Mapped[str] = mapped_column(String(256), default="")
ebook_name: Mapped[str] = mapped_column(String(256), default="") # 上传时的原始文件名
ebook_format: Mapped[str] = mapped_column(String(8), default="") # pdf | epub
ebook_size: Mapped[int] = mapped_column(Integer, default=0)
# 篇幅:PDF 页数 / EPUB 章节数,阅读器首次打开后回填真实值
ebook_pages: Mapped[int] = mapped_column(Integer, default=0)
ebook_uploaded_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True
)
class Course(Base):
"""在线课程 / 视频课:只有外链,不带章节与电子书,因此与教材分表存放。"""
__tablename__ = "courses"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True)
provider: Mapped[str] = mapped_column(String(128), default="") # 开课方
url: Mapped[str] = mapped_column(String(256), default="")
grade: Mapped[str] = mapped_column(String(32), default="")
description: Mapped[str] = mapped_column(Text, default="")
position: Mapped[int] = mapped_column(Integer, default=0)
class Chapter(Base):
__tablename__ = "chapters"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
textbook_id: Mapped[int] = mapped_column(
ForeignKey("textbooks.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(128))
position: Mapped[int] = mapped_column(Integer, default=0)
summary: Mapped[str] = mapped_column(Text, default="")
# 章节在电子书里的起始定位:PDF 是页码,EPUB 是 spine 节序号;0 表示未设置
ebook_page: Mapped[int] = mapped_column(Integer, default=0)
class Question(Base):
__tablename__ = "questions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# 遗留字段:早期“专项练习 / 自适应题库”分类,已不再作为业务概念使用,
# 仅为兼容旧数据库保留列。
source: Mapped[str] = mapped_column(String(16), default="bank")
stem: Mapped[str] = mapped_column(Text)
options: Mapped[str] = mapped_column(Text) # JSON 字符串
correct_index: Mapped[int] = mapped_column(Integer)
knowledge_name: Mapped[str] = mapped_column(String(64), index=True)
explanation: Mapped[str] = mapped_column(Text, default="")
chapter_id: Mapped[int | None] = mapped_column(
ForeignKey("chapters.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
difficulty: Mapped[int] = mapped_column(Integer, default=1)
is_generated: Mapped[bool] = mapped_column(Boolean, default=False)
used_count: Mapped[int] = mapped_column(Integer, default=0)
class AttemptSession(Base):
__tablename__ = "attempt_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
mode: Mapped[str] = mapped_column(String(16))
title: Mapped[str] = mapped_column(String(64))
total: Mapped[int] = mapped_column(Integer)
correct: Mapped[int] = mapped_column(Integer)
chapter_id: Mapped[int | None] = mapped_column(
ForeignKey("chapters.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
happened_on: Mapped[date] = mapped_column(Date, default=date.today)
class AttemptItem(Base):
"""练习中的逐题作答明细(题目做快照保存,删除题库不影响历史记录)。"""
__tablename__ = "attempt_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
attempt_id: Mapped[int] = mapped_column(
ForeignKey("attempt_sessions.id", ondelete="CASCADE"), index=True
)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
question_index: Mapped[int] = mapped_column(Integer, default=1)
question_id: Mapped[int | None] = mapped_column(
ForeignKey("questions.id", ondelete="SET NULL"),
nullable=True,
)
stem: Mapped[str] = mapped_column(Text)
options: Mapped[str] = mapped_column(Text) # JSON 字符串
correct_index: Mapped[int] = mapped_column(Integer)
selected: Mapped[int | None] = mapped_column(Integer, nullable=True)
correct: Mapped[bool] = mapped_column(Boolean)
knowledge_name: Mapped[str] = mapped_column(String(64), index=True)
knowledge_names: Mapped[str] = mapped_column(Text, default="[]")
explanation: Mapped[str] = mapped_column(Text, default="")
class ErrorEntry(Base):
__tablename__ = "error_entries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
source_label: Mapped[str] = mapped_column(String(64))
knowledge_name: Mapped[str] = mapped_column(String(64), index=True)
knowledge_names: Mapped[str] = mapped_column(Text, default="[]")
diagnosis: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(16), default="待复习")
created_on: Mapped[date] = mapped_column(Date, default=date.today)
attempt_id: Mapped[int | None] = mapped_column(
ForeignKey("attempt_sessions.id", ondelete="SET NULL"),
nullable=True,
)
question_id: Mapped[int | None] = mapped_column(
ForeignKey("questions.id", ondelete="SET NULL"),
nullable=True,
)
stem: Mapped[str] = mapped_column(Text, default="")
options: Mapped[str] = mapped_column(Text, default="") # JSON 字符串
selected: Mapped[int | None] = mapped_column(Integer, nullable=True)
correct_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
explanation: Mapped[str] = mapped_column(Text, default="")
class DailyCompletion(Base):
__tablename__ = "daily_completions"
__table_args__ = (UniqueConstraint("user_id", "done_on"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
chapter_id: Mapped[int | None] = mapped_column(
ForeignKey("chapters.id", ondelete="SET NULL"), nullable=True, index=True
)
rating: Mapped[str] = mapped_column(String(16), default="")
done_on: Mapped[date] = mapped_column(Date)
class LlmSetting(Base):
__tablename__ = "llm_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128), default="")
provider: Mapped[str] = mapped_column(String(32), default="openai")
api_key: Mapped[str] = mapped_column(Text, default="")
base_url: Mapped[str] = mapped_column(String(256), default="")
model: Mapped[str] = mapped_column(String(64), default="")
temperature: Mapped[float] = mapped_column(Float, default=0.3)
max_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class LlmTask(Base):
"""异步大模型任务:题目生成 / 章节生成,支持进度查询。"""
__tablename__ = "llm_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(32)) # generate_questions / generate_chapters
status: Mapped[str] = mapped_column(String(16), default="pending")
progress: Mapped[int] = mapped_column(Integer, default=0)
message: Mapped[str] = mapped_column(Text, default="等待执行")
params: Mapped[str] = mapped_column(Text, default="{}")
result: Mapped[str] = mapped_column(Text, default="")
created_by: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True
)
class EbookProgress(Base):
"""阅读进度:PDF 记页码,EPUB 记 CFI,每人每本书一条。"""
__tablename__ = "ebook_progress"
__table_args__ = (UniqueConstraint("user_id", "textbook_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
textbook_id: Mapped[int] = mapped_column(
ForeignKey("textbooks.id", ondelete="CASCADE"), index=True
)
locator: Mapped[str] = mapped_column(String(512), default="") # EPUB CFI
page: Mapped[int] = mapped_column(Integer, default=0)
total: Mapped[int] = mapped_column(Integer, default=0)
position: Mapped[float] = mapped_column(Float, default=0.0) # 0~1
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)

View File

@ -0,0 +1,8 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.30
sqlalchemy>=2.0,<3.0
pydantic>=2.7,<3.0
httpx>=0.27,<1.0
pyjwt>=2.8,<3.0
python-multipart>=0.0.20
pypdf>=5.0,<7.0

View File

@ -0,0 +1 @@
"""API 路由模块。"""

View File

@ -0,0 +1,95 @@
"""学习分析。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import get_current_user, user_permissions
from models import AttemptSession, ErrorEntry, User
from schemas import AnalyticsOut, DiagnosisOut, MetricItem
from services.mastery_engine import action_for, snapshot
router = APIRouter(tags=["analytics"])
def build_analytics(db: Session, user_id: int) -> AnalyticsOut:
"""按用户聚合学习分析:本人分析与管理员查看指定用户共用同一份逻辑。"""
sessions = (
db.query(AttemptSession)
.filter(AttemptSession.user_id == user_id)
.order_by(AttemptSession.id.asc())
.all()
)
total_answers = sum(s.total for s in sessions)
total_correct = sum(s.correct for s in sessions)
accuracy = total_correct / total_answers * 100 if total_answers else 0.0
last = sessions[-1] if sessions else None
errors_count = (
db.query(ErrorEntry).filter(ErrorEntry.user_id == user_id).count()
)
knowledge_rows = snapshot(db, user_id)
weak_count = sum(1 for k in knowledge_rows if k.mastery < 60)
metrics = [
MetricItem(
label="历史正确率",
value=f"{accuracy:.1f}%" if total_answers else "0%",
),
MetricItem(
label="最近一次",
value=f"{last.correct}/{last.total}" if last else "—",
),
MetricItem(label="错题数量", value=str(errors_count)),
MetricItem(label="需重点干预", value=str(weak_count)),
]
error_counts: dict[str, int] = {}
for row in (
db.query(ErrorEntry.knowledge_name, ErrorEntry.knowledge_names)
.filter(ErrorEntry.user_id == user_id)
.all()
):
try:
names = json.loads(row[1] or "[]")
if not isinstance(names, list) or not names:
names = [row[0]]
except (TypeError, ValueError):
names = [row[0]]
for name in {str(name) for name in names}:
error_counts[name] = error_counts.get(name, 0) + 1
diagnosis = [
DiagnosisOut(
name=k.name,
mastery=k.mastery,
error_count=error_counts.get(k.name, 0),
action=action_for(k.mastery),
)
for k in sorted(knowledge_rows, key=lambda x: x.mastery)
]
return AnalyticsOut(metrics=metrics, diagnosis=diagnosis)
@router.get("/analytics", response_model=AnalyticsOut)
def get_analytics(
user_id: int | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""默认返回本人学习分析;管理员可用 user_id 查看指定用户(用户管理子页面)。"""
permissions = user_permissions(db, user)
target_id = user.id
if user_id is not None and user_id != user.id:
if "users:manage" not in permissions:
raise HTTPException(status_code=403, detail="没有权限查看该用户的学习分析")
target_id = user_id
elif "learning:use" not in permissions and "users:manage" not in permissions:
raise HTTPException(status_code=403, detail="没有权限执行该操作")
if db.get(User, target_id) is None:
raise HTTPException(status_code=404, detail="用户不存在")
return build_analytics(db, target_id)

View File

@ -0,0 +1,377 @@
"""学习档案导入 / 导出(对应静态版的导出 / 导入 JSON)。"""
from __future__ import annotations
import json
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_learning_user
from models import (
AttemptItem,
AttemptSession,
DailyCompletion,
ErrorEntry,
Knowledge,
User,
UserKnowledge,
)
from schemas import ImportPayload, ImportResult
from services.knowledge_service import ensure_knowledge
router = APIRouter(prefix="/archive", tags=["archive"])
def _as_date(value) -> date:
if isinstance(value, date):
return value
return date.fromisoformat(str(value))
def _options_to_json(value) -> str:
if isinstance(value, list):
return json.dumps([str(option) for option in value], ensure_ascii=False)
return ""
def _parse_options(value) -> list[str]:
if not isinstance(value, list):
return []
return [str(option) for option in value]
def _parse_item_dict(value, index: int) -> dict:
if not isinstance(value, dict):
raise ValueError(f"第 {index} 题明细格式不正确")
stem = str(value.get("stem", "")).strip()
options = _parse_options(value.get("options"))
if not stem or len(options) != 4:
raise ValueError(f"第 {index} 题明细缺少题干或选项")
try:
correct_index = int(value.get("correct_index", -1))
except (TypeError, ValueError) as exc:
raise ValueError(f"第 {index} 题明细 correct_index 不是数字") from exc
selected = value.get("selected")
if selected is not None:
try:
selected = int(selected)
except (TypeError, ValueError) as exc:
raise ValueError(f"第 {index} 题明细 selected 不是数字") from exc
raw_names = value.get("knowledge_names") or []
if not isinstance(raw_names, list) or not raw_names:
raw_names = [value.get("knowledge_name") or "章节综合"]
return {
"question_index": index,
"question_id": value.get("question_id"),
"stem": stem,
"options": options,
"correct_index": correct_index,
"selected": selected,
"correct": bool(value.get("correct", correct_index == selected)),
"knowledge_name": str(value.get("knowledge_name", "")).strip() or "章节综合",
"knowledge_names": [str(name) for name in raw_names],
"explanation": str(value.get("explanation", "")).strip(),
}
def _parse_record(item) -> dict:
if isinstance(item, list):
if len(item) != 4:
raise ValueError("练习记录需要 4 个字段")
return {
"happened_on": _as_date(item[0]),
"mode": "imported",
"title": str(item[1]),
"total": int(item[2]),
"correct": int(item[3]),
"items": [],
}
if isinstance(item, dict):
raw_items = item.get("items") or []
parsed_items = []
for index, raw in enumerate(raw_items, start=1):
parsed_items.append(_parse_item_dict(raw, index))
return {
"happened_on": _as_date(item.get("date") or item.get("happened_on")),
"mode": str(item.get("mode", "imported")),
"title": str(item.get("title", "")),
"total": int(item.get("total", 0)),
"correct": int(item.get("correct", 0)),
"items": parsed_items,
}
raise ValueError("练习记录格式不正确")
def _parse_error(item) -> dict:
if isinstance(item, list):
if len(item) != 3:
raise ValueError("错题需要 3 个字段")
return {
"source_label": str(item[0]),
"knowledge_name": str(item[1]),
"knowledge_names": [str(item[1])],
"diagnosis": str(item[2]),
"stem": "",
"options": [],
"selected": None,
"correct_index": None,
"explanation": "",
}
if isinstance(item, dict):
raw_names = item.get("knowledge_names") or []
knowledge_name = str(item.get("knowledge_name", ""))
if not isinstance(raw_names, list) or not raw_names:
raw_names = [knowledge_name]
return {
"source_label": str(item.get("source_label", "")),
"knowledge_name": knowledge_name,
"knowledge_names": [str(name) for name in raw_names],
"diagnosis": str(item.get("diagnosis", "")),
"stem": str(item.get("stem", "")),
"options": _parse_options(item.get("options")),
"selected": item.get("selected"),
"correct_index": item.get("correct_index"),
"explanation": str(item.get("explanation", "")),
}
raise ValueError("错题格式不正确")
def _session_items(db: Session, session_id: int) -> list[dict]:
rows = (
db.query(AttemptItem)
.filter(AttemptItem.attempt_id == session_id)
.order_by(AttemptItem.question_index.asc(), AttemptItem.id.asc())
.all()
)
result = []
for row in rows:
try:
options = json.loads(row.options)
if not isinstance(options, list):
options = []
except (TypeError, ValueError):
options = []
result.append(
{
"question_index": row.question_index,
"question_id": row.question_id,
"stem": row.stem,
"options": [str(option) for option in options],
"correct_index": row.correct_index,
"selected": row.selected,
"correct": bool(row.correct),
"knowledge_name": row.knowledge_name,
"knowledge_names": (
json.loads(row.knowledge_names)
if row.knowledge_names
else [row.knowledge_name]
),
"explanation": row.explanation,
}
)
return result
@router.get("/export")
def export_archive(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
knowledge = [
{"name": knowledge.name, "mastery": user_knowledge.mastery}
for user_knowledge, knowledge in (
db.query(UserKnowledge, Knowledge)
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
.filter(UserKnowledge.user_id == user.id)
.order_by(UserKnowledge.position)
.all()
)
]
sessions = (
db.query(AttemptSession)
.filter(AttemptSession.user_id == user.id)
.order_by(AttemptSession.id)
.all()
)
records = [
{
"date": session.happened_on.isoformat(),
"mode": session.mode,
"title": session.title,
"total": session.total,
"correct": session.correct,
"items": _session_items(db, session.id),
}
for session in sessions
]
errors = []
for error in (
db.query(ErrorEntry)
.filter(ErrorEntry.user_id == user.id)
.order_by(ErrorEntry.id)
.all()
):
item = {
"source_label": error.source_label,
"knowledge_name": error.knowledge_name,
"knowledge_names": (
json.loads(error.knowledge_names)
if error.knowledge_names
else [error.knowledge_name]
),
"diagnosis": error.diagnosis,
"stem": error.stem,
"selected": error.selected,
"correct_index": error.correct_index,
"explanation": error.explanation,
}
if error.options:
try:
parsed = json.loads(error.options)
item["options"] = parsed if isinstance(parsed, list) else []
except (TypeError, ValueError):
item["options"] = []
errors.append(item)
daily = [
d.done_on.isoformat()
for d in db.query(DailyCompletion)
.filter(DailyCompletion.user_id == user.id)
.order_by(DailyCompletion.done_on)
.all()
]
payload = {
"version": 2,
"exported_at": datetime.now().isoformat(timespec="seconds"),
"knowledge": knowledge,
"records": records,
"errors": errors,
"daily_completions": daily,
}
return Response(
content=json.dumps(payload, ensure_ascii=False, indent=2),
media_type="application/json",
headers={
"Content-Disposition": 'attachment; filename="math-learning-archive.json"'
},
)
@router.post("/import", response_model=ImportResult)
def import_archive(
payload: ImportPayload,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
if not payload.knowledge:
raise HTTPException(status_code=400, detail="档案中缺少 knowledge 字段")
knowledge_items = []
for item in payload.knowledge:
name = str(item.get("name", "")).strip()
mastery = item.get("mastery", item.get("v"))
if not name or mastery is None:
raise HTTPException(status_code=400, detail="knowledge 项缺少 name 或 mastery")
knowledge_items.append({"name": name, "mastery": max(0, min(100, int(mastery)))})
parsed_records: list[dict] = []
for item in payload.records:
try:
parsed_records.append(_parse_record(item))
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=f"记录格式错误:{exc}") from exc
parsed_errors: list[dict] = []
for item in payload.errors:
try:
parsed_errors.append(_parse_error(item))
except (ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail=f"错题格式错误:{exc}") from exc
try:
done_dates = [_as_date(value) for value in payload.daily_completions]
except ValueError as exc:
raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc
# 恢复式导入:档案内容作为当前学习状态替换进来
db.query(AttemptItem).filter(AttemptItem.user_id == user.id).delete()
db.query(DailyCompletion).filter(DailyCompletion.user_id == user.id).delete()
db.query(ErrorEntry).filter(ErrorEntry.user_id == user.id).delete()
db.query(AttemptSession).filter(AttemptSession.user_id == user.id).delete()
db.query(UserKnowledge).filter(UserKnowledge.user_id == user.id).delete()
for index, item in enumerate(knowledge_items):
knowledge = ensure_knowledge(db, item["name"])
db.add(
UserKnowledge(
user_id=user.id,
knowledge_id=knowledge.id,
mastery=item["mastery"],
position=index,
)
)
for done_on in done_dates:
db.add(DailyCompletion(user_id=user.id, done_on=done_on))
item_count = 0
for error in parsed_errors:
db.add(
ErrorEntry(
user_id=user.id,
source_label=error["source_label"],
knowledge_name=error["knowledge_name"],
knowledge_names=json.dumps(
error["knowledge_names"], ensure_ascii=False
),
diagnosis=error["diagnosis"],
stem=error["stem"],
options=_options_to_json(error["options"]),
selected=error["selected"],
correct_index=error["correct_index"],
explanation=error["explanation"],
)
)
for record in parsed_records:
session = AttemptSession(
user_id=user.id,
mode=record["mode"],
title=record["title"],
total=record["total"],
correct=record["correct"],
happened_on=record["happened_on"],
)
db.add(session)
db.flush()
for item in record["items"]:
db.add(
AttemptItem(
attempt_id=session.id,
user_id=user.id,
question_index=item["question_index"],
# 题库题目可能已被删除,导入只保留快照不重建外键
question_id=None,
stem=item["stem"],
options=_options_to_json(item["options"]),
correct_index=item["correct_index"],
selected=item.get("selected"),
correct=bool(item.get("correct")),
knowledge_name=item["knowledge_name"],
knowledge_names=json.dumps(
item["knowledge_names"], ensure_ascii=False
),
explanation=item["explanation"],
)
)
item_count += 1
db.commit()
return ImportResult(
imported=True,
knowledge=len(knowledge_items),
records=len(parsed_records),
errors=len(parsed_errors),
items=item_count,
message="学习档案导入成功。",
)

View File

@ -0,0 +1,92 @@
"""登录、会话与个人账号接口。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import get_current_user
from models import Role, User, UserRole
from schemas import (
ChangePasswordIn,
LoginIn,
ProfileUpdate,
RoleOut,
TokenOut,
UserOut,
)
from services.security import create_access_token, hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
def user_roles(db: Session, user: User) -> list[Role]:
return (
db.query(Role)
.join(UserRole, UserRole.role_id == Role.id)
.filter(UserRole.user_id == user.id)
.order_by(Role.id.asc())
.all()
)
def user_to_out(db: Session, user: User) -> UserOut:
roles = user_roles(db, user)
return UserOut(
id=user.id,
username=user.username,
display_name=user.display_name,
active=user.active,
roles=[RoleOut.model_validate(role) for role in roles],
created_at=user.created_at,
)
@router.post("/login", response_model=TokenOut)
def login(payload: LoginIn, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == payload.username).first()
if user is None or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.active:
raise HTTPException(status_code=403, detail="账号已被停用")
roles = user_roles(db, user)
if not roles:
raise HTTPException(status_code=403, detail="账号未分配角色")
return TokenOut(
access_token=create_access_token(user.id),
role=roles[0].code,
display_name=user.display_name or user.username,
)
@router.get("/me", response_model=UserOut)
def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return user_to_out(db, user)
@router.post("/change-password")
def change_password(
payload: ChangePasswordIn,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
if not verify_password(payload.old_password, user.password_hash):
raise HTTPException(status_code=400, detail="原密码不正确")
user.password_hash = hash_password(payload.new_password)
db.commit()
return {"ok": True, "message": "密码已更新,下次登录请使用新密码。"}
@router.put("/profile", response_model=UserOut)
def update_profile(
payload: ProfileUpdate,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
name = payload.display_name.strip()
if not name:
raise HTTPException(status_code=400, detail="昵称不能为空")
user.display_name = name
db.commit()
return user_to_out(db, user)

View File

@ -0,0 +1,554 @@
"""教材 / 课程目录:读取对登录用户开放,增删改仅管理员。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
from dependencies import get_current_user, require_permission
from models import (
Chapter,
EbookProgress,
ChapterKnowledge,
Knowledge,
LlmSetting,
Question,
Textbook,
User,
)
from schemas import (
ChapterCreate,
EbookProgressOut,
ChapterOut,
ChapterUpdate,
ChaptersGenerateIn,
ChaptersGenerateOut,
ChaptersOrganizeIn,
TextbookCreate,
TextbookOut,
TextbookUpdate,
)
from services import ebooks as ebook_store
from services.catalog_generator import (
generate_chapters,
organize_reference_chapters,
)
from services.isbn_lookup import fetch_isbn_context
from services.llm_config import get_default_channel
from services.knowledge_service import (
set_chapter_knowledge_names,
set_chapter_knowledge_ids,
)
router = APIRouter(prefix="/textbooks", tags=["catalog"])
def _chapter_out(db: Session, chapter: Chapter) -> ChapterOut:
question_count = (
db.query(func.count(Question.id))
.filter(Question.chapter_id == chapter.id)
.scalar()
or 0
)
knowledge_rows = (
db.query(Knowledge.id, Knowledge.name)
.join(ChapterKnowledge, ChapterKnowledge.knowledge_id == Knowledge.id)
.filter(ChapterKnowledge.chapter_id == chapter.id)
.order_by(ChapterKnowledge.id.asc())
.all()
)
knowledge_names = [name for _, name in knowledge_rows]
return ChapterOut(
id=chapter.id,
textbook_id=chapter.textbook_id,
name=chapter.name,
position=chapter.position,
summary=chapter.summary,
ebook_page=chapter.ebook_page,
question_count=question_count,
knowledge_names=[str(name) for name in knowledge_names],
knowledge_ids=[kid for kid, _ in knowledge_rows],
)
def _textbook_out(
db: Session, textbook: Textbook, user: User | None = None
) -> TextbookOut:
chapters = (
db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.order_by(Chapter.position.asc(), Chapter.id.asc())
.all()
)
return TextbookOut(
id=textbook.id,
name=textbook.name,
author=textbook.author,
publisher=textbook.publisher,
isbn=textbook.isbn,
edition=textbook.edition,
grade=textbook.grade,
link=textbook.link,
description=textbook.description,
position=textbook.position,
chapters=[_chapter_out(db, chapter) for chapter in chapters],
ebook_file=textbook.ebook_file,
ebook_format=textbook.ebook_format,
ebook_name=textbook.ebook_name,
ebook_size=textbook.ebook_size,
ebook_pages=textbook.ebook_pages,
ebook_uploaded_at=textbook.ebook_uploaded_at,
ebook_progress=_ebook_progress_out(db, textbook, user),
)
def _ebook_progress_out(
db: Session, textbook: Textbook, user: User | None
) -> EbookProgressOut | None:
if user is None or not textbook.ebook_file:
return None
row = (
db.query(EbookProgress)
.filter(
EbookProgress.user_id == user.id,
EbookProgress.textbook_id == textbook.id,
)
.first()
)
if row is None:
return None
return EbookProgressOut.model_validate(row)
def _load_textbook(db: Session, textbook_id: int) -> Textbook:
textbook = db.get(Textbook, textbook_id)
if textbook is None:
raise HTTPException(status_code=404, detail="教材不存在")
return textbook
def _resolve_channel(db: Session, channel_id: int | None) -> LlmSetting:
if channel_id is not None:
channel = db.get(LlmSetting, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="指定的模型通道不存在")
return channel
channel = get_default_channel(db)
if channel is None:
raise HTTPException(
status_code=400,
detail="尚未配置模型通道,请先在“模型配置”新增并设为默认",
)
return channel
def _book_graph_context(db: Session, textbook: Textbook) -> list[str]:
name = textbook.name
category_hints: list[str] = []
if "代数" in name:
category_hints = ["数与式", "方程与不等式", "函数与图像", "数列与证明"]
elif "几何" in name:
category_hints = ["平面几何"]
elif "坐标" in name:
category_hints = ["坐标与解析几何"]
elif "三角" in name:
category_hints = ["三角函数"]
elif "函数" in name or "图像" in name:
category_hints = ["函数与图像", "坐标与解析几何"]
if category_hints:
return [
node_name
for (node_name,) in db.query(Knowledge.name)
.filter(Knowledge.category.in_(category_hints))
.all()
]
# 兜底:给常见初等图谱节点
return [
node_name
for (node_name,) in db.query(Knowledge.name)
.filter(Knowledge.domain == "初等", Knowledge.category != "总纲")
.all()
]
@router.get("", response_model=list[TextbookOut])
def list_textbooks(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
textbooks = (
db.query(Textbook)
.order_by(Textbook.position.asc(), Textbook.id.asc())
.all()
)
return [_textbook_out(db, textbook, user) for textbook in textbooks]
@router.get("/{textbook_id}/chapters", response_model=list[ChapterOut])
def list_chapters(
textbook_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
):
textbook = _load_textbook(db, textbook_id)
chapters = (
db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.order_by(Chapter.position.asc(), Chapter.id.asc())
.all()
)
return [_chapter_out(db, chapter) for chapter in chapters]
@router.post("", response_model=TextbookOut)
def create_textbook(
payload: TextbookCreate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
if db.query(Textbook).filter(Textbook.name == payload.name).first():
raise HTTPException(status_code=400, detail="同名教材已存在")
max_position = db.query(Textbook).count()
textbook = Textbook(
name=payload.name,
author=payload.author,
publisher=payload.publisher,
isbn=payload.isbn,
edition=payload.edition,
grade=payload.grade,
link=payload.link,
description=payload.description,
position=max_position,
)
db.add(textbook)
db.commit()
return _textbook_out(db, textbook)
@router.put("/{textbook_id}", response_model=TextbookOut)
def update_textbook(
textbook_id: int,
payload: TextbookUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
textbook = _load_textbook(db, textbook_id)
fields = payload.model_dump(exclude_unset=True)
if "name" in fields and fields["name"] != textbook.name:
exists = (
db.query(Textbook)
.filter(Textbook.name == fields["name"], Textbook.id != textbook.id)
.first()
)
if exists:
raise HTTPException(status_code=400, detail="同名教材已存在")
for field, value in fields.items():
setattr(textbook, field, value)
db.commit()
return _textbook_out(db, textbook)
@router.delete("/{textbook_id}")
def delete_textbook(
textbook_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
textbook = _load_textbook(db, textbook_id)
question_count = (
db.query(func.count(Question.id))
.join(Chapter, Chapter.id == Question.chapter_id)
.filter(Chapter.textbook_id == textbook.id)
.scalar()
or 0
)
if question_count > 0:
raise HTTPException(
status_code=400,
detail=f"该书下还有 {question_count} 道题目,不能删除教材,只能修改",
)
db.query(Chapter).filter(Chapter.textbook_id == textbook.id).delete()
ebook_store.drop_progress(db, textbook.id)
ebook_store.drop_book_files(textbook.id)
db.delete(textbook)
db.commit()
return {"deleted": textbook_id}
@router.post("/{textbook_id}/chapters", response_model=ChapterOut)
def create_chapter(
textbook_id: int,
payload: ChapterCreate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
textbook = _load_textbook(db, textbook_id)
chapter = Chapter(
textbook_id=textbook.id,
name=payload.name,
position=db.query(Chapter).filter(Chapter.textbook_id == textbook.id).count(),
summary=payload.summary,
)
db.add(chapter)
db.flush()
if payload.knowledge_ids:
set_chapter_knowledge_ids(db, chapter.id, payload.knowledge_ids)
else:
set_chapter_knowledge_names(db, chapter.id, payload.knowledge_names)
db.commit()
return _chapter_out(db, chapter)
@router.post("/{textbook_id}/chapters/generate", response_model=ChaptersGenerateOut)
def generate_catalog_chapters(
textbook_id: int,
payload: ChaptersGenerateIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
textbook = _load_textbook(db, textbook_id)
existing = (
db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.order_by(Chapter.position.asc(), Chapter.id.asc())
.all()
)
existing_names = {chapter.name for chapter in existing}
external_context: dict = {}
lookup_titles: list[str] = []
if textbook.isbn:
external_context = fetch_isbn_context(textbook.isbn)
lookup_titles = external_context.get("chapter_titles") or []
fixed_titles: list[str] | None = None
generation_count = payload.count if payload.count is not None else 60
source_note = "已结合书名 / ISBN / 作者信息自动获取章节"
if lookup_titles:
missing_titles = [
title for title in lookup_titles if title not in existing_names
]
if missing_titles:
fixed_titles = missing_titles[:12]
generation_count = len(fixed_titles)
source_note = "已按 ISBN 查询到的正式章节名生成摘要"
selected_names: list[str] = []
if payload.knowledge_ids:
rows = (
db.query(Knowledge.name)
.filter(Knowledge.id.in_(payload.knowledge_ids))
.all()
)
selected_names = [name for (name,) in rows]
else:
selected_names = _book_graph_context(db, textbook)
channel = _resolve_channel(db, payload.channel_id)
if not channel.api_key or not channel.base_url or not channel.model:
raise HTTPException(
status_code=400, detail="模型通道未完整配置,请先补全并测试"
)
try:
proposed = generate_chapters(
setting=channel,
textbook=textbook,
count=generation_count,
existing_names=sorted(existing_names),
instructions=(
f"可挂接的知识图谱节点参考:{'、'.join(selected_names)}。\n"
if selected_names
else ""
) + payload.instructions.strip(),
fixed_titles=fixed_titles,
external_context=external_context,
graph_context=selected_names or None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"生成失败:{exc}") from exc
max_position = (
db.query(func.max(Chapter.position))
.filter(Chapter.textbook_id == textbook.id)
.scalar()
)
position = (max_position if max_position is not None else -1) + 1
inserted: list[Chapter] = []
skipped: list[str] = []
for item in proposed:
if item["name"] in existing_names:
skipped.append(item["name"])
continue
existing_names.add(item["name"])
chapter = Chapter(
textbook_id=textbook.id,
name=item["name"],
summary=item["summary"],
position=position,
)
db.add(chapter)
db.flush()
if payload.knowledge_ids:
try:
set_chapter_knowledge_ids(
db, chapter.id, payload.knowledge_ids
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
else:
item_names = item.get("knowledge_names") or []
valid_names = [
node_name
for (node_name,) in (
db.query(Knowledge.name)
.filter(Knowledge.name.in_(item_names))
.all()
)
] if item_names else []
if valid_names:
set_chapter_knowledge_names(db, chapter.id, valid_names)
inserted.append(chapter)
position += 1
db.flush()
created = [_chapter_out(db, chapter) for chapter in inserted]
db.commit()
message = f"{source_note}:已新增 {len(created)} 个章节" if source_note else f"已新增 {len(created)} 个章节"
if skipped:
message += f",跳过重复章节 {len(skipped)} 个"
return ChaptersGenerateOut(
created=created,
skipped_duplicates=skipped,
message=message,
)
@router.post("/{textbook_id}/chapters/organize", response_model=ChaptersGenerateOut)
def organize_textbook_chapters(
textbook_id: int,
payload: ChaptersOrganizeIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
textbook = _load_textbook(db, textbook_id)
existing = (
db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.order_by(Chapter.position.asc(), Chapter.id.asc())
.all()
)
existing_names = {chapter.name for chapter in existing}
channel = _resolve_channel(db, payload.channel_id)
if not channel.api_key or not channel.base_url or not channel.model:
raise HTTPException(
status_code=400, detail="模型通道未完整配置,请先补全并测试"
)
graph_context = _book_graph_context(db, textbook)
try:
proposed = organize_reference_chapters(
setting=channel,
textbook=textbook,
reference_text=payload.reference_text.strip(),
existing_names=sorted(existing_names),
graph_context=graph_context,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"整理失败:{exc}") from exc
max_position = (
db.query(func.max(Chapter.position))
.filter(Chapter.textbook_id == textbook.id)
.scalar()
)
position = (max_position if max_position is not None else -1) + 1
inserted: list[Chapter] = []
skipped: list[str] = []
for item in proposed:
if item["name"] in existing_names:
skipped.append(item["name"])
continue
existing_names.add(item["name"])
chapter = Chapter(
textbook_id=textbook.id,
name=item["name"],
summary=item["summary"],
position=position,
)
db.add(chapter)
db.flush()
item_names = item.get("knowledge_names") or []
valid_names = (
[
node_name
for (node_name,) in (
db.query(Knowledge.name)
.filter(Knowledge.name.in_(item_names))
.all()
)
]
if item_names
else []
)
if valid_names:
set_chapter_knowledge_names(db, chapter.id, valid_names)
inserted.append(chapter)
position += 1
created = [_chapter_out(db, chapter) for chapter in inserted]
db.commit()
message = f"AI 整理完成:已新增 {len(created)} 个章节并关联知识图谱"
if skipped:
message += f",跳过重复 {len(skipped)} 个"
return ChaptersGenerateOut(
created=created,
skipped_duplicates=skipped,
message=message,
)
@router.put("/chapters/{chapter_id}", response_model=ChapterOut)
def update_chapter(
chapter_id: int,
payload: ChapterUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
chapter = db.get(Chapter, chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
update_fields = payload.model_dump(exclude_unset=True)
knowledge_names = update_fields.pop("knowledge_names", None)
knowledge_ids = update_fields.pop("knowledge_ids", None)
for field, value in update_fields.items():
setattr(chapter, field, value)
if knowledge_ids is not None:
try:
set_chapter_knowledge_ids(db, chapter.id, knowledge_ids)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
elif knowledge_names is not None:
set_chapter_knowledge_names(db, chapter.id, knowledge_names)
db.commit()
return _chapter_out(db, chapter)
@router.delete("/chapters/{chapter_id}")
def delete_chapter(
chapter_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
chapter = db.get(Chapter, chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
question_count = (
db.query(func.count(Question.id))
.filter(Question.chapter_id == chapter.id)
.scalar()
or 0
)
if question_count > 0:
raise HTTPException(
status_code=400,
detail=f"该章节下有 {question_count} 道题目,不能删除,只能修改章节",
)
db.delete(chapter)
db.commit()
return {"deleted": chapter_id}

View File

@ -0,0 +1,96 @@
"""在线课程:只有外链的视频/课程资源,与教材分表维护。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import get_current_user, require_permission
from models import Course, User
from schemas import CourseCreate, CourseOut, CourseUpdate
router = APIRouter(prefix="/courses", tags=["courses"])
def _load(db: Session, course_id: int) -> Course:
course = db.get(Course, course_id)
if course is None:
raise HTTPException(status_code=404, detail="课程不存在")
return course
def _clean_url(url: str | None) -> str:
url = (url or "").strip()
if not url:
raise HTTPException(status_code=400, detail="请填写课程链接")
return url
@router.get("", response_model=list[CourseOut])
def list_courses(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
):
return (
db.query(Course)
.order_by(Course.position.asc(), Course.id.asc())
.all()
)
@router.post("", response_model=CourseOut)
def create_course(
payload: CourseCreate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
if db.query(Course).filter(Course.name == payload.name).first():
raise HTTPException(status_code=400, detail="同名课程已存在")
course = Course(
name=payload.name,
provider=payload.provider,
url=_clean_url(payload.url),
grade=payload.grade,
description=payload.description,
position=db.query(Course).count(),
)
db.add(course)
db.commit()
return course
@router.put("/{course_id}", response_model=CourseOut)
def update_course(
course_id: int,
payload: CourseUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
course = _load(db, course_id)
fields = payload.model_dump(exclude_unset=True)
if "name" in fields and fields["name"] != course.name:
exists = (
db.query(Course)
.filter(Course.name == fields["name"], Course.id != course.id)
.first()
)
if exists:
raise HTTPException(status_code=400, detail="同名课程已存在")
if "url" in fields:
fields["url"] = _clean_url(fields["url"])
for field, value in fields.items():
setattr(course, field, value)
db.commit()
return course
@router.delete("/{course_id}")
def delete_course(
course_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
course = _load(db, course_id)
db.delete(course)
db.commit()
return {"deleted": course_id}

View File

@ -0,0 +1,427 @@
"""知识点、错题本、练习记录、今日任务与课程资源。"""
from __future__ import annotations
import json
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_learning_user
from models import (
AttemptItem,
AttemptSession,
Chapter,
ChapterKnowledge,
DailyCompletion,
ErrorEntry,
Knowledge,
KnowledgeRelation,
Question,
QuestionKnowledge,
Textbook,
User,
UserKnowledge,
)
from schemas import (
AttemptItemOut,
DailyCompleteIn,
DailyOut,
ErrorOut,
KnowledgeChapterOut,
KnowledgeDetailOut,
KnowledgeRelatedOut,
KnowledgeResourceOut,
KnowledgeOut,
RecordDetailOut,
RecordOut,
)
from services.mastery_engine import snapshot, status_for
from services.recommendation import build_daily_recommendation
from services.knowledge_service import chapter_knowledge_names, knowledge_resources
router = APIRouter(tags=["data"])
def _error_out(error: ErrorEntry) -> ErrorOut:
options: list[str] = []
if error.options:
try:
parsed = json.loads(error.options)
if isinstance(parsed, list):
options = [str(option) for option in parsed]
except (TypeError, ValueError):
options = []
try:
knowledge_names = json.loads(error.knowledge_names or "[]")
if not isinstance(knowledge_names, list) or not knowledge_names:
knowledge_names = [error.knowledge_name]
except (TypeError, ValueError):
knowledge_names = [error.knowledge_name]
return ErrorOut(
id=error.id,
source_label=error.source_label,
knowledge_name=error.knowledge_name,
diagnosis=error.diagnosis,
status=error.status,
created_on=error.created_on,
attempt_id=error.attempt_id,
question_id=error.question_id,
stem=error.stem,
options=options,
selected=error.selected,
correct_index=error.correct_index,
explanation=error.explanation,
knowledge_names=[str(name) for name in knowledge_names],
)
def _record_out(session: AttemptSession) -> RecordOut:
return RecordOut(
id=session.id,
mode=session.mode,
title=session.title,
total=session.total,
correct=session.correct,
happened_on=session.happened_on,
)
def _ebook_ref(db: Session, chapter_id: int | None) -> dict:
"""章节对应的电子书定位:章节或电子书缺失时返回空,前端据此隐藏入口。"""
chapter = db.get(Chapter, chapter_id) if chapter_id else None
textbook = db.get(Textbook, chapter.textbook_id) if chapter else None
if textbook is None or not textbook.ebook_file:
return {"has_ebook": False}
return {
"textbook_id": textbook.id,
"ebook_page": chapter.ebook_page,
"has_ebook": True,
}
def _daily_context(db: Session, user_id: int, today: date) -> dict:
completion = (
db.query(DailyCompletion)
.filter(
DailyCompletion.user_id == user_id,
DailyCompletion.done_on == today,
)
.first()
)
if completion and completion.chapter_id is not None:
chapter = db.get(Chapter, completion.chapter_id)
textbook = (
db.get(Textbook, chapter.textbook_id) if chapter is not None else None
)
return {
"chapter_id": chapter.id if chapter else None,
"chapter_name": chapter.name if chapter else "",
"book_name": textbook.name if textbook else "",
"rating": completion.rating,
"knowledge_names": chapter_knowledge_names(db, chapter.id)
if chapter
else [],
"reason": "今日章节已完成",
**_ebook_ref(db, chapter.id if chapter else None),
}
recommendation = build_daily_recommendation(db, user_id) or {}
return {
"chapter_id": recommendation.get("chapter_id"),
"chapter_name": recommendation.get("chapter_name", ""),
"book_name": recommendation.get("book_name", ""),
"rating": "",
"knowledge_names": recommendation.get("knowledge_names", []),
"reason": recommendation.get("reason", ""),
**_ebook_ref(db, recommendation.get("chapter_id")),
}
def _streak(done_dates: set[date]) -> int:
today = date.today()
if today not in done_dates and today - timedelta(days=1) not in done_dates:
return 0
anchor = today if today in done_dates else today - timedelta(days=1)
count = 0
while anchor in done_dates:
count += 1
anchor -= timedelta(days=1)
return count
@router.get("/knowledge", response_model=list[KnowledgeOut])
def list_knowledge(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
return [
KnowledgeOut(
id=k.id,
knowledge_id=k.knowledge_id,
name=k.name,
mastery=k.mastery,
position=k.position,
status=status_for(k.mastery),
domain=k.domain,
category=k.category,
)
for k in snapshot(db, user.id)
]
@router.get("/knowledge/detail/{knowledge_id}", response_model=KnowledgeDetailOut)
def knowledge_detail(
knowledge_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
"""知识点详情:定义、掌握度、相关知识点与反向关联的学习资源。"""
node = db.get(Knowledge, knowledge_id)
if node is None:
raise HTTPException(status_code=404, detail="知识点不存在")
mastery_map = {
user_knowledge.knowledge_id: user_knowledge.mastery
for user_knowledge in db.query(UserKnowledge)
.filter(UserKnowledge.user_id == user.id)
.all()
}
mastery = mastery_map.get(node.id, 0)
relation_rows = (
db.query(KnowledgeRelation, Knowledge)
.join(
Knowledge,
(Knowledge.id == KnowledgeRelation.target_id)
| (Knowledge.id == KnowledgeRelation.source_id),
)
.filter(
(KnowledgeRelation.source_id == node.id)
| (KnowledgeRelation.target_id == node.id),
Knowledge.id != node.id,
)
.all()
)
related: list[KnowledgeRelatedOut] = []
seen_related: set[int] = set()
for relation, other in relation_rows:
if other.id in seen_related:
continue
seen_related.add(other.id)
related.append(
KnowledgeRelatedOut(
id=other.id,
name=other.name,
relation_type=relation.relation_type,
direction="in" if relation.target_id == node.id else "out",
mastery=mastery_map.get(other.id),
)
)
question_count = (
db.query(QuestionKnowledge)
.filter(QuestionKnowledge.knowledge_id == node.id)
.count()
)
chapter_rows = (
db.query(Chapter, Textbook)
.join(ChapterKnowledge, ChapterKnowledge.chapter_id == Chapter.id)
.join(Textbook, Textbook.id == Chapter.textbook_id)
.filter(ChapterKnowledge.knowledge_id == node.id)
.order_by(Textbook.position.asc(), Chapter.position.asc())
.all()
)
chapters = [
KnowledgeChapterOut(
chapter_id=chapter.id,
chapter_name=chapter.name,
textbook_id=textbook.id,
textbook_name=textbook.name,
ebook_page=chapter.ebook_page,
has_ebook=bool(textbook.ebook_file),
question_count=db.query(Question)
.filter(Question.chapter_id == chapter.id)
.count(),
)
for chapter, textbook in chapter_rows
]
resources = [
KnowledgeResourceOut(
id=row.id,
kind=row.kind,
title=row.title,
url=row.url,
source_name=row.title,
)
for row in knowledge_resources(db, node.id)
]
return KnowledgeDetailOut(
id=node.id,
name=node.name,
domain=node.domain,
category=node.category,
description=node.description or "",
mastery=mastery,
status=status_for(mastery) if node.id in mastery_map else "未学习",
related_knowledge_count=len(related),
chapter_count=len(chapters),
question_count=question_count,
related=related,
chapters=chapters,
resources=resources,
)
@router.get("/errors", response_model=list[ErrorOut])
def list_errors(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
rows = (
db.query(ErrorEntry)
.filter(ErrorEntry.user_id == user.id)
.order_by(ErrorEntry.id.desc())
.all()
)
return [
_error_out(e)
for e in rows
]
@router.get("/records", response_model=list[RecordOut])
def list_records(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
rows = (
db.query(AttemptSession)
.filter(AttemptSession.user_id == user.id)
.order_by(AttemptSession.id.desc())
.all()
)
return [
_record_out(s)
for s in rows
]
@router.get("/records/{record_id}", response_model=RecordDetailOut)
def record_detail(
record_id: int,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
session = (
db.query(AttemptSession)
.filter(
AttemptSession.id == record_id,
AttemptSession.user_id == user.id,
)
.first()
)
if session is None:
raise HTTPException(status_code=404, detail="练习记录不存在")
items = (
db.query(AttemptItem)
.filter(AttemptItem.attempt_id == session.id)
.order_by(AttemptItem.question_index.asc(), AttemptItem.id.asc())
.all()
)
parsed_items: list[AttemptItemOut] = []
for item in items:
try:
options = json.loads(item.options)
if not isinstance(options, list):
options = []
except (TypeError, ValueError):
options = []
try:
parsed_names = json.loads(item.knowledge_names or "[]")
if not isinstance(parsed_names, list) or not parsed_names:
parsed_names = [item.knowledge_name]
except (TypeError, ValueError):
parsed_names = [item.knowledge_name]
parsed_items.append(
AttemptItemOut(
question_index=item.question_index,
question_id=item.question_id,
stem=item.stem,
options=[str(option) for option in options],
selected=item.selected,
correct_index=item.correct_index,
correct=bool(item.correct),
knowledge_name=item.knowledge_name,
explanation=item.explanation,
knowledge_names=[str(name) for name in parsed_names],
)
)
return RecordDetailOut(
**_record_out(session).model_dump(),
items=parsed_items,
)
@router.get("/daily", response_model=DailyOut)
def get_daily(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
today = date.today()
done_dates = {
row.done_on
for row in db.query(DailyCompletion)
.filter(DailyCompletion.user_id == user.id)
.all()
}
context = _daily_context(db, user.id, today)
return DailyOut(
today=today,
completed=today in done_dates,
streak=_streak(done_dates),
**context,
)
@router.post("/daily/complete", response_model=DailyOut)
def complete_daily(
payload: DailyCompleteIn,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
today = date.today()
chapter = db.get(Chapter, payload.chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
exists = (
db.query(DailyCompletion)
.filter(
DailyCompletion.user_id == user.id,
DailyCompletion.done_on == today,
)
.first()
)
if exists is None:
exists = DailyCompletion(
user_id=user.id, chapter_id=chapter.id, done_on=today
)
db.add(exists)
exists.chapter_id = chapter.id
exists.rating = payload.rating
db.commit()
done_dates = {
row.done_on
for row in db.query(DailyCompletion)
.filter(DailyCompletion.user_id == user.id)
.all()
}
return DailyOut(
today=today,
completed=True,
streak=_streak(done_dates),
**_daily_context(db, user.id, today),
)

View File

@ -0,0 +1,141 @@
"""电子书:管理员上传/替换 PDF·EPUB,登录用户在线阅读并记录进度。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from database import get_db
from dependencies import get_current_user, require_permission
from models import EbookProgress, Textbook, User
from schemas import EbookProgressIn, EbookProgressOut
from services import ebooks as store
# 上传 / 删除 / 取文件挂在教材下,进度单独成组
router = APIRouter(prefix="/textbooks", tags=["ebooks"])
progress_router = APIRouter(prefix="/ebooks", tags=["ebooks"])
def _load(db: Session, textbook_id: int) -> Textbook:
textbook = db.get(Textbook, textbook_id)
if textbook is None:
raise HTTPException(status_code=404, detail="教材不存在")
return textbook
def _require_file(textbook: Textbook) -> None:
if not textbook.ebook_file:
raise HTTPException(status_code=404, detail="该书还没有上传电子书")
@router.post("/{textbook_id}/ebook")
async def upload_ebook(
textbook_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
textbook = _load(db, textbook_id)
await store.store_upload(db, textbook, file)
return {
"id": textbook.id,
"ebook_format": textbook.ebook_format,
"ebook_name": textbook.ebook_name,
"ebook_size": textbook.ebook_size,
"ebook_pages": textbook.ebook_pages,
}
@router.delete("/{textbook_id}/ebook")
def delete_ebook(
textbook_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("catalog:manage")),
):
textbook = _load(db, textbook_id)
_require_file(textbook)
store.clear_ebook(db, textbook)
return {"deleted": textbook_id}
@router.get("/{textbook_id}/ebook/file/{filename}")
def ebook_file(
textbook_id: int,
filename: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
):
"""阅读器取原始文件。FileResponse 自带 Range/206 支持,PDF 可分段加载。"""
textbook = _load(db, textbook_id)
_require_file(textbook)
if filename != textbook.ebook_file:
raise HTTPException(status_code=404, detail="电子书文件不存在")
path = store.resolve_path(textbook.id, filename)
return FileResponse(
path,
media_type=store.media_type(textbook.ebook_format),
filename=textbook.ebook_name or path.name,
content_disposition_type="inline",
)
@progress_router.get("/progress", response_model=list[EbookProgressOut])
def list_progress(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
rows = (
db.query(EbookProgress)
.filter(EbookProgress.user_id == user.id)
.all()
)
return [
EbookProgressOut(
textbook_id=row.textbook_id,
locator=row.locator,
page=row.page,
total=row.total,
position=row.position,
updated_at=row.updated_at,
)
for row in rows
]
@progress_router.put("/{textbook_id}/progress", response_model=EbookProgressOut)
def save_progress(
textbook_id: int,
payload: EbookProgressIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
textbook = _load(db, textbook_id)
_require_file(textbook)
row = (
db.query(EbookProgress)
.filter(
EbookProgress.user_id == user.id,
EbookProgress.textbook_id == textbook_id,
)
.first()
)
if row is None:
row = EbookProgress(user_id=user.id, textbook_id=textbook_id)
db.add(row)
row.locator = payload.locator[:512]
row.page = max(0, payload.page)
row.total = max(0, payload.total)
row.position = min(1.0, max(0.0, payload.position))
# 阅读器拿到的才是真实篇幅(PDF 页数 / EPUB 章节数),回填给管理端展示
if row.total > 0 and textbook.ebook_pages != row.total:
textbook.ebook_pages = row.total
db.commit()
return EbookProgressOut(
textbook_id=textbook_id,
locator=row.locator,
page=row.page,
total=row.total,
position=row.position,
updated_at=row.updated_at,
)

View File

@ -0,0 +1,313 @@
"""知识图谱:节点 / 关系浏览与管理员维护。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_permission
from models import (
ChapterKnowledge,
Course,
Knowledge,
KnowledgeRelation,
KnowledgeResource,
QuestionKnowledge,
Textbook,
User,
UserKnowledge,
)
from schemas import (
KnowledgeGraphEdge,
KnowledgeGraphNode,
KnowledgeGraphOut,
KnowledgeNodeCreate,
KnowledgeNodeUpdate,
KnowledgeRelationIn,
KnowledgeResourceIn,
KnowledgeResourceOut,
)
from services.knowledge_service import knowledge_resources
router = APIRouter(prefix="/knowledge", tags=["knowledge-graph"])
RELATION_TYPES = {"包含", "前置", "相关"}
def _node_out(db: Session, node: Knowledge) -> KnowledgeGraphNode:
chapter_count = (
db.query(func.count(ChapterKnowledge.id))
.filter(ChapterKnowledge.knowledge_id == node.id)
.scalar()
or 0
)
question_count = (
db.query(func.count(QuestionKnowledge.id))
.filter(QuestionKnowledge.knowledge_id == node.id)
.scalar()
or 0
)
return KnowledgeGraphNode(
id=node.id,
name=node.name,
domain=node.domain,
category=node.category,
description=node.description,
chapter_count=chapter_count,
question_count=question_count,
resources=[
KnowledgeResourceOut(
id=row.id,
kind=row.kind,
title=row.title,
url=row.url,
source_name=row.title,
)
for row in knowledge_resources(db, node.id)
],
)
def _unique_name(db: Session, name: str, exclude_id: int | None = None):
query = db.query(Knowledge).filter(Knowledge.name == name)
if exclude_id is not None:
query = query.filter(Knowledge.id != exclude_id)
if query.first() is not None:
raise HTTPException(status_code=400, detail="知识点名称已存在")
@router.get("/graph", response_model=KnowledgeGraphOut)
def get_graph(
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
nodes = db.query(Knowledge).order_by(Knowledge.domain, Knowledge.category, Knowledge.id).all()
edges = db.query(KnowledgeRelation).order_by(KnowledgeRelation.id).all()
return KnowledgeGraphOut(
nodes=[_node_out(db, node) for node in nodes],
edges=[
KnowledgeGraphEdge(
source=edge.source_id,
target=edge.target_id,
relation_type=edge.relation_type,
)
for edge in edges
],
)
@router.post("/nodes", response_model=KnowledgeGraphNode)
def create_node(
payload: KnowledgeNodeCreate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
name = payload.name.strip()
_unique_name(db, name)
node = Knowledge(
name=name,
domain=payload.domain.strip() or "初等",
category=payload.category.strip(),
description=payload.description.strip(),
)
db.add(node)
db.commit()
return _node_out(db, node)
@router.put("/nodes/{node_id}", response_model=KnowledgeGraphNode)
def update_node(
node_id: int,
payload: KnowledgeNodeUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
node = db.get(Knowledge, node_id)
if node is None:
raise HTTPException(status_code=404, detail="知识点不存在")
fields = payload.model_dump(exclude_unset=True)
if fields.get("name"):
name = fields["name"].strip()
_unique_name(db, name, exclude_id=node.id)
node.name = name
if fields.get("domain"):
node.domain = fields["domain"].strip()
if "category" in fields:
node.category = (fields["category"] or "").strip()
if "description" in fields:
node.description = (fields["description"] or "").strip()
db.commit()
return _node_out(db, node)
@router.delete("/nodes/{node_id}")
def delete_node(
node_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
node = db.get(Knowledge, node_id)
if node is None:
raise HTTPException(status_code=404, detail="知识点不存在")
usage = (
db.query(func.count(ChapterKnowledge.id))
.filter(ChapterKnowledge.knowledge_id == node.id)
.scalar()
or 0
) + (
db.query(func.count(QuestionKnowledge.id))
.filter(QuestionKnowledge.knowledge_id == node.id)
.scalar()
or 0
)
if usage > 0:
raise HTTPException(
status_code=400,
detail=f"该知识点已关联 {usage} 个章节/题目,不能删除;可改名或先解除引用",
)
db.query(KnowledgeRelation).filter(
(KnowledgeRelation.source_id == node.id)
| (KnowledgeRelation.target_id == node.id)
).delete(synchronize_session=False)
db.query(UserKnowledge).filter(UserKnowledge.knowledge_id == node.id).delete(
synchronize_session=False
)
db.delete(node)
db.commit()
return {"deleted": node_id}
@router.post("/nodes/{node_id}/resources", response_model=KnowledgeResourceOut)
def add_resource(
node_id: int,
payload: KnowledgeResourceIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
node = db.get(Knowledge, node_id)
if node is None:
raise HTTPException(status_code=404, detail="知识点不存在")
kind = payload.kind.strip() or "video"
title = payload.title.strip()
url = payload.url.strip()
if payload.course_id is not None:
course = db.get(Course, payload.course_id)
if course is None:
raise HTTPException(status_code=404, detail="课程不存在")
title = title or course.name
url = url or course.url
elif payload.textbook_id is not None:
textbook = db.get(Textbook, payload.textbook_id)
if textbook is None:
raise HTTPException(status_code=404, detail="教材不存在")
title = title or textbook.name
url = url or textbook.link
if not title or not url:
raise HTTPException(status_code=400, detail="资源标题与链接不能为空")
exists = (
db.query(KnowledgeResource)
.filter(
KnowledgeResource.knowledge_id == node.id,
KnowledgeResource.kind == kind,
KnowledgeResource.url == url,
)
.first()
)
if exists is not None:
raise HTTPException(status_code=400, detail="该资源已关联到本知识点")
row = KnowledgeResource(
knowledge_id=node.id,
kind=kind,
title=title,
url=url,
textbook_id=payload.textbook_id,
course_id=payload.course_id,
)
db.add(row)
db.commit()
return KnowledgeResourceOut(
id=row.id,
kind=row.kind,
title=row.title,
url=row.url,
source_name=row.title,
)
@router.delete("/resources/{resource_id}")
def remove_resource(
resource_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
row = db.get(KnowledgeResource, resource_id)
if row is None:
raise HTTPException(status_code=404, detail="资源链接不存在")
db.delete(row)
db.commit()
return {"deleted": resource_id}
@router.post("/relations", response_model=KnowledgeGraphEdge)
def add_relation(
payload: KnowledgeRelationIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
if payload.relation_type not in RELATION_TYPES:
raise HTTPException(status_code=400, detail="关系类型仅支持 包含/前置/相关")
if payload.source_id == payload.target_id:
raise HTTPException(status_code=400, detail="不能连接到自身")
source = db.get(Knowledge, payload.source_id)
target = db.get(Knowledge, payload.target_id)
if source is None or target is None:
raise HTTPException(status_code=404, detail="知识点不存在")
exists = (
db.query(KnowledgeRelation)
.filter(
KnowledgeRelation.source_id == source.id,
KnowledgeRelation.target_id == target.id,
KnowledgeRelation.relation_type == payload.relation_type,
)
.first()
)
if exists is not None:
raise HTTPException(status_code=400, detail="该关系已存在")
relation = KnowledgeRelation(
source_id=source.id,
target_id=target.id,
relation_type=payload.relation_type,
)
db.add(relation)
db.commit()
return KnowledgeGraphEdge(
source=relation.source_id,
target=relation.target_id,
relation_type=relation.relation_type,
)
@router.delete("/relations")
def remove_relation(
source_id: int,
target_id: int,
relation_type: str = "前置",
db: Session = Depends(get_db),
_: User = Depends(require_permission("knowledge:manage")),
):
relation = (
db.query(KnowledgeRelation)
.filter(
KnowledgeRelation.source_id == source_id,
KnowledgeRelation.target_id == target_id,
KnowledgeRelation.relation_type == relation_type,
)
.first()
)
if relation is None:
raise HTTPException(status_code=404, detail="关系不存在")
db.delete(relation)
db.commit()
return {"deleted": relation.id}

View File

@ -0,0 +1,318 @@
"""大模型多通道配置:OpenAI / DeepSeek / 阿里千问 / OpenAI 兼容。
每个通道保存一份独立的 provider / base_url / model / api_key / temperature,
通道名建议为“服务商 · 模型”,例如“阿里千问(百炼) · qwen-plus”;
生成题目时默认使用 is_default 通道,也可在调用方显式指定 channel_id。
"""
from __future__ import annotations
import json
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_permission
from models import LlmSetting, LlmTask, User
from schemas import (
LlmChannelCreate,
LlmChannelOut,
LlmChannelUpdate,
LlmProviderOut,
LlmSettingsOut,
LlmTestResult,
LlmTaskCreate,
LlmTaskOut,
)
from services.llm_client import PROVIDER_PRESETS, chat_completion, mask_key
from services.llm_config import (
default_channel_name,
ensure_llm_channels,
get_default_channel,
promote_default,
provider_label,
)
from services.task_runner import run_llm_task
router = APIRouter(prefix="/llm", tags=["llm"])
def _providers() -> list[LlmProviderOut]:
return [
LlmProviderOut(
id=provider_id,
label=preset["label"],
base_url=preset["base_url"],
model=preset["model"],
)
for provider_id, preset in PROVIDER_PRESETS.items()
]
def _channel_out(channel: LlmSetting) -> LlmChannelOut:
return LlmChannelOut(
id=channel.id,
name=channel.name or default_channel_name(channel.provider, channel.model),
provider=channel.provider,
provider_label=provider_label(channel.provider),
base_url=channel.base_url,
model=channel.model,
temperature=channel.temperature,
max_tokens=channel.max_tokens,
has_api_key=bool(channel.api_key),
api_key_preview=mask_key(channel.api_key),
is_default=bool(channel.is_default),
)
def _settings_out(db: Session) -> LlmSettingsOut:
channels = (
db.query(LlmSetting)
.order_by(LlmSetting.is_default.desc(), LlmSetting.id.asc())
.all()
)
return LlmSettingsOut(
channels=[_channel_out(channel) for channel in channels],
providers=_providers(),
)
def _load_channel(db: Session, channel_id: int) -> LlmSetting:
channel = db.get(LlmSetting, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="模型通道不存在")
return channel
def _ensure_provider(provider: str) -> dict:
preset = PROVIDER_PRESETS.get(provider)
if preset is None:
raise HTTPException(status_code=400, detail="不支持的模型服务商")
return preset
def _ensure_unique_name(db: Session, name: str, exclude_id: int | None = None) -> None:
query = db.query(LlmSetting).filter(LlmSetting.name == name)
if exclude_id is not None:
query = query.filter(LlmSetting.id != exclude_id)
if query.first() is not None:
raise HTTPException(status_code=400, detail="通道名称已存在,请换一个名称")
def _preset_fill(provider: str, base_url: str, model: str) -> tuple[str, str]:
preset = _ensure_provider(provider)
return (
base_url.strip() or preset["base_url"],
model.strip() or preset["model"],
)
@router.get("/settings", response_model=LlmSettingsOut)
def get_settings(
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
ensure_llm_channels(db)
return _settings_out(db)
@router.post("/channels", response_model=LlmChannelOut)
def create_channel(
payload: LlmChannelCreate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
_ensure_provider(payload.provider)
name = payload.name.strip() or default_channel_name(payload.provider, payload.model)
_ensure_unique_name(db, name)
base_url, model = _preset_fill(payload.provider, payload.base_url, payload.model)
count = db.query(LlmSetting).count()
channel = LlmSetting(
name=name,
provider=payload.provider,
base_url=base_url,
model=model,
temperature=payload.temperature,
max_tokens=payload.max_tokens,
api_key=(payload.api_key or "").strip(),
is_default=count == 0,
)
db.add(channel)
db.commit()
db.refresh(channel)
return _channel_out(channel)
@router.put("/channels/{channel_id}", response_model=LlmChannelOut)
def update_channel(
channel_id: int,
payload: LlmChannelUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
channel = _load_channel(db, channel_id)
provider = payload.provider or channel.provider
_ensure_provider(provider)
base_url, model = _preset_fill(
provider,
payload.base_url if payload.base_url is not None else channel.base_url,
payload.model if payload.model is not None else channel.model,
)
if payload.name is not None and payload.name.strip():
name = payload.name.strip()
else:
name = channel.name or default_channel_name(provider, model)
_ensure_unique_name(db, name, exclude_id=channel.id)
channel.name = name
channel.provider = provider
channel.base_url = base_url
channel.model = model
if payload.temperature is not None:
channel.temperature = payload.temperature
if payload.clear_max_tokens:
channel.max_tokens = None
elif payload.max_tokens is not None:
channel.max_tokens = payload.max_tokens
if payload.clear_api_key:
channel.api_key = ""
elif payload.api_key and payload.api_key.strip():
channel.api_key = payload.api_key.strip()
db.commit()
return _channel_out(channel)
@router.delete("/channels/{channel_id}")
def delete_channel(
channel_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
channel = _load_channel(db, channel_id)
was_default = bool(channel.is_default)
db.delete(channel)
db.flush()
if was_default:
promote_default(db, None)
else:
db.commit()
return {"deleted": channel_id}
@router.put("/channels/{channel_id}/default", response_model=LlmSettingsOut)
def set_default_channel(
channel_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
_load_channel(db, channel_id)
promote_default(db, channel_id)
return _settings_out(db)
def _run_test(channel: LlmSetting) -> str:
if not channel.base_url or not channel.model:
raise HTTPException(status_code=400, detail="通道缺少 Base URL 或模型名称,请先补全")
if not channel.api_key:
raise HTTPException(status_code=400, detail="通道尚未保存 API Key,请先配置")
try:
return chat_completion(
base_url=channel.base_url,
api_key=channel.api_key,
model=channel.model,
temperature=0.0,
max_tokens=256,
timeout=45.0,
messages=[
{
"role": "system",
"content": "你是连通性测试助手,只回复两个字:正常",
},
{"role": "user", "content": "请确认服务可用。"},
],
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"测试失败:{exc}") from exc
@router.post("/channels/{channel_id}/test", response_model=LlmTestResult)
def test_channel(
channel_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
channel = _load_channel(db, channel_id)
reply = _run_test(channel)
return LlmTestResult(ok=True, message=f"连接成功,模型已回复:“{reply[:40]}”")
@router.post("/test", response_model=LlmTestResult)
def test_default_channel(
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
"""兼容旧前端:测试默认通道。"""
ensure_llm_channels(db)
channel = get_default_channel(db)
if channel is None:
raise HTTPException(status_code=400, detail="尚未配置任何模型通道")
reply = _run_test(channel)
return LlmTestResult(ok=True, message=f"连接成功,模型已回复:“{reply[:40]}”")
@router.post("/tasks", response_model=LlmTaskOut, status_code=201)
def create_task(
payload: LlmTaskCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
user: User = Depends(require_permission("llm:manage")),
):
if payload.kind not in {
"generate_questions",
"generate_chapters",
"organize_chapters",
}:
raise HTTPException(status_code=400, detail="不支持的任务类型")
task = LlmTask(
kind=payload.kind,
params=json.dumps(payload.params, ensure_ascii=False),
created_by=user.id,
)
db.add(task)
db.commit()
db.refresh(task)
background_tasks.add_task(
run_llm_task, task.id, task.kind, payload.params
)
return LlmTaskOut(
id=task.id,
kind=task.kind,
status=task.status,
progress=task.progress,
message=task.message,
result={},
)
@router.get("/tasks/{task_id}", response_model=LlmTaskOut)
def get_task(
task_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("llm:manage")),
):
task = db.get(LlmTask, task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
try:
result = json.loads(task.result or "{}")
except ValueError:
result = {}
return LlmTaskOut(
id=task.id,
kind=task.kind,
status=task.status,
progress=task.progress,
message=task.message,
result=result,
)

View File

@ -0,0 +1,70 @@
"""学习总览。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_learning_user
from models import AttemptSession, User
from schemas import KnowledgeOut, MetricItem, OverviewOut, PlanOut
from services.mastery_engine import snapshot, status_for
from services.recommendation import build_plan
router = APIRouter(tags=["overview"])
@router.get("/overview", response_model=OverviewOut)
def get_overview(
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
total_questions = (
db.query(AttemptSession.total)
.filter(AttemptSession.user_id == user.id)
.all()
)
total_answers = sum(row.total for row in total_questions)
total_correct = (
db.query(AttemptSession.correct)
.filter(AttemptSession.user_id == user.id)
.all()
)
correct_answers = sum(row.correct for row in total_correct)
accuracy = correct_answers / total_answers * 100 if total_answers else 0.0
knowledge = snapshot(db, user.id)
weak_count = sum(1 for k in knowledge if k.mastery < 60)
metrics = [
MetricItem(label="累计题目", value=str(total_answers)),
MetricItem(
label="累计正确率",
value=f"{accuracy:.1f}%" if total_answers else "0%",
),
MetricItem(label="薄弱知识点", value=str(weak_count)),
MetricItem(label="当前阶段", value="L2 基础应用"),
]
plan = build_plan(db, user.id)
return OverviewOut(
metrics=metrics,
knowledge=[
KnowledgeOut(
id=k.id,
knowledge_id=k.knowledge_id,
name=k.name,
mastery=k.mastery,
position=k.position,
status=status_for(k.mastery),
domain=k.domain,
category=k.category,
)
for k in knowledge
],
plan=PlanOut(
notice=plan["notice"],
focus_names=plan["focus_names"],
steps=plan["steps"],
),
)

View File

@ -0,0 +1,217 @@
"""练习:章节组卷与提交判分(学习数据按用户隔离)。"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_learning_user
from models import (
AttemptItem,
AttemptSession,
Chapter,
ErrorEntry,
Knowledge,
Question,
Textbook,
UserKnowledge,
User,
)
from schemas import (
AnswerIn,
KnowledgeOut,
PerQuestionOut,
PracticeSessionOut,
QuestionOut,
SubmitIn,
SubmitResultOut,
)
from services.ai_teacher import explain as teacher_explain
from services.mastery_engine import apply_answers, snapshot, status_for
from services.question_engine import chapter_question_ids, load_questions
from services.knowledge_service import question_knowledge_names
router = APIRouter(prefix="/practice", tags=["practice"])
def _to_question_out(q: Question) -> QuestionOut:
return QuestionOut(
id=q.id,
stem=q.stem,
options=json.loads(q.options),
knowledge_name=q.knowledge_name,
)
@router.get("/chapter/{chapter_id}", response_model=PracticeSessionOut)
def chapter_session(
chapter_id: int,
limit: int = 5,
variant: int = 0,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
chapter = db.get(Chapter, chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
textbook = db.get(Textbook, chapter.textbook_id)
question_ids = chapter_question_ids(
db, user.id, chapter.id, limit=max(1, min(limit, 10)), variant=variant
)
questions = load_questions(db, question_ids)
return PracticeSessionOut(
mode="chapter",
title=f"{textbook.name if textbook else '教材'} · {chapter.name}",
chapter_id=chapter.id,
questions=[_to_question_out(q) for q in questions],
)
@router.post("/submit", response_model=SubmitResultOut)
def submit_answers(
payload: SubmitIn,
db: Session = Depends(get_db),
user: User = Depends(require_learning_user),
):
mode = payload.mode
if mode != "chapter":
raise HTTPException(status_code=400, detail="未知的练习模式")
seen: set[int] = set()
for answer in payload.answers:
if answer.question_id in seen:
raise HTTPException(status_code=400, detail="同一道题提交了多次")
seen.add(answer.question_id)
questions = load_questions(db, list(seen))
by_id = {q.id: q for q in questions}
if len(by_id) != len(seen):
raise HTTPException(status_code=400, detail="部分题目不存在")
knowledge_map = {
knowledge.name: user_knowledge
for user_knowledge, knowledge in (
db.query(UserKnowledge, Knowledge)
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
.filter(UserKnowledge.user_id == user.id)
.all()
)
}
if payload.chapter_id is None:
raise HTTPException(status_code=400, detail="章节测试需要指定 chapter_id")
chapter = db.get(Chapter, payload.chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
textbook = db.get(Textbook, chapter.textbook_id)
title = f"{textbook.name if textbook else '教材'} · {chapter.name}"
if any(q.chapter_id != chapter.id for q in questions):
raise HTTPException(
status_code=400,
detail="提交的题目不属于所选章节,请重新开始测试",
)
score = 0
answered_mastery: list[tuple[str, bool]] = []
per_question: list[PerQuestionOut] = []
attempt = AttemptSession(
user_id=user.id,
mode=mode,
title=title,
total=len(payload.answers),
correct=0,
chapter_id=chapter.id,
)
db.add(attempt)
db.flush()
for index, answer in enumerate(payload.answers, start=1):
q = by_id[answer.question_id]
correct = answer.selected == q.correct_index
if correct:
score += 1
tags = question_knowledge_names(db, q)
for tag in tags:
answered_mastery.append((tag, correct))
teacher_text = teacher_explain(
q,
answer.selected,
correct,
knowledge_map.get(q.knowledge_name),
)
q.used_count = (q.used_count or 0) + 1
db.add(
AttemptItem(
attempt_id=attempt.id,
user_id=user.id,
question_index=index,
question_id=q.id,
stem=q.stem,
options=q.options,
correct_index=q.correct_index,
selected=answer.selected,
correct=correct,
knowledge_name=tags[0] if tags else q.knowledge_name,
knowledge_names=json.dumps(tags or [q.knowledge_name], ensure_ascii=False),
explanation=teacher_text,
)
)
if not correct:
diagnosis = q.explanation or "需要复习该知识点"
db.add(
ErrorEntry(
user_id=user.id,
attempt_id=attempt.id,
source_label=f"{title} · {index}",
knowledge_name=tags[0] if tags else q.knowledge_name,
knowledge_names=json.dumps(
tags or [q.knowledge_name], ensure_ascii=False
),
diagnosis=diagnosis,
question_id=q.id,
stem=q.stem,
options=q.options,
selected=answer.selected,
correct_index=q.correct_index,
explanation=teacher_text,
)
)
per_question.append(
PerQuestionOut(
question_id=q.id,
correct=correct,
selected=answer.selected,
correct_index=q.correct_index,
explanation=teacher_text,
)
)
apply_answers(db, user.id, mode, answered_mastery)
attempt.correct = score
db.commit()
accuracy = score / len(payload.answers) * 100 if payload.answers else 0.0
return SubmitResultOut(
mode=mode,
title=title,
total=len(payload.answers),
score=score,
accuracy=round(accuracy, 1),
per_question=per_question,
knowledge=[
KnowledgeOut(
id=k.id,
knowledge_id=k.knowledge_id,
name=k.name,
mastery=k.mastery,
position=k.position,
status=status_for(k.mastery),
domain=k.domain,
category=k.category,
)
for k in snapshot(db, user.id)
],
)

View File

@ -0,0 +1,290 @@
"""题库管理:题目列表、删除、按章节生成新题。"""
from __future__ import annotations
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from database import REPO_ROOT
from dependencies import require_permission
from models import (
Chapter,
Knowledge,
LlmSetting,
Question,
QuestionKnowledge,
Textbook,
User,
)
from schemas import GenerateIn, QuestionBankItemOut, QuestionUpdate
from services.question_generator import generate_questions
from services.llm_config import get_default_channel
from services.knowledge_service import (
question_knowledge_names,
set_question_knowledge_names,
)
router = APIRouter(prefix="/question-bank", tags=["question-bank"])
IMAGE_UPLOAD_DIR = REPO_ROOT / "data" / "question_images"
def _store_generated_svg(svg: str) -> str:
"""把模型生成的 SVG 存成题目独立图片,返回可访问的相对 URL。"""
import re
clean = re.sub(r"<script[\s\S]*?</script>", "", svg, flags=re.I)
clean = re.sub(r"\son\w+=[\"'][^\"']*[\"']", "", clean, flags=re.I)
IMAGE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
filename = f"generated_{uuid.uuid4().hex}.svg"
(IMAGE_UPLOAD_DIR / filename).write_text(clean, encoding="utf-8")
return f"/api/question-images/{filename}"
def _resolve_selected_knowledge(
db: Session,
knowledge_ids: list[int],
knowledge_names: list[str],
fallback: str,
) -> list[str]:
if knowledge_ids:
rows = (
db.query(Knowledge)
.filter(Knowledge.id.in_(knowledge_ids))
.all()
)
by_id = {row.id: row for row in rows}
missing = [kid for kid in knowledge_ids if kid not in by_id]
if missing:
raise HTTPException(
status_code=400, detail="部分所选知识点不存在,请刷新知识图谱后重试"
)
return [by_id[kid].name for kid in knowledge_ids]
names = [
name.strip() for name in knowledge_names if name.strip()
]
if names:
return names
return [fallback.strip()] if fallback.strip() else []
def _to_item(
db: Session,
question: Question,
chapter: Chapter | None,
textbook: Textbook | None,
):
knowledge_rows = (
db.query(Knowledge.id, Knowledge.name)
.join(
QuestionKnowledge,
QuestionKnowledge.knowledge_id == Knowledge.id,
)
.filter(QuestionKnowledge.question_id == question.id)
.order_by(QuestionKnowledge.id.asc())
.all()
)
if not knowledge_rows and question.knowledge_name:
knowledge_rows = [
(0, question.knowledge_name),
]
knowledge_names = [name for _, name in knowledge_rows]
return QuestionBankItemOut(
id=question.id,
chapter_id=question.chapter_id,
chapter_name=chapter.name if chapter else "",
textbook_name=textbook.name if textbook else "",
knowledge_name=question.knowledge_name,
stem=question.stem,
options=json.loads(question.options),
correct_index=question.correct_index,
explanation=question.explanation,
difficulty=question.difficulty,
is_generated=bool(question.is_generated),
used_count=question.used_count or 0,
knowledge_names=[str(name) for name in knowledge_names],
knowledge_ids=[kid for kid, _ in knowledge_rows if kid],
)
def _query(db: Session):
return (
db.query(Question, Chapter, Textbook)
.outerjoin(Chapter, Question.chapter_id == Chapter.id)
.outerjoin(Textbook, Chapter.textbook_id == Textbook.id)
)
@router.get("/questions", response_model=list[QuestionBankItemOut])
def list_questions(
textbook_id: int | None = None,
chapter_id: int | None = None,
knowledge_name: str = "",
generated: bool | None = None,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
query = _query(db)
if textbook_id:
query = query.filter(Textbook.id == textbook_id)
if chapter_id:
query = query.filter(Question.chapter_id == chapter_id)
if knowledge_name:
query = query.filter(Question.knowledge_name == knowledge_name)
if generated is not None:
query = query.filter(Question.is_generated == generated)
rows = query.order_by(Question.id.desc()).all()
return [_to_item(db, question, chapter, textbook) for question, chapter, textbook in rows]
@router.delete("/questions/{question_id}")
def delete_question(
question_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
question = db.get(Question, question_id)
if question is None:
raise HTTPException(status_code=404, detail="题目不存在")
used_count = question.used_count or 0
if used_count > 0:
raise HTTPException(
status_code=400,
detail=f"该题目已被使用 {used_count} 次,不能删除;只能编辑题目内容",
)
db.delete(question)
db.commit()
return {"deleted": question_id}
@router.put("/questions/{question_id}", response_model=QuestionBankItemOut)
def update_question(
question_id: int,
payload: QuestionUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
question = db.get(Question, question_id)
if question is None:
raise HTTPException(status_code=404, detail="题目不存在")
if payload.chapter_id is not None:
chapter = db.get(Chapter, payload.chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
question.chapter_id = chapter.id
else:
question.chapter_id = None
options = [option.strip() for option in payload.options]
if any(not option for option in options) or len(set(options)) != 4:
raise HTTPException(status_code=400, detail="选项必须是 4 个非空且不重复的内容")
question.stem = payload.stem.strip()
question.options = json.dumps(options, ensure_ascii=False)
question.correct_index = payload.correct_index
question.explanation = payload.explanation.strip()
chosen = _resolve_selected_knowledge(
db, payload.knowledge_ids, payload.knowledge_names, payload.knowledge_name
)
if not chosen:
raise HTTPException(status_code=400, detail="请至少选择一个知识点")
question.knowledge_name = chosen[0]
set_question_knowledge_names(
db, question, chosen, attach_to_chapter=True
)
question.difficulty = payload.difficulty
db.commit()
chapter = (
db.query(Chapter).filter(Chapter.id == question.chapter_id).first()
if question.chapter_id is not None
else None
)
textbook = (
db.query(Textbook).filter(Textbook.id == chapter.textbook_id).first()
if chapter is not None
else None
)
return _to_item(db, question, chapter, textbook)
@router.post("/generate", response_model=list[QuestionBankItemOut])
def generate(
payload: GenerateIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("question-bank:manage")),
):
chapter = db.get(Chapter, payload.chapter_id)
if chapter is None:
raise HTTPException(status_code=404, detail="章节不存在")
textbook = db.get(Textbook, chapter.textbook_id)
if textbook is None:
raise HTTPException(status_code=404, detail="教材/课程不存在")
if payload.channel_id is not None:
setting = db.get(LlmSetting, payload.channel_id)
if setting is None:
raise HTTPException(status_code=404, detail="指定的模型通道不存在")
else:
setting = get_default_channel(db)
if setting is None:
raise HTTPException(
status_code=400,
detail="尚未配置模型通道,请先到“模型配置”新增并设为默认",
)
try:
raw_items = generate_questions(
db=db,
setting=setting,
chapter=chapter,
textbook=textbook,
count=payload.count,
difficulty=payload.difficulty,
knowledge_name=(
"、".join(
name.strip()
for name in payload.knowledge_names
if name.strip()
)
or payload.knowledge_name.strip()
),
instructions=payload.instructions.strip(),
need_figure=payload.need_figure,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
inserted: list[Question] = []
for item in raw_items:
chosen = _resolve_selected_knowledge(
db,
payload.knowledge_ids,
payload.knowledge_names,
item["knowledge_name"],
)
if not chosen:
chosen = [item["knowledge_name"]]
stem = item["stem"]
if payload.need_figure and item.get("image_svg"):
image_url = _store_generated_svg(item["image_svg"])
stem = f"{stem}\n\n![AI 配图]({image_url})"
question = Question(
stem=stem,
options=json.dumps(item["options"], ensure_ascii=False),
correct_index=item["correct_index"],
knowledge_name=chosen[0],
explanation=item["explanation"],
chapter_id=chapter.id,
difficulty=item["difficulty"],
is_generated=True,
)
db.add(question)
inserted.append(question)
db.flush()
for question in inserted:
set_question_knowledge_names(db, question, chosen, attach_to_chapter=True)
result = [_to_item(db, question, chapter, textbook) for question in inserted]
db.commit()
return result

View File

@ -0,0 +1,144 @@
"""管理员用户管理。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from dependencies import require_permission
from models import Role, User, UserRole
from routers.auth import user_to_out
from schemas import (
ResetPasswordIn,
RoleOut,
UserCreate,
UserOut,
UserUpdate,
)
from seed.rbac import ROLES
from services.learning_profile import (
ensure_profile,
remove_unused_admin_profile,
reset_all_learning,
)
from services.security import hash_password
router = APIRouter(prefix="/users", tags=["users"], dependencies=[])
@router.post("/reset-learning")
def reset_learning(
db: Session = Depends(get_db),
_: User = Depends(require_permission("users:manage")),
):
"""清空所有用户的学习过程数据(保留用户/教材/题库/知识点图谱)。"""
reset_all_learning(db)
return {"ok": True, "message": "所有学习过程数据已清空,可从新的章节任务重新开始"}
def _role(db: Session, code: str) -> Role:
role = db.query(Role).filter(Role.code == code).first()
if role is None:
raise HTTPException(status_code=400, detail="角色不存在")
return role
@router.get("", response_model=list[UserOut])
def list_users(
role_code: str = "",
db: Session = Depends(get_db),
_: User = Depends(require_permission("users:manage")),
):
query = db.query(User)
if role_code:
query = (
query.join(UserRole, UserRole.user_id == User.id)
.join(Role, Role.id == UserRole.role_id)
.filter(Role.code == role_code)
)
users = query.order_by(User.id.asc()).all()
return [user_to_out(db, user) for user in users]
@router.get("/roles", response_model=list[RoleOut])
def list_roles(
db: Session = Depends(get_db),
_: User = Depends(require_permission("users:manage")),
):
return [RoleOut.model_validate(role) for role in db.query(Role).order_by(Role.id).all()]
@router.post("", response_model=UserOut)
def create_user(
payload: UserCreate,
db: Session = Depends(get_db),
_: User = Depends(require_permission("users:manage")),
):
if payload.role_code not in ROLES:
raise HTTPException(status_code=400, detail="不支持的角色")
if db.query(User).filter(User.username == payload.username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
role = _role(db, payload.role_code)
user = User(
username=payload.username,
display_name=payload.display_name or payload.username,
password_hash=hash_password(payload.password),
active=True,
)
db.add(user)
db.flush()
db.add(UserRole(user_id=user.id, role_id=role.id))
db.commit()
if role.code != "admin":
ensure_profile(db, user.id)
else:
remove_unused_admin_profile(db, user.id)
db.refresh(user)
return user_to_out(db, user)
@router.put("/{user_id}", response_model=UserOut)
def update_user(
user_id: int,
payload: UserUpdate,
db: Session = Depends(get_db),
operator: User = Depends(require_permission("users:manage")),
):
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
if user.id == operator.id and payload.active is False:
raise HTTPException(status_code=400, detail="不能停用自己的账号")
if payload.display_name is not None:
user.display_name = payload.display_name
if payload.active is not None:
user.active = payload.active
if payload.role_code is not None:
role = _role(db, payload.role_code)
if user.id == operator.id and role.code != "admin":
raise HTTPException(status_code=400, detail="不能移除自己的管理员角色")
db.query(UserRole).filter(UserRole.user_id == user.id).delete()
db.add(UserRole(user_id=user.id, role_id=role.id))
if role.code == "admin":
remove_unused_admin_profile(db, user.id)
else:
ensure_profile(db, user.id)
db.commit()
return user_to_out(db, user)
@router.post("/{user_id}/reset-password")
def reset_password(
user_id: int,
payload: ResetPasswordIn,
db: Session = Depends(get_db),
_: User = Depends(require_permission("users:manage")),
):
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="用户不存在")
user.password_hash = hash_password(payload.password)
db.commit()
return {"ok": True, "message": f"已重置 {user.username} 的密码。"}

View File

@ -0,0 +1,561 @@
"""API 请求 / 响应模型。"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class KnowledgeOut(ORMModel):
id: int
knowledge_id: int
name: str
mastery: int
position: int
status: str = ""
domain: str = ""
category: str = ""
class KnowledgeResourceOut(BaseModel):
id: int
kind: str
title: str
url: str
source_name: str = ""
class KnowledgeRelatedOut(BaseModel):
id: int
name: str
relation_type: str
direction: str = "out" # out: 当前节点指向对方;in: 对方指向当前节点
mastery: int | None = None
class KnowledgeChapterOut(BaseModel):
chapter_id: int
chapter_name: str
textbook_id: int
textbook_name: str
question_count: int = 0
# 电子书定位:has_ebook 为假时前端不显示「读教材」入口
ebook_page: int = 0
has_ebook: bool = False
class KnowledgeDetailOut(BaseModel):
id: int
name: str
domain: str
category: str
description: str
mastery: int
status: str
related_knowledge_count: int
chapter_count: int
question_count: int
related: list[KnowledgeRelatedOut] = []
chapters: list[KnowledgeChapterOut] = []
resources: list[KnowledgeResourceOut] = []
class KnowledgeResourceIn(BaseModel):
kind: str = "video"
title: str = ""
url: str = ""
textbook_id: int | None = None
course_id: int | None = None
class QuestionOut(BaseModel):
id: int
stem: str
options: list[str]
knowledge_name: str
class PracticeSessionOut(BaseModel):
mode: str
title: str
chapter_id: int | None = None
questions: list[QuestionOut]
class AnswerIn(BaseModel):
question_id: int
selected: int
class SubmitIn(BaseModel):
mode: str
chapter_id: int | None = None
answers: list[AnswerIn] = Field(min_length=1)
class PerQuestionOut(BaseModel):
question_id: int
correct: bool
selected: int
correct_index: int
explanation: str
class SubmitResultOut(BaseModel):
mode: str
title: str
total: int
score: int
accuracy: float
per_question: list[PerQuestionOut]
knowledge: list[KnowledgeOut]
class ErrorOut(BaseModel):
id: int
source_label: str
knowledge_name: str
diagnosis: str
status: str
created_on: date
attempt_id: int | None = None
question_id: int | None = None
stem: str = ""
options: list[str] = []
selected: int | None = None
correct_index: int | None = None
explanation: str = ""
knowledge_names: list[str] = []
class RecordOut(BaseModel):
id: int
mode: str
title: str
total: int
correct: int
happened_on: date
class AttemptItemOut(BaseModel):
question_index: int
question_id: int | None
stem: str
options: list[str]
selected: int | None
correct_index: int
correct: bool
knowledge_name: str
explanation: str
knowledge_names: list[str] = []
class RecordDetailOut(RecordOut):
items: list[AttemptItemOut] = []
class DailyOut(BaseModel):
today: date
completed: bool
streak: int
chapter_id: int | None = None
chapter_name: str = ""
book_name: str = ""
# 今日章节对应的电子书定位,没有电子书时 has_ebook 为假
textbook_id: int | None = None
ebook_page: int = 0
has_ebook: bool = False
rating: str = ""
knowledge_names: list[str] = []
reason: str = ""
class DailyCompleteIn(BaseModel):
chapter_id: int
rating: str = ""
class MetricItem(BaseModel):
label: str
value: str
class PlanOut(BaseModel):
notice: str
focus_names: list[str]
steps: list[str]
class OverviewOut(BaseModel):
metrics: list[MetricItem]
knowledge: list[KnowledgeOut]
plan: PlanOut
class DiagnosisOut(BaseModel):
name: str
mastery: int
error_count: int
action: str
class AnalyticsOut(BaseModel):
metrics: list[MetricItem]
diagnosis: list[DiagnosisOut]
class ImportPayload(BaseModel):
version: int = 1
knowledge: list[dict[str, Any]] = []
records: list[Any] = []
errors: list[Any] = []
daily_completions: list[date] = []
class ImportResult(BaseModel):
imported: bool
knowledge: int
records: int
errors: int
items: int = 0
message: str
class ChapterOut(ORMModel):
id: int
textbook_id: int
name: str
position: int
summary: str = ""
ebook_page: int = 0
question_count: int = 0
knowledge_names: list[str] = []
knowledge_ids: list[int] = []
class EbookProgressOut(ORMModel):
textbook_id: int
locator: str = ""
page: int = 0
total: int = 0
position: float = 0.0
updated_at: datetime | None = None
class EbookProgressIn(BaseModel):
locator: str = Field(default="", max_length=512)
page: int = 0
total: int = 0
position: float = 0.0
class TextbookOut(ORMModel):
id: int
name: str
author: str
publisher: str
isbn: str
edition: str
grade: str
link: str
description: str
position: int
chapters: list[ChapterOut] = []
# 在线阅读:文件本身走 /api/textbooks/{id}/ebook/file/{file}
ebook_file: str = ""
ebook_format: str = ""
ebook_name: str = ""
ebook_size: int = 0
ebook_pages: int = 0
ebook_uploaded_at: datetime | None = None
ebook_progress: EbookProgressOut | None = None
class CourseOut(ORMModel):
id: int
name: str
provider: str
url: str
grade: str
description: str
position: int
class QuestionBankItemOut(BaseModel):
id: int
chapter_id: int | None
chapter_name: str = ""
textbook_name: str = ""
knowledge_name: str
stem: str
options: list[str]
correct_index: int
explanation: str
difficulty: int
is_generated: bool
used_count: int
knowledge_names: list[str] = []
knowledge_ids: list[int] = []
class QuestionUpdate(BaseModel):
stem: str = Field(min_length=1)
options: list[str] = Field(min_length=4, max_length=4)
correct_index: int = Field(ge=0, le=3)
explanation: str = ""
knowledge_name: str = Field(min_length=1, max_length=64)
difficulty: int = Field(default=1, ge=1, le=3)
chapter_id: int | None = None
knowledge_names: list[str] = []
knowledge_ids: list[int] = []
class GenerateIn(BaseModel):
chapter_id: int
count: int = Field(default=3, ge=1, le=10)
difficulty: int = Field(default=2, ge=1, le=3)
knowledge_name: str = ""
instructions: str = ""
channel_id: int | None = None
knowledge_names: list[str] = []
knowledge_ids: list[int] = []
need_figure: bool = False
class LlmProviderOut(BaseModel):
id: str
label: str
base_url: str
model: str
class LlmChannelOut(BaseModel):
id: int
name: str
provider: str
provider_label: str
base_url: str
model: str
temperature: float
max_tokens: int | None = None
has_api_key: bool
api_key_preview: str
is_default: bool
class LlmSettingsOut(BaseModel):
channels: list[LlmChannelOut] = []
providers: list[LlmProviderOut]
class LlmChannelCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
provider: str = "openai"
base_url: str = ""
model: str = ""
temperature: float = Field(default=0.3, ge=0.0, le=2.0)
max_tokens: int | None = Field(default=None, ge=64, le=12000)
api_key: str | None = None
class LlmChannelUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
provider: str | None = None
base_url: str | None = None
model: str | None = None
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
max_tokens: int | None = Field(default=None, ge=64, le=12000)
clear_max_tokens: bool = False
api_key: str | None = None
clear_api_key: bool = False
class LlmTestResult(BaseModel):
ok: bool
message: str
class LlmTaskCreate(BaseModel):
kind: str
params: dict[str, Any] = {}
class LlmTaskOut(BaseModel):
id: int
kind: str
status: str
progress: int
message: str
result: dict[str, Any] = {}
class LoginIn(BaseModel):
username: str
password: str
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
role: str
display_name: str
class ChangePasswordIn(BaseModel):
old_password: str
new_password: str = Field(min_length=6, max_length=128)
class RoleOut(ORMModel):
id: int
code: str
name: str
description: str
class UserOut(BaseModel):
id: int
username: str
display_name: str
active: bool
roles: list[RoleOut] = []
created_at: datetime | None = None
class UserCreate(BaseModel):
username: str = Field(min_length=2, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
display_name: str = Field(default="", max_length=64)
password: str = Field(min_length=6, max_length=128)
role_code: str = "student"
class UserUpdate(BaseModel):
display_name: str | None = Field(default=None, max_length=64)
active: bool | None = None
role_code: str | None = None
class ResetPasswordIn(BaseModel):
password: str = Field(min_length=6, max_length=128)
class TextbookCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
author: str = ""
publisher: str = ""
isbn: str = ""
edition: str = ""
grade: str = ""
link: str = ""
description: str = ""
class TextbookUpdate(BaseModel):
name: str | None = Field(default=None, max_length=128)
author: str | None = None
publisher: str | None = None
isbn: str | None = None
edition: str | None = None
grade: str | None = None
link: str | None = None
description: str | None = None
class CourseCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
provider: str = ""
url: str = ""
grade: str = ""
description: str = ""
class CourseUpdate(BaseModel):
name: str | None = Field(default=None, max_length=128)
provider: str | None = None
url: str | None = None
grade: str | None = None
description: str | None = None
class ChapterCreate(BaseModel):
name: str = Field(min_length=1, max_length=128)
summary: str = ""
knowledge_names: list[str] = []
knowledge_ids: list[int] = []
class ChapterUpdate(BaseModel):
name: str | None = Field(default=None, max_length=128)
summary: str | None = None
ebook_page: int | None = None
knowledge_names: list[str] | None = None
knowledge_ids: list[int] | None = None
class ChaptersGenerateIn(BaseModel):
count: int | None = Field(default=None, ge=1, le=12)
channel_id: int | None = None
instructions: str = ""
knowledge_ids: list[int] = []
class ChaptersGenerateOut(BaseModel):
created: list[ChapterOut] = []
skipped_duplicates: list[str] = []
message: str = ""
class ChaptersOrganizeIn(BaseModel):
reference_text: str = Field(min_length=1)
channel_id: int | None = None
class ProfileUpdate(BaseModel):
display_name: str = Field(min_length=1, max_length=64)
class KnowledgeGraphNode(BaseModel):
id: int
name: str
domain: str
category: str
description: str
chapter_count: int = 0
question_count: int = 0
resources: list[KnowledgeResourceOut] = []
class KnowledgeGraphEdge(BaseModel):
source: int
target: int
relation_type: str
class KnowledgeGraphOut(BaseModel):
nodes: list[KnowledgeGraphNode] = []
edges: list[KnowledgeGraphEdge] = []
class KnowledgeNodeCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
domain: str = "初等"
category: str = ""
description: str = ""
class KnowledgeNodeUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
domain: str | None = None
category: str | None = None
description: str | None = None
class KnowledgeRelationIn(BaseModel):
source_id: int
target_id: int
relation_type: str = "前置"

View File

@ -0,0 +1 @@
"""初始化数据:知识点、题库、示例练习记录。"""

View File

@ -0,0 +1,180 @@
"""初始化数据库:RBAC 账号、学习档案、题库与教材目录。
用法:在 backend/ 目录下执行 python -m seed [--force]
--force 会清空全部学习数据与题库后重新导入,保留用户与角色。
"""
from __future__ import annotations
import argparse
import json
from datetime import date, timedelta
from database import DATABASE_URL, SessionLocal, ensure_schema
from models import (
AttemptItem,
AttemptSession,
DailyCompletion,
ErrorEntry,
Question,
User,
UserKnowledge,
)
from seed.catalog import ensure_catalog
from seed.data import KNOWLEDGE, QUESTIONS
from seed.rbac import DEFAULT_USERS, ensure_rbac
from services.learning_profile import (
ensure_default_profiles,
remove_unused_admin_profile,
user_has_admin_role,
)
from services.llm_config import ensure_llm_channels
from services.knowledge_service import set_question_knowledge_names
def _insert_questions(db) -> None:
for item in QUESTIONS:
db.add(
Question(
stem=item["stem"],
options=json.dumps(item["options"], ensure_ascii=False),
correct_index=item["correct_index"],
knowledge_name=item["knowledge_name"],
explanation=item["explanation"],
)
)
def _demo_history(db, student: User) -> None:
questions = db.query(Question).order_by(Question.id.asc()).all()
configs = [
# (标题, 题目, 答错题号集合, 几天前)
("第一轮基础题", questions[:10], {2, 4, 7}, 3),
("第二轮综合题", questions[10:18], {1, 3, 5}, 1),
]
for title, questions, wrong_indexes, days_ago in configs:
if not questions:
continue
correct_count = len(questions) - len(wrong_indexes)
session = AttemptSession(
user_id=student.id,
mode="legacy",
title=title,
total=len(questions),
correct=correct_count,
happened_on=date.today() - timedelta(days=days_ago),
)
db.add(session)
db.flush()
for index, question in enumerate(questions, start=1):
is_correct = index not in wrong_indexes
selected = (
question.correct_index
if is_correct
else (question.correct_index + 1) % 4
)
teacher_text = question.explanation or "结合定义与图像性质重新判断。"
question.used_count = (question.used_count or 0) + 1
db.add(
AttemptItem(
attempt_id=session.id,
user_id=student.id,
question_index=index,
question_id=question.id,
stem=question.stem,
options=question.options,
correct_index=question.correct_index,
selected=selected,
correct=is_correct,
knowledge_name=question.knowledge_name,
knowledge_names=json.dumps(
[question.knowledge_name], ensure_ascii=False
),
explanation=teacher_text,
)
)
if not is_correct:
db.add(
ErrorEntry(
user_id=student.id,
attempt_id=session.id,
source_label=f"{title} · {index}",
knowledge_name=question.knowledge_name,
knowledge_names=json.dumps(
[question.knowledge_name], ensure_ascii=False
),
diagnosis="答题错误,需要复习",
question_id=question.id,
stem=question.stem,
options=question.options,
selected=selected,
correct_index=question.correct_index,
explanation=teacher_text,
)
)
# 保持每日任务演示数据(昨天已完成)
db.add(DailyCompletion(user_id=student.id, done_on=date.today() - timedelta(days=1)))
def load_seed(force: bool = False) -> bool:
ensure_schema()
db = SessionLocal()
created = False
try:
ensure_rbac(db)
users = {
username: db.query(User).filter(User.username == username).first()
for username, _, _, _ in DEFAULT_USERS
}
ensure_default_profiles(db)
ensure_llm_channels(db)
if force:
db.query(AttemptItem).delete()
db.query(DailyCompletion).delete()
db.query(ErrorEntry).delete()
db.query(AttemptSession).delete()
db.query(UserKnowledge).delete()
db.query(Question).delete()
ensure_default_profiles(db)
_insert_questions(db)
db.flush()
for question in db.query(Question).all():
set_question_knowledge_names(
db, question, [question.knowledge_name]
)
_demo_history(db, users["student"])
created = True
elif db.query(Question).count() == 0:
_insert_questions(db)
db.flush()
for question in db.query(Question).all():
set_question_knowledge_names(
db, question, [question.knowledge_name]
)
_demo_history(db, users["student"])
created = True
db.commit()
ensure_catalog(db, force_sync=force)
for user in users.values():
if user and user_has_admin_role(db, user.id):
remove_unused_admin_profile(db, user.id)
return created
finally:
db.close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="初始化数学学习系统数据库")
parser.add_argument("--force", action="store_true", help="清空学习数据后重新导入种子")
args = parser.parse_args()
created = load_seed(force=args.force)
print(f"数据库:{DATABASE_URL}")
if created:
total = len(QUESTIONS)
print(
"已完成初始化:默认账号 admin/admin123、student/student123,"
f"{total} 道题目。"
)
else:
print("已存在数据,跳过导入(重置可运行:python -m seed --force)。")

View File

@ -0,0 +1,389 @@
"""教材与在线课程种子数据(两张表分别写入)。
教材以《盖尔范德中学生数学思维丛书》(中国科学技术大学出版社)为基础;
书号 / 出版信息均核对公开书目信息,课程使用官方站点链接。
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from models import Chapter, ChapterKnowledge, Course, Knowledge, Question, Textbook
from services.knowledge_service import (
ensure_knowledge_resource,
set_chapter_knowledge_names,
)
# 现有题库知识点与《函数和图像》章节的对应关系(按丛书内容就近归类)
KNOWLEDGE_CHAPTER_MAP = {
"函数概念": "引言",
"函数值": "引言",
"定义域": "引言",
"正负性": "引言",
"零点": "第4章 二次三项式",
"偶函数": "第6章 幂函数",
"奇函数": "第6章 幂函数",
"图像对称性": "第6章 幂函数",
}
DEFAULT_CHAPTER_KNOWLEDGE = {
"引言": ["函数概念", "函数值", "定义域", "正负性"],
"第4章 二次三项式": ["零点"],
"第6章 幂函数": ["偶函数", "奇函数", "图像对称性"],
"第1章 三角函数": ["三角函数"],
"第2章 三角形中的三角比": ["三角比", "直角三角形"],
"第3章 三角比之间的关系": ["三角恒等式", "同角关系"],
"第4章 三角形内的关系": ["正弦定理", "余弦定理"],
"第5章 角与旋转": ["任意角", "弧度制"],
"第6章 弧度法": ["弧度制", "弧长与扇形"],
"第7章 加法公式": ["和角公式", "差角公式"],
"第8章 三角恒等式": ["倍角公式", "三角恒等式"],
"第9章 三角函数的图形": ["三角函数图像", "周期函数"],
"第10章 反函数与三角方程": ["反三角函数", "三角方程"],
"点与直线:走进射影几何": ["点线面的基本关系", "几何变换"],
"平行线:走进仿射几何": ["平行与垂直"],
"面积:走进辛几何": ["面积与周长"],
"圆:走进欧氏几何": ["圆"],
"绪论": ["数轴", "平面直角坐标系"],
"第1章 直线上点的坐标": ["数轴", "实数", "两点间距离"],
"第2章 平面里点的坐标": [
"平面直角坐标系",
"两点间距离",
"曲线与方程",
],
"第3章 三维空间里点的坐标": ["平面直角坐标系", "点线面的基本关系"],
"第4章 引言": ["平面直角坐标系", "曲线与方程"],
"第5章 四维空间": ["平面直角坐标系", "曲线与方程"],
"第6章 四维方体": ["平面直角坐标系", "曲线与方程"],
}
# 在线课程 / 视频与知识点的默认关联(知识点 → 课程资源反向链接)。
COURSE_KNOWLEDGE_MAP = {
"Khan Academy · Algebra 1: Functions": [
"函数与图像",
"函数概念",
"函数值",
"定义域",
"值域",
"零点",
"单调性",
"奇偶性",
"偶函数",
"奇函数",
"图像对称性",
"一次函数",
"函数图像与变换",
],
"3Blue1Brown · Essence of Calculus": [
"极限与连续",
"导数与微分",
"一元函数积分",
],
"国家中小学智慧教育平台 · 高中数学(人教A版)": [
"二次函数",
"抛物线",
"幂函数",
"三角函数",
"任意角",
"弧度制",
"三角比",
"同角三角函数关系",
"和角与差角公式",
"三角恒等式",
"三角函数图像",
"解三角形",
"直线方程",
],
}
# 以正式出版目录为准的官方章节模板(中文书名/ISBN 来自中国科学技术大学出版社版本)。
OFFICIAL_PRIMARY_CHAPTERS = [
{"name": "引言", "summary": "函数、自变量、定义域与函数值:从真实图像理解函数。"},
{"name": "第1章 例子", "summary": "不同函数图像的直观例子与作图方法。"},
{"name": "第2章 线性函数", "summary": "一次函数的图像、斜率与直线。"},
{"name": "第3章 函数 y=|x|", "summary": "绝对值函数的图像与性质,关于 y 轴对称的典型例子。"},
{"name": "第4章 二次三项式", "summary": "二次函数图像、配方、判别式与零点。"},
{"name": "第5章 分式线性函数", "summary": "y=1/x 类函数图像与渐近行为。"},
{"name": "第6章 幂函数", "summary": "y=x、y=x²、y=x³ 等幂函数的奇偶性与对称性。"},
{"name": "第7章 有理函数", "summary": "更一般的有理函数图像与性质综合。"},
]
OFFICIAL_TRIG_CHAPTERS = [
{"name": "第1章 三角函数", "summary": "从角与旋转进入三角函数,建立正弦、余弦、正切的基本概念。"},
{"name": "第2章 三角形中的三角比", "summary": "在直角三角形中定义正弦、余弦与正切,理解锐角三角比。"},
{"name": "第3章 三角比之间的关系", "summary": "同角三角比的关系、互余关系与基本恒等式推导。"},
{"name": "第4章 三角形内的关系", "summary": "正弦定理、余弦定理以及三角形中边角关系的应用。"},
{"name": "第5章 角与旋转", "summary": "把角推广到任意旋转角,定义任意角的三角函数。"},
{"name": "第6章 弧度法", "summary": "引入弧度制,理解弧长、扇形与角度制换算。"},
{"name": "第7章 加法公式", "summary": "两角和与差的三角函数公式及其几何与代数证明。"},
{"name": "第8章 三角恒等式", "summary": "倍角、半角与和差化积等常用三角恒等式。"},
{"name": "第9章 三角函数的图形", "summary": "正弦、余弦、正切函数的图像、周期与变换。"},
{"name": "第10章 反函数与三角方程", "summary": "反三角函数的基本概念与三角方程的求解方法。"},
]
OFFICIAL_COORDINATE_CHAPTERS = [
{"name": "绪论", "summary": "用坐标把几何问题转化为代数问题的方法引言。"},
{"name": "第1章 直线上点的坐标", "summary": "数轴、数的绝对值与两点之间的距离。"},
{"name": "第2章 平面里点的坐标", "summary": "坐标平面、坐标间关系、距离公式、确定平面图形与其他坐标系。"},
{"name": "第3章 三维空间里点的坐标", "summary": "空间坐标轴与坐标平面,确定空间中的图形。"},
{"name": "第4章 引言", "summary": "从三维推广到四维空间的动机:几何、计算与物理。"},
{"name": "第5章 四维空间", "summary": "四维空间中的坐标轴、坐标平面与距离问题。"},
{"name": "第6章 四维方体", "summary": "球与方体的定义、四维方体的结构与相关问题。"},
]
OFFICIAL_GEOMETRY_CHAPTERS = [
{"name": "点与直线:走进射影几何", "summary": "从点与直线的位置关系出发理解射影几何的基本思想。"},
{"name": "平行线:走进仿射几何", "summary": "以平行关系为线索建立仿射几何的直观框架。"},
{"name": "面积:走进辛几何", "summary": "通过面积概念与保持面积变换认识辛几何。"},
{"name": "圆:走进欧氏几何", "summary": "以圆为核心回到经典欧氏几何的结构与性质。"},
]
COURSES = [
{
"name": "Khan Academy · Algebra 1: Functions",
"provider": "Khan Academy",
"url": "https://www.khanacademy.org/math/algebra/x2f8bb11595b61c86:functions",
"grade": "初中衔接 / 高中",
"description": (
"函数单元含函数求值、定义域值域、函数图像与性质等大量免费练习,"
"英文界面,适合配合盖尔范德丛书做自动化巩固。"
),
},
{
"name": "3Blue1Brown · Essence of Calculus",
"provider": "3Blue1Brown",
"url": "https://www.youtube.com/playlist?list=PLZHQObOWTQDMsr9K-rj53DwVRMYO3t5Yr",
"grade": "高中高年级 / 大学先修",
"description": (
"微积分本质系列:从几何直觉理解导数与积分,适合函数图像直觉建立后"
"进入微积分阶段使用。"
),
},
{
"name": "国家中小学智慧教育平台 · 高中数学(人教A版)",
"provider": "教育部教育技术与资源发展中心",
"url": "https://basic.smartedu.cn/syncClassroom",
"grade": "高中全学段",
"description": (
"官方免费同步课程与配套课件,可按教材版本与册次点播,"
"适合做课内进度对照。"
),
},
]
OFFICIAL_CHAPTERS_BY_ISBN = {}
CATALOG = [
{
"name": "函数和图像(盖尔范德中学生数学思维丛书)",
"author": "I. M. Gelfand, E. G. Glagoleva, E. E. Shnol 著;麻希南、胡长青 译",
"publisher": "中国科学技术大学出版社",
"isbn": "9787312050053",
"edition": "2020年12月第1版",
"grade": "初中至高中低年级",
"link": "",
"description": (
"本系统的主教材。以大量真实图像(地震图、心电图等)引入函数,"
"强调“画图就是看见公式”,系统训练函数概念、图像与性质直觉;"
"英译本 Functions and Graphs 由 Dover 出版。现有函数与对称性题库"
"以本书为依据。"
),
"chapters": [],
},
{
"name": "代数(盖尔范德中学生数学思维丛书)",
"author": "I. M. Gelfand, A. Shen 著;胡长青、麻希南 译",
"publisher": "中国科学技术大学出版社",
"isbn": "9787312048937",
"edition": "2020年第1版",
"grade": "初中至高中低年级",
"link": "",
"description": (
"用问题驱动的叙述串联自然数、负数、代数式与方程,"
"重视“为什么可以这样变形”的代数思维,适合作为代数学基础。"
),
"chapters": [],
},
{
"name": "几何(盖尔范德中学生数学思维丛书)",
"author": "I. M. Gelfand 等 著",
"publisher": "中国科学技术大学出版社",
"isbn": "9787312057793",
"edition": "2023年第1版",
"grade": "初中至高中低年级",
"link": "",
"description": (
"以直观可视的方式从图形构造逐步建立几何,突出“先看见、再证明”"
"的思维路线。"
),
"chapters": [],
},
{
"name": "三角函数(盖尔范德中学生数学思维丛书)",
"author": "I. M. Gelfand, M. Saul 著;王作勤 译",
"publisher": "中国科学技术大学出版社",
"isbn": "9787312046957",
"edition": "2020年第1版",
"grade": "初中至高中低年级",
"link": "",
"description": (
"从初等几何直觉进入正弦、余弦与正切,解释周期与波动现象的数学本质。"
),
"chapters": [],
},
{
"name": "坐标方法(盖尔范德中学生数学思维丛书)",
"author": "I. M. Gelfand, E. G. Glagoleva, A. A. Kirillov 著;王作勤 译",
"publisher": "中国科学技术大学出版社",
"isbn": "9787312050060",
"edition": "2020年12月第1版",
"grade": "初中至高中低年级",
"link": "",
"description": (
"讲解如何用坐标把几何问题转化为代数问题,是解析几何与函数图像"
"方法的基础。"
),
"chapters": [],
},
]
def ensure_catalog(db: Session, force_sync: bool = False) -> None:
"""仅在空库时写入默认目录;管理员后续可自由增删改并持久保留。"""
_ensure_courses(db)
if db.query(Textbook).count() > 0 and not force_sync:
# 存量库不覆盖用户修改,但会补齐新增的“官方正式目录”章节
for item in CATALOG:
textbook = (
db.query(Textbook)
.filter(Textbook.name == item["name"])
.first()
)
if textbook is None or not item["chapters"]:
continue
existing = {
chapter.name: chapter
for chapter in db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.all()
}
position = len(existing)
for chapter in item["chapters"]:
if chapter["name"] not in existing:
db.add(
Chapter(
textbook_id=textbook.id,
name=chapter["name"],
position=position,
summary=chapter["summary"],
)
)
position += 1
_sync_default_chapter_knowledge(db)
_sync_default_course_resources(db)
db.commit()
return
for position, item in enumerate(CATALOG):
textbook = db.query(Textbook).filter(Textbook.name == item["name"]).first()
if textbook is None:
textbook = Textbook(name=item["name"], position=position)
db.add(textbook)
db.flush()
textbook.author = item["author"]
textbook.publisher = item["publisher"]
textbook.isbn = item["isbn"]
textbook.edition = item["edition"]
textbook.grade = item["grade"]
textbook.link = item["link"]
textbook.description = item["description"]
textbook.position = position
existing = {
chapter.name: chapter
for chapter in db.query(Chapter)
.filter(Chapter.textbook_id == textbook.id)
.all()
}
for chapter_index, chapter in enumerate(item["chapters"]):
if chapter["name"] not in existing:
db.add(
Chapter(
textbook_id=textbook.id,
name=chapter["name"],
position=chapter_index,
summary=chapter["summary"],
)
)
# 按最新知识点-章节映射重新关联所有题目
primary = db.query(Textbook).filter(Textbook.name == CATALOG[0]["name"]).first()
if primary:
chapter_map = {
chapter.name: chapter
for chapter in db.query(Chapter)
.filter(Chapter.textbook_id == primary.id)
.all()
}
for question in db.query(Question).all():
chapter = chapter_map.get(
KNOWLEDGE_CHAPTER_MAP.get(question.knowledge_name, "")
)
question.chapter_id = chapter.id if chapter else None
_sync_default_chapter_knowledge(db)
_sync_default_course_resources(db)
db.commit()
def _sync_default_chapter_knowledge(db: Session) -> None:
"""仅为还没有知识点关联的章节写入默认知识点(不覆盖管理员维护)。"""
for chapter in db.query(Chapter).all():
linked = (
db.query(ChapterKnowledge)
.filter(ChapterKnowledge.chapter_id == chapter.id)
.count()
)
if linked:
continue
names = DEFAULT_CHAPTER_KNOWLEDGE.get(chapter.name, [])
if names:
set_chapter_knowledge_names(db, chapter.id, names)
def _ensure_courses(db: Session) -> None:
"""课程表为空时写入默认在线课程;管理员删掉后不再自动恢复。"""
if db.query(Course).count() > 0:
return
for position, item in enumerate(COURSES):
db.add(
Course(
name=item["name"],
provider=item["provider"],
url=item["url"],
grade=item["grade"],
description=item["description"],
position=position,
)
)
db.commit()
def _sync_default_course_resources(db: Session) -> None:
"""把在线课程 / 视频挂到对应知识点,作为知识点详情里的反向资源链接。"""
for course_name, knowledge_names in COURSE_KNOWLEDGE_MAP.items():
course = db.query(Course).filter(Course.name == course_name).first()
if course is None or not course.url:
continue
for name in knowledge_names:
knowledge = db.query(Knowledge).filter(Knowledge.name == name).first()
if knowledge is None:
continue
ensure_knowledge_resource(
db,
knowledge.id,
kind="video",
title=course.name,
url=course.url,
course_id=course.id,
)

View File

@ -0,0 +1,151 @@
"""从 math_learning_dashboard_v3.html 抽取的初始内容。"""
KNOWLEDGE = [
{"name": "函数概念", "mastery": 80, "position": 1},
{"name": "函数值", "mastery": 85, "position": 2},
{"name": "定义域", "mastery": 90, "position": 3},
{"name": "零点", "mastery": 80, "position": 4},
{"name": "正负性", "mastery": 70, "position": 5},
{"name": "偶函数", "mastery": 40, "position": 6},
{"name": "奇函数", "mastery": 40, "position": 7},
{"name": "图像对称性", "mastery": 50, "position": 8},
]
QUESTIONS = [
{
"stem": "已知函数 f(x) 满足 f(-x)=f(x),且 f(3)=5。下列哪个点一定在图像上?",
"options": ["A. (-3,5)", "B. (-3,-5)", "C. (3,-5)", "D. (5,-3)"],
"correct_index": 0,
"knowledge_name": "偶函数",
"explanation": "由偶函数定义 f(-x)=f(x) 可知,点 (3,5) 关于 y 轴的镜像点 (-3,5) 一定在图像上。",
},
{
"stem": "已知函数 f(x) 满足 f(-x)=-f(x),且 f(2)=4。下列哪个点一定在图像上?",
"options": ["A. (-2,4)", "B. (-2,-4)", "C. (2,-4)", "D. (4,-2)"],
"correct_index": 1,
"knowledge_name": "奇函数",
"explanation": "由奇函数定义 f(-x)=-f(x) 可知,(2,4) 关于原点的对称点 (-2,-4) 一定在图像上。",
},
{
"stem": "下列函数的图像关于 $y$ 轴对称的是?",
"options": ["A. $y=x+1$", "B. $y=x^3$", "C. $y=x^2+2$", "D. $y=x^2+x$"],
"correct_index": 2,
"knowledge_name": "偶函数",
"explanation": "$y=x^2+2$ 满足 $f(-x)=f(x)$,因此图像关于 $y$ 轴对称,属于偶函数。",
},
{
"stem": "函数 y=1/(x²-9) 的图像不可能经过哪个点?",
"options": ["A. (0,-1/9)", "B. (4,1/7)", "C. (-4,1/7)", "D. (3,1)"],
"correct_index": 3,
"knowledge_name": "定义域",
"explanation": "分母 x²-9 不能为 0,x=±3 不在定义域内,所以图像不可能经过 (3,1)。",
},
{
"stem": "若函数图像关于原点对称,则下列关系一定成立的是?",
"options": ["A. f(-x)=f(x)", "B. f(-x)=-f(x)", "C. f(x)=0", "D. f(x)>0"],
"correct_index": 1,
"knowledge_name": "奇函数",
"explanation": "图像关于原点对称对应奇函数,即对任意定义域内的 x 有 f(-x)=-f(x)。",
},
{
"stem": "已知 $f(-x)=f(x)$,则函数图像的几何性质是?",
"options": ["A. 关于 x 轴对称", "B. 关于 y 轴对称", "C. 关于原点对称", "D. 无法判断"],
"correct_index": 1,
"knowledge_name": "偶函数",
"explanation": "f(-x)=f(x) 是偶函数的定义式,几何上等价于图像关于 y 轴对称。",
},
{
"stem": "函数 y=2/(x²-1) 的图像不可能经过哪个点?",
"options": ["A. (0,-2)", "B. (2,2/3)", "C. (-2,2/3)", "D. (1,2)"],
"correct_index": 3,
"knowledge_name": "定义域",
"explanation": "分母 x²-1 不能为 0,x=±1 不在定义域内,所以图像不可能经过 (1,2)。",
},
{
"stem": "若 f(-x)=-f(x),且图像经过 (5,-2),则一定经过?",
"options": ["A. (-5,-2)", "B. (-5,2)", "C. (5,2)", "D. (2,5)"],
"correct_index": 1,
"knowledge_name": "奇函数",
"explanation": "奇函数图像关于原点对称,(5,-2) 关于原点的对称点是 (-5,2)。",
},
{
"stem": "若 f(-x)=f(x),且图像经过 (4,7),则下列点一定在图像上的是?",
"options": ["(-4,7)", "(-4,-7)", "(4,-7)", "(7,4)"],
"correct_index": 0,
"knowledge_name": "偶函数",
"explanation": "由偶函数定义得到关于 y 轴的镜像点。",
},
{
"stem": "若 f(-x)=-f(x),且图像经过 (3,5),则下列点一定在图像上的是?",
"options": ["(-3,5)", "(-3,-5)", "(3,-5)", "(5,3)"],
"correct_index": 1,
"knowledge_name": "奇函数",
"explanation": "由奇函数定义得到关于原点的对称点。",
},
{
"stem": "下列函数中属于偶函数的是?",
"options": ["$y=x^3$", "$y=x^2+1$", "$y=x+1$", "$y=x^3+x$"],
"correct_index": 1,
"knowledge_name": "偶函数",
"explanation": "代入 $-x$ 检验 $f(-x)=f(x)$。",
},
{
"stem": "下列函数中属于奇函数的是?",
"options": ["y=x²", "y=x^3", "y=x²+1", "y=x^3+1"],
"correct_index": 1,
"knowledge_name": "奇函数",
"explanation": "代入 -x 检验 f(-x)=-f(x)。",
},
{
"stem": "函数 y=1/(x-2) 的图像不可能经过哪个点?",
"options": ["(0,-1/2)", "(3,1)", "(2,0)", "(4,1/2)"],
"correct_index": 2,
"knowledge_name": "定义域",
"explanation": "分母不能为 0,因此 x=2 不属于定义域。",
},
{
"stem": "若一个函数图像关于 y 轴对称,则该函数通常满足?",
"options": ["f(-x)=f(x)", "f(-x)=-f(x)", "f(x)=0", "f(x)>0"],
"correct_index": 0,
"knowledge_name": "图像对称性",
"explanation": "关于 y 轴对称对应偶函数。",
},
{
"stem": "若一个函数图像关于原点对称,则该函数通常满足?",
"options": ["f(-x)=f(x)", "f(-x)=-f(x)", "f(x)=1", "f(x)>0"],
"correct_index": 1,
"knowledge_name": "图像对称性",
"explanation": "关于原点对称对应奇函数。",
},
{
"stem": "若 f(x) 是偶函数,且 f(-5)=9,则 f(5)=?",
"options": ["-9", "0", "9", "无法确定"],
"correct_index": 2,
"knowledge_name": "偶函数",
"explanation": "偶函数的两个对称自变量对应相同函数值。",
},
{
"stem": "若 f(x) 是奇函数,且 f(-6)=4,则 f(6)=?",
"options": ["4", "-4", "0", "无法确定"],
"correct_index": 1,
"knowledge_name": "奇函数",
"explanation": "奇函数的两个相反自变量对应相反函数值。",
},
{
"stem": "判断函数 y=x⁴-x² 的奇偶性。",
"options": ["偶函数", "奇函数", "既奇又偶", "非奇非偶"],
"correct_index": 0,
"knowledge_name": "偶函数",
"explanation": "偶次幂组成,代入 -x 后表达式不变。",
},
]
SAMPLE_ERRORS = [
("第1轮 · 1", "函数图像", "基础判断不稳定"),
("第1轮 · 8", "定义域", "无定义点识别"),
("第1轮 · 9", "偶函数", "核心概念混淆"),
("第2轮 · 5", "偶函数", "点的对应关系错误"),
("第2轮 · 7", "偶函数", "对称性判断错误"),
("第2轮 · 8", "奇函数", "与偶函数混淆"),
]
SAMPLE_RECORDS = [
("第一轮基础题", 10, 7),
("第二轮专项题", 8, 5),
]

View File

@ -0,0 +1,88 @@
"""电子书种子:把 res/ 下已有的三本书挂到教材上。
幂等:只补不覆盖,管理员在后台重新上传的文件不会被种子冲掉。
"""
from __future__ import annotations
from pathlib import Path
from sqlalchemy.orm import Session
from database import REPO_ROOT
from models import Textbook
from services import ebooks as store
RES_ROOT = REPO_ROOT / "res"
# res/ 里的文件名带书目后缀,展示时用干净的名称
PRINCETON = {
"name": "普林斯顿微积分读本(修订版)",
"author": "阿德里安·班纳 (Adrian Banner)",
"publisher": "人民邮电出版社",
"grade": "高中至大学先修",
"description": "面向微积分入门的系统辅导书,覆盖极限、导数、积分与级数,"
"按考点组织,适合自学与考前查漏补缺。",
"resource": "普林斯顿微积分读本(修订版)",
"display": "普林斯顿微积分读本(修订版).epub",
}
# 已有教材 → res/ 中的 PDF(按教材名前缀匹配)
PDF_LINKS = [
("函数和图像", "函数和图像", "函数和图像(盖尔范德中学生数学思维丛书).pdf"),
("三角函数", "三角函数", "三角函数(盖尔范德中学生数学思维丛书).pdf"),
]
def _find_resource(prefix: str, suffix: str) -> Path | None:
"""res/ 下的文件名带作者与来源后缀,只按标题前缀匹配。"""
if not RES_ROOT.is_dir():
return None
for path in sorted(RES_ROOT.iterdir()):
if (
path.is_file()
and path.suffix.lower() == suffix
and path.name.startswith(prefix)
):
return path
return None
def ensure_ebooks(db: Session) -> None:
created = False
textbook = (
db.query(Textbook).filter(Textbook.name == PRINCETON["name"]).first()
)
if textbook is None:
position = (
db.query(Textbook).count()
)
textbook = Textbook(
name=PRINCETON["name"],
author=PRINCETON["author"],
publisher=PRINCETON["publisher"],
grade=PRINCETON["grade"],
description=PRINCETON["description"],
position=position,
)
db.add(textbook)
db.commit()
created = True
if created:
epub = _find_resource(PRINCETON["resource"], ".epub")
if epub is not None and not textbook.ebook_file:
store.attach_local_file(
db, textbook, epub, display_name=PRINCETON["display"]
)
for name_prefix, file_prefix, display in PDF_LINKS:
textbook = (
db.query(Textbook)
.filter(Textbook.name.startswith(name_prefix))
.first()
)
if textbook is None or textbook.ebook_file:
continue
pdf = _find_resource(file_prefix, ".pdf")
if pdf is None:
continue
store.attach_local_file(db, textbook, pdf, display_name=display)

View File

@ -0,0 +1,512 @@
"""初等 / 高等数学标准知识图谱(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()

View File

@ -0,0 +1,111 @@
"""RBAC 种子:权限、角色与默认账号。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from models import Permission, Role, RolePermission, User, UserRole
from services.security import hash_password
PERMISSIONS = [
("learning:use", "使用学习功能", "learning"),
("users:manage", "用户管理", "admin"),
("catalog:manage", "教材/课程管理", "admin"),
("question-bank:manage", "题库管理", "admin"),
("llm:manage", "模型配置管理", "admin"),
("knowledge:manage", "知识图谱管理", "admin"),
]
ROLES = {
"admin": {
"name": "管理员",
"description": "拥有用户、模型、教材课程与题库的全部管理权限",
# 管理员是纯管理账号,不参与学习,因此不授予 learning:use
"permissions": [
code for code, _, _ in PERMISSIONS if code != "learning:use"
],
},
"student": {
"name": "普通用户",
"description": "使用学习总览、练习、错题、分析等学习功能",
"permissions": ["learning:use"],
},
}
DEFAULT_USERS = [
("admin", "admin123", "系统管理员", "admin"),
("student", "student123", "演示学生", "student"),
]
def _permission_map(db: Session) -> dict[str, Permission]:
rows = {row.code: row for row in db.query(Permission).all()}
for code, name, group in PERMISSIONS:
if code not in rows:
row = Permission(code=code, name=name, group=group)
db.add(row)
db.flush()
rows[code] = row
return rows
def ensure_rbac(db: Session) -> None:
"""幂等创建权限、角色与默认账号,并同步旧库中已过期的角色权限。"""
permissions = _permission_map(db)
roles = {row.code: row for row in db.query(Role).all()}
for code, meta in ROLES.items():
if code not in roles:
role = Role(code=code, name=meta["name"], description=meta["description"])
db.add(role)
db.flush()
roles[code] = role
expected = set(meta["permissions"])
existing_rows = (
db.query(RolePermission, Permission.code)
.join(Permission, Permission.id == RolePermission.permission_id)
.filter(RolePermission.role_id == roles[code].id)
.all()
)
for role_permission, permission_code in existing_rows:
if permission_code not in expected:
db.delete(role_permission)
for permission_code in meta["permissions"]:
exists = (
db.query(RolePermission)
.filter(
RolePermission.role_id == roles[code].id,
RolePermission.permission_id == permissions[permission_code].id,
)
.first()
)
if exists is None:
db.add(
RolePermission(
role_id=roles[code].id,
permission_id=permissions[permission_code].id,
)
)
db.flush()
for username, password, display_name, role_code in DEFAULT_USERS:
user = db.query(User).filter(User.username == username).first()
if user is None:
user = User(
username=username,
password_hash=hash_password(password),
display_name=display_name,
active=True,
)
db.add(user)
db.flush()
assignment = (
db.query(UserRole)
.filter(
UserRole.user_id == user.id,
UserRole.role_id == roles[role_code].id,
)
.first()
)
if assignment is None:
db.add(UserRole(user_id=user.id, role_id=roles[role_code].id))
db.commit()

View File

@ -0,0 +1 @@
"""业务逻辑服务。"""

View File

@ -0,0 +1,30 @@
"""AI 教师:基于题目讲解与知识点状态生成即时反馈。
当前为本地规则式实现,不依赖外部模型:能够给出“为什么对/为什么错”的
讲解与后续建议;若配置了 OPENAI_API_KEY,可在此基础上扩展为真实模型调用。
"""
from __future__ import annotations
from models import Question, UserKnowledge
from services.mastery_engine import action_for
def explain(
question: Question,
selected: int,
correct: bool,
knowledge: UserKnowledge | None,
) -> str:
head = "回答正确。" if correct else "回答错误。"
base = question.explanation or "结合定义与图像性质重新判断。"
mastery_hint = ""
if knowledge is not None:
mastery_hint = (
f" 该知识点当前掌握度为 {knowledge.mastery}%,"
f"建议{action_for(knowledge.mastery)}。"
)
if correct:
return f"{head} 考点「{question.knowledge_name}」:{base}{mastery_hint}"
return f"{head} 你选择了选项 {chr(65 + selected)}。正确思路:{base}{mastery_hint}"

View File

@ -0,0 +1,269 @@
"""通过大模型按教材/课程生成章节目录。"""
from __future__ import annotations
from models import LlmSetting, Textbook
from services import llm_client
SYSTEM_PROMPT = (
"你是一位资深数学教材编审与课程设计专家,熟悉盖尔范德"
"《函数和图像》《代数》《三角函数》等中学生数学思维丛书。"
)
def build_chapters_prompt(
*,
textbook: Textbook,
count: int,
existing_names: list[str],
instructions: str,
fixed_titles: list[str] | None = None,
external_context: dict | None = None,
graph_context: list[str] | None = None,
) -> str:
extra = f"\n额外要求:{instructions}" if instructions else ""
existing = "、".join(existing_names) if existing_names else "(暂无章节)"
isbn = textbook.isbn or ""
external = external_context or {}
external_title = external.get("title") or ""
external_description = external.get("description") or ""
lookup_status = (
"ISBN 公开目录未收录,请结合书名 + ISBN + 作者/出版社"
"确认正式出版物后作答"
if (isbn and not external)
else "已通过 ISBN 查询到公开书目信息"
)
knowledge_hint = (
"、".join(graph_context) if graph_context else "(按该书内容从知识图谱选择)"
)
graph_line = f"\n知识图谱可用节点:{knowledge_hint}"
if fixed_titles:
numbered = "\n".join(
f"{index}. {title}" for index, title in enumerate(fixed_titles, start=1)
)
lookup_note = "已根据 ISBN 查询到该书正式目录"
if external_title:
lookup_note += f"({external_title})"
knowledge_hint = (
"、".join(graph_context) if graph_context else "(按该书内容选择)"
)
return f"""通过 ISBN 查询到《{textbook.name}》的正式章节目录。
{lookup_note}。请为下列章节编写符合数学教材风格的内容摘要。
正式目录章节:
{numbered}
系统知识图谱可用节点:{knowledge_hint}
要求:
1. 章节名称必须与上面给定名称完全一致,不得改名、不得新增或删减;
2. 每章 summary 用 1-2 句中文说明该章核心主题、知识能力与学习任务;
3. 每章从系统知识图谱可用节点中按语义选择 1-5 个作为 knowledge_names;
4. 不要输出多余文字,只输出 JSON:
{{"chapters":[{{"name":"给定章节名","summary":"摘要","knowledge_names":["节点名"]}}]}}"""
return f"""请像检索真实书目一样,根据以下信息还原《{textbook.name}》中文正式版的完整章节目录。
书目信息:
- 书名:{textbook.name}
- ISBN:{isbn or "(未填写)"}
- 作者/翻译/出版:{textbook.author} {textbook.publisher}
- 版本/学段:{textbook.edition} {textbook.grade}
- 简介:{textbook.description or "请结合标题判断"}
- 本书目前已存在章节:{existing or "(暂无)"}
{f"- 外部书目补充:{external_description}" if external_description else ""}
{graph_line}
要求:
1. 只做“目录还原”,不要编撰新章节,不要套用通用课程大纲;
2. 请检索/回忆该 ISBN 对应版本的中文正式目录,原样输出目录条目
(引言、第 X 章、附录/习题解答等都要保留原名,例如“第 8 章 独立求解的问题”);
3. 输出全部正式目录;若确有把握有限,也可只输出能确认的部分,不要补造;
4. 为每个目录条目写 1 句 summary(前言/解答等可写其用途);
5. 每个教学章节从“知识图谱可用节点”中选择 1-5 个作为 knowledge_names;
前言、习题解答等非教学条目 knowledge_names 输出空数组;
6. 不要输出多余文字,只输出 JSON:
{{"chapters":[{{"name":"目录原样条目","summary":"摘要","knowledge_names":["节点名"]}}]}}
"""
def _extract_json(content: str) -> dict:
from services.json_utils import extract_json_lax
return extract_json_lax(content)
def generate_chapters(
*,
setting: LlmSetting,
textbook: Textbook,
count: int,
existing_names: list[str],
instructions: str,
fixed_titles: list[str] | None = None,
external_context: dict | None = None,
graph_context: list[str] | None = None,
) -> list[dict]:
use_titles = fixed_titles or []
if use_titles:
count = len(use_titles)
prompt = build_chapters_prompt(
textbook=textbook,
count=count,
existing_names=existing_names,
instructions=instructions,
fixed_titles=use_titles or None,
external_context=external_context,
graph_context=graph_context,
)
payload = {}
last_error = ""
for attempt in range(3):
try:
content = llm_client.chat_completion(
base_url=setting.base_url,
api_key=setting.api_key,
model=setting.model,
temperature=0.3,
max_tokens=setting.max_tokens or 5000,
timeout=200.0,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
)
except ValueError as exc:
last_error = str(exc)
continue
try:
payload = _extract_json(content)
break
except ValueError as exc:
last_error = str(exc)
else:
raise ValueError(
f"模型连续 3 次未返回合法 JSON,最后一次:{last_error}"
)
raw_chapters = payload.get("chapters")
if not isinstance(raw_chapters, list) or not raw_chapters:
raise ValueError("模型返回内容中没有 chapters 列表")
result: list[dict] = []
fixed_set = set(use_titles)
for raw in raw_chapters[:count]:
if not isinstance(raw, dict):
raise ValueError("模型返回的章节格式不正确")
name = str(raw.get("name", "")).strip()
summary = str(raw.get("summary", "")).strip()
if not name:
raise ValueError("模型返回的章节名称为空")
if fixed_set and name not in fixed_set:
raise ValueError(
f"模型返回的章节“{name}”不在 ISBN 查询到的正式目录中,请重试"
)
raw_knowledge = raw.get("knowledge_names") or []
if not isinstance(raw_knowledge, list):
raw_knowledge = []
result.append(
{
"name": name[:128],
"summary": summary[:1000],
"knowledge_names": [
str(tag).strip()[:64] for tag in raw_knowledge
],
}
)
return result
def organize_reference_chapters(
*,
setting: LlmSetting,
textbook: Textbook,
reference_text: str,
existing_names: list[str],
graph_context: list[str],
) -> list[dict]:
"""根据用户粘贴的目录素材整理出章节,并为每个章节从知识图谱选节点。"""
prompt = f"""你是一名教材目录整理助手。下面是从用户或其他 AI 处得到的
《{textbook.name}》目录原始资料,请整理成结构化章节列表。
教材信息:
- ISBN:{textbook.isbn or "未填写"}
- 作者/出版:{textbook.author} {textbook.publisher}
- 已存在章节:{"、".join(existing_names) if existing_names else "(暂无)"}
知识图谱可用节点(只能从这里选 knowledge_names):
{"、".join(graph_context) if graph_context else "(暂无)"}
用户提供的目录素材:
==========================
{reference_text}
==========================
要求:
1. 只整理素材中真实出现的章节目录,不得新增、不得凭印象补造;
2. 保留正式名称(含“第 X 章”、引言、前言、习题解答等),去除多余格式符号;
3. 无法判断为目录项的文字不要输出;
4. 每章 summary 用 1 句中文说明;
5. 教学章节从“知识图谱可用节点”中按语义选择 1-5 个 knowledge_names,
非教学条目为空数组;不得自造图谱节点;
6. 只输出 JSON:{{"chapters":[{{"name":"目录项","summary":"摘要","knowledge_names":["节点"]}}]}}
"""
payload = {}
last_error = ""
for attempt in range(3):
try:
content = llm_client.chat_completion(
base_url=setting.base_url,
api_key=setting.api_key,
model=setting.model,
temperature=0.1,
max_tokens=setting.max_tokens or 5000,
timeout=200.0,
messages=[
{
"role": "system",
"content": (
"你只负责整理用户提供的真实目录素材,不编造内容。"
"JSON 必须完整闭合。"
),
},
{"role": "user", "content": prompt},
],
)
except ValueError as exc:
last_error = str(exc)
continue
try:
payload = _extract_json(content)
break
except ValueError as exc:
last_error = str(exc)
else:
raise ValueError(
f"模型连续 3 次未返回合法 JSON,最后一次:{last_error}"
)
raw_chapters = payload.get("chapters")
if not isinstance(raw_chapters, list) or not raw_chapters:
raise ValueError("整理结果中没有 chapters 列表")
result: list[dict] = []
for raw in raw_chapters:
if not isinstance(raw, dict):
continue
name = str(raw.get("name", "")).strip()
if not name:
continue
raw_knowledge = raw.get("knowledge_names") or []
if not isinstance(raw_knowledge, list):
raw_knowledge = []
result.append(
{
"name": name[:128],
"summary": str(raw.get("summary", "")).strip()[:1000],
"knowledge_names": [
str(tag).strip()[:64] for tag in raw_knowledge
],
}
)
return result

View File

@ -0,0 +1,242 @@
"""电子书存储:文件放 data/ebooks/{textbook_id}/ 下,数据库只存文件名与元信息。
上传按分块写临时文件(不整体进内存),先校验扩展名与文件头魔数,
再原子改名,避免半截文件污染在线阅读。
"""
from __future__ import annotations
import os
import re
import shutil
import uuid
import zipfile
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree
from fastapi import HTTPException, UploadFile
from sqlalchemy.orm import Session
from pypdf import PdfReader
from database import REPO_ROOT
from models import EbookProgress, Textbook
EBOOK_ROOT = REPO_ROOT / "data" / "ebooks"
MAX_EBOOK_BYTES = 500 * 1024 * 1024 # 500MB
_CHUNK = 1024 * 1024
# 扩展名 -> (格式标识, 魔数, MIME)
KINDS: dict[str, tuple[str, bytes]] = {
".pdf": ("pdf", b"%PDF-"),
".epub": ("epub", b"PK\x03\x04"),
}
MIME_BY_FORMAT = {"pdf": "application/pdf", "epub": "application/epub+zip"}
def book_dir(textbook_id: int) -> Path:
return EBOOK_ROOT / str(textbook_id)
def resolve_path(textbook_id: int, filename: str) -> Path:
"""只允许该书目录下的裸文件名,杜绝 ../ 穿越。"""
safe = os.path.basename(filename)
if not safe or safe.startswith("."):
raise HTTPException(status_code=404, detail="电子书文件不存在")
path = (book_dir(textbook_id) / safe).resolve()
root = book_dir(textbook_id).resolve()
if path.parent != root or not path.is_file():
raise HTTPException(status_code=404, detail="电子书文件不存在")
return path
def media_type(fmt: str) -> str:
return MIME_BY_FORMAT.get(fmt, "application/octet-stream")
def file_kind(filename: str) -> tuple[str, str]:
"""按扩展名给出 (格式, 魔数);不支持的扩展名直接拒绝。"""
ext = Path(filename or "").suffix.lower()
kind = KINDS.get(ext)
if kind is None:
raise HTTPException(status_code=400, detail="只支持 PDF 与 EPUB 两种格式")
return kind[0], kind[1]
def _safe_name(filename: str) -> str:
"""原始文件名只用于展示:去掉路径与可能破坏排版的控制字符。"""
name = os.path.basename(filename or "").strip()
name = re.sub(r"[\r\n\t]+", " ", name)
return name[:180]
async def store_upload(db: Session, textbook: Textbook, upload: UploadFile) -> Textbook:
fmt, magic = file_kind(upload.filename or "")
target_dir = book_dir(textbook.id)
target_dir.mkdir(parents=True, exist_ok=True)
temp = target_dir / f".upload-{uuid.uuid4().hex}.part"
size = 0
try:
with temp.open("wb") as handle:
while True:
chunk = await upload.read(_CHUNK)
if not chunk:
break
if size == 0 and not chunk.startswith(magic):
raise HTTPException(
status_code=400, detail="文件内容与扩展名不符,请上传真实的 PDF / EPUB 文件"
)
size += len(chunk)
if size > MAX_EBOOK_BYTES:
raise HTTPException(status_code=400, detail="电子书超过 500MB 上限")
handle.write(chunk)
if size == 0:
raise HTTPException(status_code=400, detail="上传的文件是空的")
final = target_dir / f"{uuid.uuid4().hex}.{fmt}"
os.replace(temp, final)
finally:
if temp.exists():
temp.unlink(missing_ok=True)
await upload.close()
# 覆盖式上传:旧文件随后清理,磁盘上不留重复副本
if textbook.ebook_file:
try:
resolve_path(textbook.id, textbook.ebook_file).unlink(missing_ok=True)
except HTTPException:
pass
textbook.ebook_file = final.name
textbook.ebook_format = fmt
textbook.ebook_name = _safe_name(upload.filename or final.name)
textbook.ebook_size = size
textbook.ebook_pages = count_pages(final, fmt)
textbook.ebook_uploaded_at = datetime.utcnow()
db.commit()
return textbook
def clear_ebook(db: Session, textbook: Textbook) -> Textbook:
if textbook.ebook_file:
try:
resolve_path(textbook.id, textbook.ebook_file).unlink(missing_ok=True)
except HTTPException:
pass
textbook.ebook_file = ""
textbook.ebook_name = ""
textbook.ebook_format = ""
textbook.ebook_size = 0
textbook.ebook_pages = 0
textbook.ebook_uploaded_at = None
db.commit()
return textbook
def attach_local_file(
db: Session,
textbook: Textbook,
source: Path,
display_name: str = "",
) -> Textbook:
"""给种子脚本用:把仓库里的电子书复制进 data/ebooks/{id}/ 并登记。"""
fmt, magic = file_kind(source.name)
if not source.is_file():
raise FileNotFoundError(str(source))
target_dir = book_dir(textbook.id)
target_dir.mkdir(parents=True, exist_ok=True)
final = target_dir / f"{uuid.uuid4().hex}.{fmt}"
with source.open("rb") as reader, final.open("wb") as writer:
head = reader.read(len(magic))
if not head.startswith(magic):
final.unlink(missing_ok=True)
raise ValueError(f"{source.name} 不是有效的 {fmt.upper()} 文件")
writer.write(head)
shutil.copyfileobj(reader, writer, _CHUNK)
if textbook.ebook_file:
try:
resolve_path(textbook.id, textbook.ebook_file).unlink(missing_ok=True)
except HTTPException:
pass
textbook.ebook_file = final.name
textbook.ebook_format = fmt
textbook.ebook_name = _safe_name(display_name or source.name)
textbook.ebook_size = final.stat().st_size
textbook.ebook_pages = count_pages(final, fmt)
textbook.ebook_uploaded_at = datetime.utcnow()
db.commit()
return textbook
def _pdf_pages(path: Path) -> int:
return len(PdfReader(path).pages)
def _epub_pages(path: Path) -> int:
"""EPUB 没有“页”,用正文文档数(spine 条目)作为篇幅。"""
with zipfile.ZipFile(path) as archive:
container = ElementTree.fromstring(archive.read("META-INF/container.xml"))
opf = next(
(
node.get("full-path")
for node in container.iter()
if node.tag.endswith("rootfile")
),
"",
)
if not opf:
return 0
package = ElementTree.fromstring(archive.read(opf))
spine = next((node for node in package if node.tag.endswith("spine")), None)
if spine is None:
return 0
return len(
[
item
for item in spine
if item.tag.endswith("itemref") and item.get("idref")
]
)
def count_pages(path: Path, fmt: str) -> int:
"""解析篇幅;文件损坏时返回 0,交给阅读器再回填,不阻断上传。"""
try:
return _pdf_pages(path) if fmt == "pdf" else _epub_pages(path)
except Exception: # noqa: BLE001 - 解析器版本/畸形文件都按“未知篇幅”处理
return 0
def backfill_pages(db: Session) -> None:
"""给存量数据补篇幅:上传时没解析过(或当时解析失败)的书。"""
rows = (
db.query(Textbook)
.filter(Textbook.ebook_file != "", Textbook.ebook_pages == 0)
.all()
)
changed = False
for textbook in rows:
try:
path = resolve_path(textbook.id, textbook.ebook_file)
except HTTPException:
continue
pages = count_pages(path, textbook.ebook_format)
if pages and textbook.ebook_pages != pages:
textbook.ebook_pages = pages
changed = True
if textbook.ebook_uploaded_at is None:
textbook.ebook_uploaded_at = datetime.utcfromtimestamp(
path.stat().st_mtime
)
changed = True
if changed:
db.commit()
def drop_book_files(textbook_id: int) -> None:
shutil.rmtree(book_dir(textbook_id), ignore_errors=True)
def drop_progress(db: Session, textbook_id: int) -> None:
db.query(EbookProgress).filter(EbookProgress.textbook_id == textbook_id).delete()

View File

@ -0,0 +1,79 @@
"""通过 ISBN 查询公开书目信息与章节目录。
优先 Open Library,其次 Google Books。网络不可用或 ISBN 无收录时返回空结果,
调用方继续使用书名 + ISBN 让大模型依据正式出版物内容生成。
"""
from __future__ import annotations
import re
import httpx
def _normalize_isbn(isbn: str) -> str:
return re.sub(r"[^0-9Xx]", "", isbn).upper()
def _clean_toc_item(item: dict) -> str | None:
title = str(item.get("title", "")).strip()
label = str(item.get("label", "")).strip()
if not title and not label:
return None
title = title or label
# 去掉 Open Library 常见的前置编号/页眉,避免把“引言”“1”当标题
title = re.sub(r"^\s*(?:page\s*)?\d+\s*[:.]?\s*", "", title, flags=re.I)
return title.strip() or None
def fetch_isbn_context(isbn: str) -> dict:
"""返回 {source, title, description, chapter_titles}。"""
normalized = _normalize_isbn(isbn)
if not normalized:
return {"source": "", "title": "", "description": "", "chapter_titles": []}
# 1) Open Library 按 ISBN 查书目
try:
with httpx.Client(timeout=6.0, follow_redirects=True) as client:
response = client.get(
f"https://openlibrary.org/isbn/{normalized}.json"
)
if response.status_code == 200:
payload = response.json()
titles: list[str] = []
for item in payload.get("table_of_contents") or []:
if not isinstance(item, dict):
continue
title = _clean_toc_item(item)
if title and title not in titles:
titles.append(title)
return {
"source": "openlibrary",
"title": str(payload.get("title", "")),
"description": "",
"chapter_titles": titles,
}
except httpx.HTTPError:
pass
# 2) Google Books 按 ISBN 查书目
try:
with httpx.Client(timeout=6.0) as client:
response = client.get(
"https://www.googleapis.com/books/v1/volumes",
params={"q": f"isbn:{normalized}", "maxResults": 1},
)
if response.status_code == 200:
data = response.json()
items = data.get("items") or []
if items:
volume = items[0].get("volumeInfo", {})
return {
"source": "google_books",
"title": str(volume.get("title", "")),
"description": str(volume.get("description", ""))[:1500],
"chapter_titles": [],
}
except httpx.HTTPError:
pass
return {"source": "", "title": "", "description": "", "chapter_titles": []}

View File

@ -0,0 +1,85 @@
"""宽松 JSON 提取:模型常把 LaTeX 的 \\ 写进 JSON,需要修复非法转义。"""
from __future__ import annotations
import json
import re
VALID_JSON_ESCAPES = set('"\\/bfnrtu')
def _repair_json_escapes(content: str) -> str:
"""把字符串里未被正确转义的单个反斜杠补成双反斜杠。"""
result: list[str] = []
index = 0
in_string = False
length = len(content)
while index < length:
char = content[index]
if not in_string:
result.append(char)
if char == '"':
in_string = True
index += 1
continue
if char != "\\":
result.append(char)
if char == '"':
in_string = False
index += 1
continue
run_end = index
while run_end < length and content[run_end] == "\\":
run_end += 1
run_length = run_end - index
next_char = content[run_end] if run_end < length else ""
if (
run_length % 2 == 1
and next_char
and next_char not in VALID_JSON_ESCAPES
):
result.append("\\" * (run_length + 1))
else:
result.append("\\" * run_length)
index = run_end
return "".join(result)
def extract_json_lax(content: str) -> dict:
text = content.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
end = text.rfind("}")
if end <= 0:
raise ValueError("模型没有返回 JSON,请重试或调整模型")
starts = [
match.start()
for match in re.finditer(r'\{"(questions|chapters)":', text)
]
starts.extend(match.start() for match in re.finditer(r"\{", text))
starts = sorted(set(starts))
# 限制扫描数量,避免模型把大量思考文本误当成 JSON
for start in starts:
if start >= end:
break
payload = text[start : end + 1]
try:
return json.loads(payload, strict=False)
except json.JSONDecodeError:
repaired = _repair_json_escapes(payload)
try:
return json.loads(repaired, strict=False)
except json.JSONDecodeError:
continue
# 没有可解析候选时,给出最后一次错误细节
payload = text[starts[0] : end + 1] if starts else text
try:
json.loads(payload, strict=False)
except json.JSONDecodeError as exc:
raise ValueError(
"模型返回的 JSON 格式不正确"
f"({exc.msg},位置 {exc.pos}),请重试或调整模型"
) from exc
raise ValueError("模型返回的 JSON 格式不正确,请重试或调整模型")

View File

@ -0,0 +1,226 @@
"""知识点关系:全局 Knowledge + 章节/题目多对多 + 用户掌握度。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from models import (
ChapterKnowledge,
Knowledge,
KnowledgeRelation,
KnowledgeResource,
Question,
QuestionKnowledge,
UserKnowledge,
)
def ensure_knowledge(db: Session, name: str) -> Knowledge:
clean = name.strip()
row = db.query(Knowledge).filter(Knowledge.name == clean).first()
if row is None:
row = Knowledge(name=clean)
db.add(row)
db.flush()
return row
def ensure_knowledge_relation(
db: Session,
source: Knowledge,
target: Knowledge,
relation_type: str = "前置",
) -> KnowledgeRelation:
row = (
db.query(KnowledgeRelation)
.filter(
KnowledgeRelation.source_id == source.id,
KnowledgeRelation.target_id == target.id,
KnowledgeRelation.relation_type == relation_type,
)
.first()
)
if row is None:
row = KnowledgeRelation(
source_id=source.id,
target_id=target.id,
relation_type=relation_type,
)
db.add(row)
db.flush()
return row
def chapter_knowledge_names(db: Session, chapter_id: int) -> list[str]:
rows = (
db.query(Knowledge.name)
.join(ChapterKnowledge, ChapterKnowledge.knowledge_id == Knowledge.id)
.filter(ChapterKnowledge.chapter_id == chapter_id)
.order_by(ChapterKnowledge.id.asc())
.all()
)
return [name for (name,) in rows]
def knowledge_resources(db: Session, knowledge_id: int) -> list[KnowledgeResource]:
return (
db.query(KnowledgeResource)
.filter(KnowledgeResource.knowledge_id == knowledge_id)
.order_by(KnowledgeResource.position.asc(), KnowledgeResource.id.asc())
.all()
)
def ensure_knowledge_resource(
db: Session,
knowledge_id: int,
*,
kind: str,
title: str,
url: str,
textbook_id: int | None = None,
course_id: int | None = None,
) -> KnowledgeResource:
row = (
db.query(KnowledgeResource)
.filter(
KnowledgeResource.knowledge_id == knowledge_id,
KnowledgeResource.kind == kind,
KnowledgeResource.url == url,
)
.first()
)
if row is None:
row = KnowledgeResource(
knowledge_id=knowledge_id,
kind=kind,
title=title,
url=url,
textbook_id=textbook_id,
course_id=course_id,
)
db.add(row)
db.flush()
return row
def set_chapter_knowledge_names(
db: Session, chapter_id: int, names: list[str]
) -> list[Knowledge]:
cleaned = list(dict.fromkeys(name.strip() for name in names if name.strip()))
db.query(ChapterKnowledge).filter(
ChapterKnowledge.chapter_id == chapter_id
).delete(synchronize_session=False)
db.flush()
ordered: list[Knowledge] = []
for name in cleaned:
knowledge = ensure_knowledge(db, name)
db.add(ChapterKnowledge(chapter_id=chapter_id, knowledge_id=knowledge.id))
ordered.append(knowledge)
db.flush()
return ordered
def set_chapter_knowledge_ids(
db: Session, chapter_id: int, knowledge_ids: list[int]
) -> list[Knowledge]:
ids = list(dict.fromkeys(int(kid) for kid in knowledge_ids))
rows = (
db.query(Knowledge).filter(Knowledge.id.in_(ids)).all()
if ids
else []
)
by_id = {row.id: row for row in rows}
if len(by_id) != len(ids):
raise ValueError("部分知识点不存在")
db.query(ChapterKnowledge).filter(
ChapterKnowledge.chapter_id == chapter_id
).delete(synchronize_session=False)
db.flush()
ordered = [by_id[kid] for kid in ids]
for knowledge in ordered:
db.add(ChapterKnowledge(chapter_id=chapter_id, knowledge_id=knowledge.id))
db.flush()
return ordered
def question_knowledge_names(db: Session, question: Question) -> list[str]:
rows = (
db.query(Knowledge.name)
.join(QuestionKnowledge, QuestionKnowledge.knowledge_id == Knowledge.id)
.filter(QuestionKnowledge.question_id == question.id)
.order_by(QuestionKnowledge.id.asc())
.all()
)
names = [name for (name,) in rows]
return names or ([question.knowledge_name] if question.knowledge_name else [])
def set_question_knowledge_names(
db: Session,
question: Question,
names: list[str],
*,
attach_to_chapter: bool = False,
) -> list[Knowledge]:
cleaned = list(dict.fromkeys(name.strip() for name in names if name.strip()))
if not cleaned and question.knowledge_name:
cleaned = [question.knowledge_name]
db.query(QuestionKnowledge).filter(
QuestionKnowledge.question_id == question.id
).delete(synchronize_session=False)
db.flush()
ordered: list[Knowledge] = []
for name in cleaned:
knowledge = ensure_knowledge(db, name)
db.add(
QuestionKnowledge(question_id=question.id, knowledge_id=knowledge.id)
)
ordered.append(knowledge)
if attach_to_chapter and question.chapter_id is not None:
exists = (
db.query(ChapterKnowledge)
.filter(
ChapterKnowledge.chapter_id == question.chapter_id,
ChapterKnowledge.knowledge_id == knowledge.id,
)
.first()
)
if exists is None:
db.add(
ChapterKnowledge(
chapter_id=question.chapter_id,
knowledge_id=knowledge.id,
)
)
db.flush()
if cleaned:
question.knowledge_name = cleaned[0]
return ordered
def ensure_user_knowledge(
db: Session, user_id: int, knowledge: Knowledge
) -> UserKnowledge:
row = (
db.query(UserKnowledge)
.filter(
UserKnowledge.user_id == user_id,
UserKnowledge.knowledge_id == knowledge.id,
)
.first()
)
if row is None:
max_position = (
db.query(UserKnowledge.position)
.filter(UserKnowledge.user_id == user_id)
.count()
)
row = UserKnowledge(
user_id=user_id,
knowledge_id=knowledge.id,
mastery=60,
position=max_position,
)
db.add(row)
db.flush()
return row

View File

@ -0,0 +1,76 @@
"""学习账号初始化与清理(用户掌握度按全局 Knowledge 节点记录)。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from sqlalchemy import text
from models import (
AttemptItem,
AttemptSession,
DailyCompletion,
ErrorEntry,
Role,
User,
UserKnowledge,
UserRole,
)
from seed.rbac import DEFAULT_USERS
def user_has_admin_role(db: Session, user_id: int) -> bool:
return (
db.query(Role.id)
.join(UserRole, UserRole.role_id == Role.id)
.filter(Role.code == "admin", UserRole.user_id == user_id)
.first()
is not None
)
def ensure_profile(db: Session, user_id: int) -> None:
"""新学习账号无需预置掌握度:从第一次章节测试起由答题结果生成。"""
db.commit()
def ensure_default_profiles(db: Session) -> None:
"""默认学生账号同样从空学习记录开始,管理员不建学习档案。"""
for username, _, _, _ in DEFAULT_USERS:
user = db.query(User).filter(User.username == username).first()
if user is None or user_has_admin_role(db, user.id):
continue
ensure_profile(db, user.id)
def remove_unused_admin_profile(db: Session, user_id: int) -> None:
"""管理员不保留学习数据。"""
db.query(UserKnowledge).filter(UserKnowledge.user_id == user_id).delete()
db.query(ErrorEntry).filter(ErrorEntry.user_id == user_id).delete()
db.query(AttemptItem).filter(AttemptItem.user_id == user_id).delete()
db.query(AttemptSession).filter(AttemptSession.user_id == user_id).delete()
db.query(DailyCompletion).filter(DailyCompletion.user_id == user_id).delete()
db.commit()
def reset_user_learning(db: Session, user_id: int) -> None:
remove_unused_admin_profile(db, user_id)
def reset_all_learning(db: Session) -> None:
db.query(UserKnowledge).delete()
db.query(ErrorEntry).delete()
db.query(AttemptItem).delete()
db.query(AttemptSession).delete()
db.query(DailyCompletion).delete()
db.execute(text("DROP TABLE IF EXISTS knowledge_points"))
for table_name, column in (
("chapters", "knowledge_names"),
("questions", "knowledge_names"),
):
try:
db.execute(
text(f"ALTER TABLE {table_name} DROP COLUMN {column}")
)
except Exception:
# 新库本来就没有这些旧文本列,忽略
pass
db.commit()

View File

@ -0,0 +1,149 @@
"""大模型调用客户端:OpenAI / DeepSeek / 阿里百炼 qwen / OpenAI 兼容。"""
from __future__ import annotations
import httpx
PROVIDER_PRESETS = {
"openai": {
"label": "OpenAI",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
},
"deepseek": {
"label": "DeepSeek",
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
},
"qwen": {
"label": "阿里千问(百炼)",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
},
"openai_compatible": {
"label": "OpenAI 兼容(自定义)",
"base_url": "",
"model": "",
},
}
def mask_key(api_key: str) -> str:
if not api_key:
return ""
if len(api_key) <= 8:
return "****"
return f"****{api_key[-4:]}"
def _content_text(value) -> str:
"""兼容 content 为字符串或 OpenAI 多段数组([{"type":"text","text":...}])。"""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, list):
chunks: list[str] = []
for part in value:
if isinstance(part, dict):
text = part.get("text")
if isinstance(text, str):
chunks.append(text)
else:
chunks.append(str(part))
return "".join(chunks)
if isinstance(value, dict):
text = value.get("text")
return text if isinstance(text, str) else ""
return str(value)
def _message_text(message: dict) -> str:
"""OpenAI 兼容网关常见差异:正文可能在 content 或 reasoning/reasoning_content。"""
text = _content_text(message.get("content"))
if text.strip():
return text.strip()
for key in ("reasoning_content", "reasoning"):
fallback = _content_text(message.get(key))
if fallback.strip():
return fallback.strip()
refusal = message.get("refusal")
if isinstance(refusal, str) and refusal.strip():
raise ValueError(f"模型拒绝回答:{refusal[:200]}")
return ""
def chat_completion(
*,
base_url: str,
api_key: str,
model: str,
messages: list[dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 1600,
timeout: float = 120.0,
) -> str:
if not base_url or not api_key or not model:
raise ValueError("请先完整填写模型配置(地址、API Key、模型名)。")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
payload = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
with httpx.Client(timeout=timeout) as client:
response = client.post(endpoint, json=payload, headers=headers)
except httpx.HTTPError as exc:
raise ValueError(f"无法连接模型服务:{exc.__class__.__name__}") from exc
if response.status_code != 200:
detail = response.text[:300]
raise ValueError(f"模型服务返回 {response.status_code}:{detail}")
try:
data = response.json()
message = data["choices"][0].get("message")
if not isinstance(message, dict):
raise KeyError("message")
content = _message_text(message)
if not content:
finish_reason = data["choices"][0].get("finish_reason")
raise ValueError(
"模型没有返回可见文本"
f"(finish_reason={finish_reason})。"
"若模型启用了推理模式,请提高 max_tokens 或缩短提示。"
)
return content
except ValueError:
raise
except (KeyError, IndexError, TypeError) as exc:
raise ValueError("模型返回格式不符合 Chat Completions 规范") from exc
def quick_test(
*,
base_url: str,
api_key: str,
model: str,
temperature: float = 0.0,
) -> str:
return chat_completion(
base_url=base_url,
api_key=api_key,
model=model,
temperature=temperature,
max_tokens=256,
timeout=45.0,
messages=[
{
"role": "system",
"content": "你是连通性测试助手,只回复两个字:正常",
},
{"role": "user", "content": "请确认服务可用。"},
],
)

View File

@ -0,0 +1,66 @@
"""模型通道(多通道)配置辅助:默认命名、幂等初始化与默认通道管理。"""
from __future__ import annotations
import os
from sqlalchemy.orm import Session
from models import LlmSetting
from services.llm_client import PROVIDER_PRESETS
def provider_label(provider: str) -> str:
preset = PROVIDER_PRESETS.get(provider)
return preset["label"] if preset else provider
def default_channel_name(provider: str, model: str) -> str:
label = provider_label(provider)
return f"{label} · {model}" if model else label
def ensure_llm_channels(db: Session) -> None:
"""老库升级:给历史单通道配置补名字并保证存在默认通道。"""
rows = db.query(LlmSetting).order_by(LlmSetting.id.asc()).all()
if not rows:
api_key = os.getenv("OPENAI_API_KEY", "").strip()
if api_key:
preset = PROVIDER_PRESETS["openai"]
db.add(
LlmSetting(
name=default_channel_name("openai", preset["model"]),
provider="openai",
base_url=preset["base_url"],
model=preset["model"],
temperature=0.3,
api_key=api_key,
is_default=True,
)
)
db.commit()
return
for row in rows:
if not row.name:
row.name = default_channel_name(row.provider, row.model)
if not any(row.is_default for row in rows):
rows[0].is_default = True
db.commit()
def get_default_channel(db: Session) -> LlmSetting | None:
return (
db.query(LlmSetting)
.filter(LlmSetting.is_default.is_(True))
.order_by(LlmSetting.id.asc())
.first()
)
def promote_default(db: Session, channel_id: int | None = None) -> None:
"""把指定通道设为默认;未指定时选择第一个可用通道。"""
rows = db.query(LlmSetting).order_by(LlmSetting.id.asc()).all()
for row in rows:
row.is_default = channel_id is not None and row.id == channel_id
if channel_id is None and rows:
rows[0].is_default = True
db.commit()

View File

@ -0,0 +1,83 @@
"""掌握度引擎:状态分级、按全局知识点节点答题后更新。"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.orm import Session
from models import Knowledge, UserKnowledge
from services.knowledge_service import ensure_knowledge, ensure_user_knowledge
MASTERY_DELTA = {
"chapter": (5, 4),
"legacy": (4, 3),
"imported": (0, 0),
}
@dataclass
class KnowledgeView:
id: int
knowledge_id: int
name: str
mastery: int
position: int
domain: str
category: str
def status_for(mastery: int) -> str:
if mastery >= 85:
return "已掌握"
if mastery >= 75:
return "基本掌握"
if mastery >= 60:
return "待加强"
return "重点"
def action_for(mastery: int) -> str:
if mastery < 60:
return "优先强化"
if mastery < 75:
return "继续练习"
return "保持复习"
def snapshot(db: Session, user_id: int) -> list[KnowledgeView]:
rows = (
db.query(UserKnowledge, Knowledge)
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
.filter(UserKnowledge.user_id == user_id)
.order_by(UserKnowledge.position.asc(), UserKnowledge.id.asc())
.all()
)
return [
KnowledgeView(
id=user_knowledge.id,
knowledge_id=knowledge.id,
name=knowledge.name,
mastery=user_knowledge.mastery,
position=user_knowledge.position,
domain=knowledge.domain,
category=knowledge.category,
)
for user_knowledge, knowledge in rows
]
def apply_answers(
db: Session,
user_id: int,
mode: str,
answered: list[tuple[str, bool]],
) -> None:
"""按题目全量知识点逐点调整掌握度(答对加分、答错扣分,封顶 0-100)。"""
plus, minus = MASTERY_DELTA.get(mode, (4, 3))
for knowledge_name, is_correct in answered:
knowledge = ensure_knowledge(db, knowledge_name)
row = ensure_user_knowledge(db, user_id, knowledge)
row.mastery = max(
0, min(100, row.mastery + (plus if is_correct else -minus))
)

View File

@ -0,0 +1,89 @@
"""题目引擎:按章节动态组卷(掌握度缺口 + 错题 + 轮换扰动)。"""
from __future__ import annotations
import json
from sqlalchemy.orm import Session
from sqlalchemy import func
from models import (
AttemptItem,
Chapter,
ErrorEntry,
Knowledge,
Question,
UserKnowledge,
)
from services.knowledge_service import question_knowledge_names
def load_questions(db: Session, ids: list[int]) -> list[Question]:
rows = db.query(Question).filter(Question.id.in_(ids)).all()
by_id = {row.id: row for row in rows}
return [by_id[qid] for qid in ids if qid in by_id]
def chapter_question_ids(
db: Session,
user_id: int,
chapter_id: int,
limit: int = 5,
variant: int = 0,
) -> list[int]:
"""按章节动态组卷:只取该章题库,不足则返回实际题量,不从其他章节补题。
权重 = 掌握度缺口 + 错题加成 + 上次已做惩罚 + 轮换扰动,避免每轮完全相同。
"""
chapter = db.get(Chapter, chapter_id)
if chapter is None:
return []
knowledge_map = {
knowledge.name: user_knowledge.mastery
for user_knowledge, knowledge in (
db.query(UserKnowledge, Knowledge)
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
.filter(UserKnowledge.user_id == user_id)
.all()
)
}
error_counts: dict[str, int] = {}
for primary, raw_names in (
db.query(ErrorEntry.knowledge_name, ErrorEntry.knowledge_names)
.filter(ErrorEntry.user_id == user_id)
.all()
):
try:
names = json.loads(raw_names or "[]")
if not isinstance(names, list) or not names:
names = [primary]
except (TypeError, ValueError):
names = [primary]
for name in {str(name) for name in names}:
error_counts[name] = error_counts.get(name, 0) + 1
attempt_counts = dict(
db.query(AttemptItem.question_id, func.count(AttemptItem.id))
.filter(AttemptItem.user_id == user_id, AttemptItem.question_id.isnot(None))
.group_by(AttemptItem.question_id)
.all()
)
chapter_questions = (
db.query(Question).filter(Question.chapter_id == chapter_id).all()
)
ranked = []
for question in chapter_questions:
tags = question_knowledge_names(db, question)
knowledge_deficit = max(
(110 - knowledge_map.get(tag, 60) for tag in tags),
default=50,
)
errors = sum(error_counts.get(tag, 0) for tag in tags)
repeated = attempt_counts.get(question.id, 0) * 22
jitter = (question.id * 7 + variant * 13) % 19
weight = knowledge_deficit + errors * 12 - repeated + jitter
ranked.append((weight, question))
ranked.sort(key=lambda pair: (-pair[0], pair[1].id))
return [q.id for _, q in ranked[:limit]]

View File

@ -0,0 +1,320 @@
"""按教材章节调用大模型生成客观题,并做结构化校验后入库。"""
from __future__ import annotations
from models import Chapter, Knowledge, LlmSetting, Question, Textbook
from services import llm_client
SYSTEM_PROMPT = (
"你是一位经验丰富的高中数学教师和命题人,擅长编写规范、严谨、"
"区分度良好的单项选择题。所有题面必须自洽且答案唯一。"
)
def build_user_prompt(
*,
textbook_name: str,
chapter_name: str,
chapter_summary: str,
count: int,
difficulty: int,
knowledge_name: str,
instructions: str,
available_knowledge: list[str],
need_figure: bool = False,
) -> str:
difficulty_text = {1: "基础识记与直接计算", 2: "概念理解与中等计算", 3: "综合推理与易错辨析"}[
difficulty
]
knowledge_hint = (
f"知识点限定为“{knowledge_name}”。"
if knowledge_name
else "知识点由你依据章节内容选择,尽量使用下面给出的知识点名称;"
"若下面列表没有合适名称,可自拟简洁的中文知识点名。"
)
extra = f"额外要求:{instructions}" if instructions else ""
figure_requirement = (
(
"配图要求:每题必须在 image_svg 字段中给出与题意对应的原创 SVG 图像"
"(只画该题需要的函数图像/几何示意图,含坐标轴与必要标注),"
"SVG 用单行字符串输出(内部换行用 \\n),不要用 Markdown 图片或外链;"
"题干中不要再重复插入任何图片标记。"
)
if need_figure
else "配图要求:默认不配图,image_svg 字段填空字符串 \"\"。"
)
figure_hint = f"\n{figure_requirement}\n" if need_figure else f"\n{figure_requirement}"
return f"""请围绕以下教材章节命制 {count} 道高中数学单选题。
教材:{textbook_name}
章节:{chapter_name}
章节内容提示:{chapter_summary or "请结合章节标题自行判断"}
难度:{difficulty_text}
{knowledge_hint}
本系统已有知识点:{", ".join(available_knowledge) or "(暂无)"}
{extra}
{figure_hint}
硬性要求:
1. 每题必须恰好 4 个选项,且只有一个正确选项;
2. 不使用“以上都对”“以上都错”这类选项;
3. 涉及数学符号、公式时必须用 LaTeX:行内公式写 $...$,独立公式写 $$...$$,
不要使用 x²、x³、√、¼ 这类 Unicode 记号,例如 $x^2$、$\\sqrt{{x+1}}$、
$\\frac{{1}}{{x^2-9}}$、$f(-x)=-f(x)$;
4. 图像只通过 JSON 的 image_svg 字段提供:配图要求开启时,每题必须有与题目
语义一致的原创 SVG 图(函数图或几何示意图,含坐标轴/关键标注),
题干中不得再写任何 Markdown 图片;未开启配图时不要输出 image_svg;
5. explanation 用 1-2 句中文解释关键思路,便于学生理解,含公式时同样用 LaTeX;
6. 不要输出多余文字,只输出 JSON:
{{"questions":[
{{"stem":"题干","options":["A选项","B选项","C选项","D选项"],
"correct_index":0,"explanation":"解析","knowledge_name":"知识点","difficulty":{difficulty},
"image_svg":"<svg xmlns=\\"http://www.w3.org/2000/svg\\" ...></svg> 或空字符串"}}
]}}"""
def _extract_json(content: str) -> dict:
from services.json_utils import extract_json_lax
return extract_json_lax(content)
def _normalize_item(raw: dict, fallback_knowledge: str, difficulty: int) -> dict:
stem = str(raw.get("stem", "")).strip()
options = raw.get("options") or []
options = [str(option).strip() for option in options]
explanation = str(raw.get("explanation", "")).strip()
knowledge_name = str(raw.get("knowledge_name", "")).strip() or fallback_knowledge
image_svg = str(raw.get("image_svg") or "").strip()
# 部分兼容网关会对 JSON 内的 SVG 引号/换行做双重转义
image_svg = image_svg.replace("\\\\", "\\")
image_svg = (
image_svg.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\r", "\r")
)
if not stem:
raise ValueError("模型返回的题干为空")
if len(options) != 4 or any(not option for option in options):
raise ValueError(f"选项必须是 4 个非空选项,实际为 {len(options)} 个")
if len(set(options)) != 4:
raise ValueError("选项存在重复")
try:
correct_index = int(raw.get("correct_index", -1))
except (TypeError, ValueError) as exc:
raise ValueError("correct_index 必须是数字") from exc
if correct_index < 0 or correct_index >= 4:
raise ValueError("correct_index 超出选项范围")
return {
"stem": stem,
"options": options,
"correct_index": correct_index,
"explanation": explanation or "请依据本章定义与性质重新判断。",
"knowledge_name": knowledge_name,
"difficulty": max(1, min(3, difficulty)),
"image_svg": image_svg,
}
def generate_questions(
*,
db,
setting: LlmSetting,
chapter: Chapter,
textbook: Textbook,
count: int,
difficulty: int,
knowledge_name: str,
instructions: str,
need_figure: bool = False,
) -> list[dict]:
if not setting or not setting.api_key:
raise ValueError("尚未配置大模型 API Key,请先到“模型配置”页面保存并测试")
# 带图模式先按无图生成文本题目,再逐题补 SVG,避免长 JSON 超时/截断
if need_figure:
text_items = generate_questions(
db=db,
setting=setting,
chapter=chapter,
textbook=textbook,
count=count,
difficulty=difficulty,
knowledge_name=knowledge_name,
instructions=instructions,
need_figure=False,
)
for item in text_items:
item["image_svg"] = _request_figure_svg(setting, item["stem"])
return text_items
knowledge_names = {name for (name,) in db.query(Knowledge.name).all()}
knowledge_names.update(
name for (name,) in db.query(Question.knowledge_name).all()
)
available_knowledge = sorted(knowledge_names)
prompt = build_user_prompt(
textbook_name=textbook.name,
chapter_name=chapter.name,
chapter_summary=chapter.summary,
count=count,
difficulty=difficulty,
knowledge_name=knowledge_name,
instructions=instructions,
available_knowledge=available_knowledge,
need_figure=need_figure,
)
payload = {}
last_error = ""
for attempt in range(3):
try:
content = llm_client.chat_completion(
base_url=setting.base_url,
api_key=setting.api_key,
model=setting.model,
temperature=setting.temperature,
max_tokens=_effective_max_tokens(setting, need_figure),
timeout=200.0,
messages=[
{
"role": "system",
"content": (
SYSTEM_PROMPT
+ " JSON 必须完整且闭合:字符串中的双引号写 \\\","
"反斜杠写 \\\\,不要输出多余字符。"
),
},
{"role": "user", "content": prompt},
],
)
except ValueError as exc:
last_error = str(exc)
continue
try:
payload = _extract_json(content)
break
except ValueError as exc:
last_error = str(exc)
else:
raise ValueError(
f"模型连续 3 次未返回合法 JSON,最后一次:{last_error}"
)
raw_items = payload.get("questions")
if not isinstance(raw_items, list) or not raw_items:
raise ValueError("模型返回内容中没有 questions 列表")
generated: list[dict] = []
for raw in raw_items[:count]:
item = _normalize_item(raw, knowledge_name or "章节综合", difficulty)
if need_figure and (
"<svg" not in item["image_svg"].lower()
or "</svg>" not in item["image_svg"].lower()
):
# 补图:只让模型针对该题生成一张 SVG,缩短输出降低截断概率
for _ in range(3):
svg_content = llm_client.chat_completion(
base_url=setting.base_url,
api_key=setting.api_key,
model=setting.model,
temperature=0.1,
max_tokens=max(
int(setting.max_tokens)
if setting.max_tokens
else 3000,
1200,
),
timeout=120.0,
messages=[
{
"role": "system",
"content": "只输出 JSON:{\"image_svg\":\"<svg ...>\"}",
},
{
"role": "user",
"content": (
f"请为下列题干生成与题意一致的原创 SVG 示意图:\n{item['stem']}"
),
},
],
)
try:
svg_payload = _extract_json(svg_content)
svg_value = str(svg_payload.get("image_svg") or "")
svg_value = svg_value.replace("\\\\", "\\")
svg_value = (
svg_value.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\r", "\r")
)
if (
"<svg" in svg_value.lower()
and "</svg>" in svg_value.lower()
):
item["image_svg"] = svg_value
break
except ValueError:
continue
if (
"<svg" not in item["image_svg"].lower()
or "</svg>" not in item["image_svg"].lower()
):
raise ValueError(
"模型未能生成本题有效配图,请尝试减少题量或更换模型通道"
)
generated.append(item)
return generated
def _request_figure_svg(setting: LlmSetting, stem: str) -> str:
"""针对单一题干请求一张尽量简单的 SVG 配图。"""
max_tokens = int(setting.max_tokens) if setting.max_tokens else 1600
for _ in range(3):
try:
content = llm_client.chat_completion(
base_url=setting.base_url,
api_key=setting.api_key,
model=setting.model,
temperature=0.1,
max_tokens=max_tokens,
timeout=300.0,
messages=[
{
"role": "system",
"content": (
"你是数学示意图生成器。只输出 JSON:"
'{"image_svg":"<svg ...></svg>"}。SVG 必须非常简洁,'
"建议坐标轴+图形+少量标注,控制在 100 行内。"
),
},
{
"role": "user",
"content": f"请为以下题干生成与题意一致的示意图:\n{stem}",
},
],
)
except ValueError:
continue
try:
payload = _extract_json(content)
svg = str(payload.get("image_svg") or "")
svg = svg.replace("\\\\", "\\")
svg = (
svg.replace('\\"', '"')
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\r", "\r")
)
if "<svg" in svg.lower() and "</svg>" in svg.lower():
return svg
except ValueError:
continue
raise ValueError("模型未能为本题生成有效 SVG,请更换模型通道或关闭配图")
def _effective_max_tokens(setting: LlmSetting, need_figure: bool) -> int:
if getattr(setting, "max_tokens", None):
return int(setting.max_tokens)
return 3000 if need_figure else 1600

View File

@ -0,0 +1,151 @@
"""推荐引擎:找出薄弱知识点并生成“下一步任务”。"""
from __future__ import annotations
import json
from sqlalchemy.orm import Session
from models import (
AttemptItem,
Chapter,
DailyCompletion,
ErrorEntry,
Knowledge,
Question,
Textbook,
UserKnowledge,
)
from services.mastery_engine import snapshot
from services.knowledge_service import chapter_knowledge_names
def _error_counts(db: Session, user_id: int) -> dict[str, int]:
counts: dict[str, int] = {}
for primary, raw_names in (
db.query(ErrorEntry.knowledge_name, ErrorEntry.knowledge_names)
.filter(ErrorEntry.user_id == user_id)
.all()
):
try:
names = json.loads(raw_names or "[]")
if not isinstance(names, list) or not names:
names = [primary]
except (TypeError, ValueError):
names = [primary]
for item in names:
counts[str(item)] = counts.get(str(item), 0) + 1
return counts
def build_plan(db: Session, user_id: int) -> dict:
knowledge = snapshot(db, user_id)
counts = _error_counts(db, user_id)
weak = [k for k in knowledge if k.mastery < 75]
strong = [k for k in knowledge if k.mastery >= 75]
focus_names = [k.name for k in weak]
if not focus_names:
focus_names = [strong[0].name] if strong else []
focus_text = " / ".join(focus_names[:2]) or "当前知识点"
notice = f"系统根据已有答题记录,将“{focus_text}”设为当前强化重点。"
steps: list[str] = []
if weak:
group = weak[:2]
step1 = " / ".join(k.name for k in group)
steps.append(f"① 重点强化 {step1}:巩固定义 → 点的对应 → 图像性质判断。")
if len(weak) > 2:
steps.append(f"② {weak[2].name}:从表达式判断图像性质,补做针对性小题。")
else:
steps.append("② 再完成一次章节测试,检验本轮掌握情况。")
steps.append("③ 间隔复习:在后续测试中穿插旧知识,防止遗忘。")
else:
steps.append("① 保持当前节奏,进入下一章节的学习。")
steps.append("② 每周做一次章节测试,维持各知识点掌握度。")
steps.append("③ 错题清零后开始新主题,建立更完整的知识地图。")
top_error = sorted(knowledge, key=lambda k: (-counts.get(k.name, 0), k.mastery))
return {
"notice": notice,
"focus_names": focus_names,
"steps": steps,
"top_error": (
top_error[0].name
if top_error and counts.get(top_error[0].name, 0)
else None
),
}
def build_daily_recommendation(
db: Session, user_id: int
) -> dict | None:
"""推荐今日章节:无掌握度且未做过的章优先,然后按薄弱反馈/错误加权。"""
chapters = (
db.query(Chapter, Textbook)
.join(Textbook, Textbook.id == Chapter.textbook_id)
.order_by(Textbook.position.asc(), Chapter.position.asc())
.all()
)
candidates: list[dict] = []
for chapter, textbook in chapters:
question_count = (
db.query(Question.id).filter(Question.chapter_id == chapter.id).count()
)
if question_count == 0:
continue
tags = chapter_knowledge_names(db, chapter.id)
masteries = [
mastery
for mastery, in db.query(UserKnowledge.mastery)
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
.filter(
UserKnowledge.user_id == user_id,
Knowledge.name.in_(tags),
)
.all()
]
answered = (
db.query(AttemptItem.id)
.join(Question, Question.id == AttemptItem.question_id)
.filter(
AttemptItem.user_id == user_id,
Question.chapter_id == chapter.id,
)
.count()
)
candidates.append(
{
"chapter": chapter,
"textbook": textbook,
"tags": tags,
"mastery_avg": sum(masteries) / len(masteries)
if masteries
else None,
"answered": answered,
"knowledge_count": len(tags),
}
)
if not candidates:
return None
# 未开始章节优先;同等情况按章节顺序
candidates.sort(
key=lambda item: (
0 if item["answered"] == 0 and item["mastery_avg"] is None else 1,
item["mastery_avg"] if item["mastery_avg"] is not None else 50,
)
)
choice = candidates[0]
reason = (
"你还没有练习过这一章,建议从本章开始建立基础"
if choice["answered"] == 0
else "该章知识点掌握度较低,需要优先强化"
)
return {
"chapter_id": choice["chapter"].id,
"chapter_name": choice["chapter"].name,
"book_name": choice["textbook"].name,
"knowledge_names": choice["tags"],
"reason": reason,
}

View File

@ -0,0 +1,56 @@
"""密码散列与 JWT 工具。"""
from __future__ import annotations
import hashlib
import hmac
import os
import secrets
from datetime import datetime, timedelta, timezone
import jwt
PBKDF2_ITERATIONS = 200_000
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), bytes.fromhex(salt), PBKDF2_ITERATIONS
).hex()
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt}${digest}"
def verify_password(password: str, stored: str) -> bool:
try:
algorithm, iterations, salt, expected = stored.split("$")
if algorithm != "pbkdf2_sha256":
return False
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), bytes.fromhex(salt), int(iterations)
).hex()
return hmac.compare_digest(digest, expected)
except (ValueError, TypeError):
return False
def _jwt_secret() -> str:
return os.getenv("JWT_SECRET", "nex-math-dev-secret-change-me-in-production")
def create_access_token(user_id: int, expires_hours: int = 24 * 7) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"iat": now,
"exp": now + timedelta(hours=expires_hours),
}
return jwt.encode(payload, _jwt_secret(), algorithm="HS256")
def decode_access_token(token: str) -> int | None:
try:
payload = jwt.decode(token, _jwt_secret(), algorithms=["HS256"])
return int(payload["sub"])
except (jwt.PyJWTError, KeyError, ValueError):
return None

View File

@ -0,0 +1,119 @@
"""异步大模型任务执行器。"""
from __future__ import annotations
import json
import traceback
from datetime import datetime
from fastapi import HTTPException
from database import SessionLocal
from models import LlmTask
class _FakeUser:
id = 0
def run_llm_task(task_id: int, kind: str, params: dict) -> None:
db = SessionLocal()
task = db.get(LlmTask, task_id)
if task is None:
db.close()
return
task.status = "running"
task.progress = 10
task.message = "任务已开始,正在调用模型…"
db.commit()
try:
task.progress = 25
db.commit()
if kind == "generate_questions":
from routers.question_bank import generate as execute
from schemas import GenerateIn
payload = GenerateIn.model_validate(params)
task.progress = 40
task.message = "正在生成题目并解析结构…"
db.commit()
result = execute(payload=payload, db=db, _=_FakeUser())
count = len(result)
ids = [item.id for item in result]
task.progress = 100
task.status = "success"
task.message = f"成功生成 {count} 道题"
task.result = json.dumps({"count": count, "ids": ids}, ensure_ascii=False)
elif kind == "generate_chapters":
from routers.catalog import generate_catalog_chapters
from schemas import ChaptersGenerateIn
payload = ChaptersGenerateIn.model_validate(params)
task.progress = 40
task.message = "正在生成章节目录…"
db.commit()
result = execute_generate_chapters(
db, params["textbook_id"], payload
)
task.progress = 100
task.status = "success"
task.message = result.message
task.result = json.dumps(
{
"count": len(result.created),
"message": result.message,
},
ensure_ascii=False,
)
elif kind == "organize_chapters":
from routers.catalog import organize_textbook_chapters
from schemas import ChaptersOrganizeIn
payload = ChaptersOrganizeIn.model_validate(params)
task.progress = 40
task.message = "正在解析目录素材并关联知识图谱…"
db.commit()
result = organize_textbook_chapters(
textbook_id=params["textbook_id"],
payload=payload,
db=db,
_=_FakeUser(),
)
task.progress = 100
task.status = "success"
task.message = result.message
task.result = json.dumps(
{
"count": len(result.created),
"message": result.message,
},
ensure_ascii=False,
)
else:
raise ValueError(f"不支持的任务类型:{kind}")
except Exception as exc: # noqa: BLE001
db.rollback()
task = db.get(LlmTask, task_id)
task.status = "failed"
task.message = (
str(exc.detail)[:500]
if isinstance(exc, HTTPException)
else (str(exc)[:500] or exc.__class__.__name__)
)
task.progress = 100
task.result = json.dumps(
{"trace": traceback.format_exc()[-2000:]}, ensure_ascii=False
)
finally:
task.finished_at = datetime.utcnow()
db.commit()
db.close()
def execute_generate_chapters(db, textbook_id: int, payload):
from routers.catalog import generate_catalog_chapters
return generate_catalog_chapters(
textbook_id=textbook_id,
payload=payload,
db=db,
_=_FakeUser(),
)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="200" viewBox="-20 -100 240 200"><line x1="0" y1="0" x2="220" y2="0" stroke="black" stroke-width="1"/><line x1="20" y1="-90" x2="20" y2="90" stroke="black" stroke-width="1"/><polygon points="220,0 215,-3 215,3" fill="black"/><polygon points="20,-90 17,-85 23,-85" fill="black"/><text x="215" y="-5" font-size="12">x</text><text x="25" y="-85" font-size="12">y</text><polyline points="-40,-60 20,0 80,-60" fill="none" stroke="blue" stroke-width="2"/><polyline points="0,-60 60,0 120,-60" fill="none" stroke="red" stroke-width="2"/><path d="M 30 -20 L 70 -20" fill="none" stroke="green" stroke-width="1.5" stroke-dasharray="3,2"/><polygon points="70,-20 65,-23 65,-17" fill="green"/><text x="40" y="-25" font-size="10" fill="green">平移</text></svg>

After

Width:  |  Height:  |  Size: 808 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240' width='320' height='240'><rect width='320' height='240' fill='white'/><line x1='10' y1='180' x2='300' y2='180' stroke='black'/><line x1='80' y1='10' x2='80' y2='230' stroke='black'/><text x='295' y='175' font-size='12'>x</text><text x='85' y='20' font-size='12'>y</text><line x1='140' y1='20' x2='140' y2='220' stroke='gray' stroke-dasharray='4 3'/><line x1='20' y1='120' x2='300' y2='120' stroke='gray' stroke-dasharray='4 3'/><text x='145' y='35' font-size='11' fill='gray'>x=2</text><text x='250' y='115' font-size='11' fill='gray'>y=3</text><path d='M182,20 C190,35 210,65 260,90' fill='none' stroke='blue' stroke-width='2'/><path d='M20,145 C50,165 80,190 98,220' fill='none' stroke='blue' stroke-width='2'/><line x1='200' y1='50' x2='80' y2='190' stroke='red' stroke-dasharray='4 3'/><circle cx='200' cy='50' r='3' fill='red'/><circle cx='80' cy='190' r='3' fill='red'/><circle cx='140' cy='120' r='4' fill='black'/><text x='148' y='115' font-size='12'>(2,3)</text></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-100 -100 200 200"><line x1="-90" y1="0" x2="90" y2="0" stroke="black" stroke-width="1"/><line x1="0" y1="-90" x2="0" y2="90" stroke="black" stroke-width="1"/><polygon points="90,0 85,-3 85,3" fill="black"/><polygon points="0,-90 -3,-85 3,-85" fill="black"/><text x="85" y="-5" font-size="12">x</text><text x="5" y="-85" font-size="12">y</text><polyline points="-60,-60 0,0 60,-60" fill="none" stroke="blue" stroke-width="2"/><line x1="0" y1="-80" x2="0" y2="80" stroke="red" stroke-width="1" stroke-dasharray="4,2"/><text x="5" y="75" font-size="10" fill="red">y轴</text></svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 220'><line x1='20' y1='120' x2='280' y2='120' stroke='black'/><line x1='150' y1='20' x2='150' y2='200' stroke='black'/><polygon points='280,120 272,116 272,124' fill='black'/><polygon points='150,20 146,28 154,28' fill='black'/><line x1='150' y1='20' x2='150' y2='200' stroke='red' stroke-dasharray='5,4'/><polyline points='50,20 150,120 250,20' fill='none' stroke='blue' stroke-width='2'/><text x='270' y='138'>x</text><text x='158' y='28'>y</text><text x='190' y='45' fill='blue'>f(x)=|x|</text><text x='158' y='190' fill='red'>y轴对称</text></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='220'><rect width='320' height='220' fill='white'/><line x1='20' y1='160' x2='300' y2='160' stroke='black'/><polygon points='300,160 292,156 292,164' fill='black'/><line x1='160' y1='20' x2='160' y2='200' stroke='black'/><polygon points='160,20 156,28 164,28' fill='black'/><line x1='160' y1='20' x2='160' y2='200' stroke='red' stroke-dasharray='5,4'/><polyline points='60,60 160,160 260,60' fill='none' stroke='blue' stroke-width='2'/><circle cx='110' cy='110' r='3' fill='blue'/><circle cx='210' cy='110' r='3' fill='blue'/><line x1='110' y1='110' x2='210' y2='110' stroke='gray' stroke-dasharray='4,3'/><text x='305' y='165' font-size='12'>x</text><text x='165' y='25' font-size='12'>y</text><text x='168' y='175' font-size='12'>O</text><text x='170' y='45' font-size='12' fill='red'>y轴对称</text></svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='-100 -100 200 200' width='200' height='200'><defs><marker id='arrow' viewBox='0 0 10 10' refX='5' refY='5' markerWidth='6' markerHeight='6' orient='auto-start-reverse'><path d='M 0 0 L 10 5 L 0 10 z' fill='#333'/></marker></defs><line x1='-90' y1='0' x2='90' y2='0' stroke='#333' stroke-width='1.5' marker-end='url(#arrow)' marker-start='url(#arrow)'/><line x1='0' y1='-90' x2='0' y2='90' stroke='#333' stroke-width='1.5' marker-end='url(#arrow)' marker-start='url(#arrow)'/><text x='95' y='5' font-size='10' fill='#333'>x</text><text x='-5' y='-95' font-size='10' fill='#333'>y</text><line x1='-80' y1='60' x2='60' y2='-80' stroke='#e74c3c' stroke-width='2.5'/><text x='-75' y='55' font-size='11' fill='#e74c3c' font-weight='bold'>y=kx+b</text><text x='10' y='-10' font-size='10' fill='#333'>O</text><text x='30' y='-55' font-size='9' fill='#2c3e50'>k&lt;0</text><text x='-55' y='-30' font-size='9' fill='#2c3e50'>b&gt;0</text></svg>

After

Width:  |  Height:  |  Size: 983 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' width='320' height='220'><rect width='320' height='220' fill='white'/><line x1='30' y1='180' x2='280' y2='180' stroke='black' stroke-width='1.5'/><line x1='160' y1='20' x2='160' y2='200' stroke='black' stroke-width='1.5'/><polygon points='280,180 272,176 272,184' fill='black'/><polygon points='160,20 156,28 164,28' fill='black'/><text x='285' y='185' font-size='12'>x</text><text x='165' y='25' font-size='12'>y</text><polyline points='40,40 100,120 190,120 250,40' fill='none' stroke='blue' stroke-width='2.5'/><circle cx='100' cy='120' r='3' fill='red'/><circle cx='190' cy='120' r='3' fill='red'/><line x1='100' y1='120' x2='190' y2='120' stroke='red' stroke-width='2' stroke-dasharray='4 3'/><text x='118' y='110' font-size='12' fill='red'>最小值=3</text><text x='85' y='195' font-size='11'>-2</text><text x='185' y='195' font-size='11'>1</text><text x='250' y='35' font-size='12' fill='blue'>f(x)</text></svg>

After

Width:  |  Height:  |  Size: 981 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'><rect width='320' height='220' fill='white'/><line x1='20' y1='140' x2='300' y2='140' stroke='black' stroke-width='2'/><line x1='160' y1='20' x2='160' y2='200' stroke='black' stroke-width='2'/><polygon points='300,140 292,136 292,144' fill='black'/><polygon points='160,20 156,28 164,28' fill='black'/><polyline points='60,40 160,140 260,40' fill='none' stroke='blue' stroke-width='3'/><line x1='160' y1='20' x2='160' y2='200' stroke='red' stroke-width='2' stroke-dasharray='5,4'/><line x1='110' y1='90' x2='210' y2='90' stroke='gray' stroke-dasharray='4,3'/><circle cx='110' cy='90' r='3' fill='black'/><circle cx='210' cy='90' r='3' fill='black'/><text x='170' y='35' font-size='14' fill='red'>y轴</text><text x='225' y='60' font-size='14' fill='blue'>y=|x|</text><text x='290' y='158' font-size='14'>x</text></svg>

After

Width:  |  Height:  |  Size: 905 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220'><rect width='320' height='220' fill='white'/><line x1='20' y1='150' x2='300' y2='150' stroke='black' stroke-width='1.5'/><line x1='100' y1='20' x2='100' y2='200' stroke='black' stroke-width='1.5'/><polygon points='300,150 292,146 292,154' fill='black'/><polygon points='100,20 96,28 104,28' fill='black'/><text x='295' y='165' font-size='12'>x</text><text x='108' y='28' font-size='12'>y</text><text x='92' y='165' font-size='10'>O</text><path d='M40 90 L100 150 L160 90' fill='none' stroke='#888' stroke-width='2' stroke-dasharray='4 3'/><path d='M100 0 L160 60 L220 0' fill='none' stroke='#1f77b4' stroke-width='2.5'/><circle cx='160' cy='60' r='3' fill='#1f77b4'/><text x='168' y='58' font-size='11' fill='#1f77b4'>(2,3)</text><text x='165' y='100' font-size='11' fill='#888'>y=|x|</text><text x='225' y='20' font-size='11' fill='#1f77b4'>y=|x-2|+3</text><path d='M105 145 L152 70' stroke='#d62728' stroke-width='1.5' stroke-dasharray='3 3'/><polygon points='155,65 148,72 156,74' fill='#d62728'/><text x='105' y='115' font-size='10' fill='#d62728'>右2 上3</text></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300' width='400' height='300'><defs><marker id='arrow' viewBox='0 0 10 10' refX='5' refY='5' markerWidth='6' markerHeight='6' orient='auto-start-reverse'><path d='M 0 0 L 10 5 L 0 10 z' fill='#333'/></marker></defs><line x1='40' y1='260' x2='360' y2='260' stroke='#333' stroke-width='1.5' marker-end='url(#arrow)'/><line x1='168' y1='40' x2='168' y2='287' stroke='#333' stroke-width='1.5' marker-end='url(#arrow)'/><line x1='104' y1='67.5' x2='296' y2='232.5' stroke='#2a6' stroke-width='2.5'/><circle cx='232' cy='177.5' r='4' fill='#d44'/><circle cx='104' cy='67.5' r='4' fill='#d44'/><text x='236' y='172' font-size='13' font-family='sans-serif' fill='#333'>A(1,2)</text><text x='92' y='62' font-size='13' font-family='sans-serif' fill='#333'>B(-1,6)</text><text x='210' y='130' font-size='13' font-family='sans-serif' fill='#2a6' font-style='italic'>y=-2x+4</text><text x='360' y='278' font-size='12' font-family='sans-serif' fill='#333'>x</text><text x='172' y='38' font-size='12' font-family='sans-serif' fill='#333'>y</text><text x='168' y='278' font-size='12' font-family='sans-serif' fill='#333'>O</text></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'>...</svg>

After

Width:  |  Height:  |  Size: 71 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'><rect width='320' height='220' fill='white'/><line x1='20' y1='180' x2='300' y2='180' stroke='black' stroke-width='1.5'/><line x1='130' y1='20' x2='130' y2='200' stroke='black' stroke-width='1.5'/><polygon points='300,180 292,176 292,184' fill='black'/><polygon points='130,20 126,28 134,28' fill='black'/><text x='305' y='184' font-size='12'>x</text><text x='134' y='24' font-size='12'>y</text><line x1='40' y1='120' x2='250' y2='120' stroke='gray' stroke-dasharray='4 3'/><text x='255' y='124' font-size='12'>y=3</text><polyline points='40,40 100,120 190,120 250,40' fill='none' stroke='blue' stroke-width='2.5'/><circle cx='100' cy='120' r='3' fill='red'/><circle cx='190' cy='120' r='3' fill='red'/><text x='68' y='138' font-size='11' fill='red'>(-1,3)</text><text x='195' y='138' font-size='11' fill='red'>(2,3)</text><text x='150' y='35' font-size='11' fill='blue'>f(x)=|x-2|+|x+1|</text><text x='96' y='194' font-size='10'>-1</text><text x='186' y='194' font-size='10'>2</text><text x='122' y='194' font-size='10'>0</text></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="400" height="300">
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="black"/></marker></defs>
<line x1="20" y1="250" x2="380" y2="250" stroke="black" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="200" y1="280" x2="200" y2="20" stroke="black" stroke-width="1.5" marker-end="url(#arrow)"/>
<polyline points="80,10 120,90 280,90 320,10" fill="none" stroke="blue" stroke-width="2"/>
<circle cx="120" cy="90" r="3" fill="black"/>
<circle cx="280" cy="90" r="3" fill="black"/>
<text x="120" y="265" font-family="serif" font-size="14" text-anchor="middle">-2</text>
<text x="280" y="265" font-family="serif" font-size="14" text-anchor="middle">2</text>
<text x="185" y="95" font-family="serif" font-size="14" text-anchor="end">4</text>
<text x="90" y="80" font-family="serif" font-size="12" text-anchor="middle">(-2, 4)</text>
<text x="310" y="80" font-family="serif" font-size="12" text-anchor="middle">(2, 4)</text>
<text x="380" y="265" font-family="serif" font-size="14" font-style="italic">x</text>
<text x="210" y="25" font-family="serif" font-size="14" font-style="italic">y</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 260' width='360' height='260'><rect width='360' height='260' fill='white'/><line x1='40' y1='180' x2='320' y2='180' stroke='black' stroke-width='2'/><line x1='180' y1='20' x2='180' y2='240' stroke='black' stroke-width='2'/><polygon points='320,180 312,176 312,184' fill='black'/><polygon points='180,20 176,28 184,28' fill='black'/><text x='325' y='185' font-size='14'>x</text><text x='185' y='25' font-size='14'>y</text><polyline points='90,45 105,86.25 120,120 135,146.25 150,165 165,176.25 180,180 195,176.25 210,165 225,146.25 240,120 255,86.25 270,45' fill='none' stroke='blue' stroke-width='2'/><line x1='120' y1='120' x2='120' y2='180' stroke='gray' stroke-dasharray='4 3'/><line x1='120' y1='120' x2='180' y2='120' stroke='gray' stroke-dasharray='4 3'/><line x1='240' y1='120' x2='240' y2='180' stroke='gray' stroke-dasharray='4 3'/><line x1='240' y1='120' x2='180' y2='120' stroke='gray' stroke-dasharray='4 3'/><circle cx='120' cy='120' r='4' fill='red'/><circle cx='240' cy='120' r='4' fill='red'/><text x='245' y='112' font-size='14'>f(2)=4</text><text x='55' y='112' font-size='14'>f(-2)=?</text><text x='250' y='70' font-size='14'>y=f(x)</text><text x='185' y='130' font-size='12'>y轴对称</text></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,35 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 500 400' width='500' height='400'>
<defs>
<marker id='arrow' markerWidth='10' markerHeight='10' refX='10' refY='5' orient='auto'>
<path d='M0,0 L10,5 L0,10 Z' fill='#333'/>
</marker>
</defs>
<!-- 坐标轴 -->
<line x1='30' y1='200' x2='470' y2='200' stroke='#333' stroke-width='1.5' marker-end='url(#arrow)'/>
<line x1='250' y1='30' x2='250' y2='370' stroke='#333' stroke-width='1.5' marker-end='url(#arrow)'/>
<!-- 轴标签 -->
<text x='465' y='215' font-size='14' fill='#333'>x</text>
<text x='260' y='35' font-size='14' fill='#333'>y</text>
<!-- 原点 -->
<text x='245' y='215' font-size='12' fill='#333'>O</text>
<!-- 刻度 -->
<line x1='250' y1='198' x2='250' y2='202' stroke='#333'/>
<text x='245' y='195' font-size='11' fill='#333'>0</text>
<!-- 垂直渐近线 x=1 -->
<line x1='250' y1='30' x2='250' y2='370' stroke='#999' stroke-width='1' stroke-dasharray='5,5'/>
<text x='255' y='45' font-size='11' fill='#999'>x=1</text>
<!-- 水平渐近线 y=2 -->
<line x1='30' y1='50' x2='470' y2='50' stroke='#999' stroke-width='1' stroke-dasharray='5,5'/>
<text x='380' y='45' font-size='11' fill='#999'>y=2</text>
<!-- 对称中心 (1,2) -->
<circle cx='250' cy='50' r='4' fill='#c00'/>
<text x='260' y='45' font-size='11' fill='#c00'>(1,2)</text>
<!-- 双曲线第一支 (x>1) -->
<path d='M255,370 Q260,300 270,200 Q280,100 300,70 Q340,40 450,45'
fill='none' stroke='#0066cc' stroke-width='2'/>
<!-- 双曲线第二支 (x<1) -->
<path d='M245,30 Q240,100 230,200 Q220,300 200,330 Q160,360 50,355'
fill='none' stroke='#0066cc' stroke-width='2'/>
<!-- 函数标签 -->
<text x='400' y='80' font-size='13' fill='#0066cc'>f(x)=(2x+3)/(x-1)</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='240' viewBox='0 0 320 240'><rect width='320' height='240' fill='white'/><line x1='20' y1='120' x2='300' y2='120' stroke='black' stroke-width='1.5'/><line x1='160' y1='20' x2='160' y2='220' stroke='black' stroke-width='1.5'/><polygon points='300,120 292,116 292,124' fill='black'/><polygon points='160,20 156,28 164,28' fill='black'/><text x='295' y='138' font-size='12'>x</text><text x='168' y='28' font-size='12'>y</text><text x='150' y='135' font-size='12'>O</text><path d='M40,205 C90,190 130,150 160,120 C190,90 230,50 280,35' fill='none' stroke='blue' stroke-width='2'/><text x='210' y='70' font-size='13' fill='blue'>y=x^3</text><path d='M160,120 L280,120' stroke='red' stroke-width='3' opacity='0.35'/><text x='210' y='112' font-size='11' fill='red'>(0,+∞)</text><text x='20' y='20' font-size='12'>奇函数,x&gt;0递增</text></svg>

After

Width:  |  Height:  |  Size: 904 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='240' viewBox='0 0 320 240'><rect width='320' height='240' fill='white'/><line x1='20' y1='160' x2='300' y2='160' stroke='black'/><line x1='60' y1='220' x2='60' y2='20' stroke='black'/><polygon points='300,160 292,156 292,164' fill='black'/><polygon points='60,20 56,28 64,28' fill='black'/><polyline points='30,130 60,160 120,100' fill='none' stroke='gray' stroke-dasharray='4 3'/><polyline points='60,70 120,130 180,70' fill='none' stroke='blue' stroke-width='2'/><circle cx='120' cy='130' r='3' fill='blue'/><text x='35' y='120' font-size='12' fill='gray'>y=|x|</text><text x='185' y='75' font-size='12' fill='blue'>y=|x-2|+1</text><text x='125' y='125' font-size='11' fill='blue'>(2,1)</text><text x='115' y='175' font-size='11' fill='black'>2</text><text x='45' y='135' font-size='11' fill='black'>1</text><text x='295' y='150' font-size='12' fill='black'>x</text><text x='65' y='30' font-size='12' fill='black'>y</text></svg>

After

Width:  |  Height:  |  Size: 990 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
<polygon points="40,120 160,120 160,40" fill="none" stroke="black" stroke-width="2"/>
<rect x="150" y="110" width="10" height="10" fill="none" stroke="black" stroke-width="1"/>
<text x="25" y="135" font-size="16">A</text>
<text x="165" y="135" font-size="16">B</text>
<text x="165" y="35" font-size="16">C</text>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 360 240' width='360' height='240'><rect width='100%' height='100%' fill='white'/><line x1='20' y1='180' x2='340' y2='180' stroke='black' stroke-width='1.5'/><line x1='100' y1='220' x2='100' y2='20' stroke='black' stroke-width='1.5'/><polygon points='340,180 332,176 332,184' fill='black'/><polygon points='100,20 96,28 104,28' fill='black'/><text x='345' y='185' font-size='12'>x</text><text x='105' y='20' font-size='12'>y</text><line x1='150' y1='176' x2='150' y2='184' stroke='black'/><text x='147' y='196' font-size='10'>2</text><line x1='96' y1='105' x2='104' y2='105' stroke='black'/><text x='82' y='109' font-size='10'>3</text><path d='M50 130 L100 180 L150 130' fill='none' stroke='gray' stroke-width='2' stroke-dasharray='4 3'/><path d='M100 55 L150 105 L200 55' fill='none' stroke='blue' stroke-width='2.5'/><circle cx='100' cy='180' r='3' fill='gray'/><circle cx='150' cy='105' r='3' fill='blue'/><line x1='100' y1='180' x2='150' y2='105' stroke='red' stroke-width='1.5' stroke-dasharray='4 3'/><text x='122' y='138' font-size='12' fill='red'>右2上3</text><text x='45' y='125' font-size='12' fill='gray'>y=|x|</text><text x='205' y='50' font-size='12' fill='blue'>y=|x-2|+3</text><text x='155' y='120' font-size='10' fill='blue'>(2,3)</text></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg ...></svg>

After

Width:  |  Height:  |  Size: 15 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
<polygon points="100,30 40,120 160,120" fill="none" stroke="black" stroke-width="2"/>
<line x1="100" y1="30" x2="100" y2="120" stroke="black" stroke-width="1" stroke-dasharray="5,5"/>
<rect x="90" y="110" width="10" height="10" fill="none" stroke="black" stroke-width="1"/>
<text x="95" y="20" font-size="16">A</text>
<text x="25" y="135" font-size="16">B</text>
<text x="165" y="135" font-size="16">C</text>
<text x="105" y="80" font-size="14">D</text>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="400" height="300">
<line x1="50" y1="250" x2="350" y2="250" stroke="black" stroke-width="2"/>
<line x1="200" y1="280" x2="200" y2="20" stroke="black" stroke-width="2"/>
<polygon points="350,250 340,245 340,255" fill="black"/>
<polygon points="200,20 195,30 205,30" fill="black"/>
<line x1="100" y1="245" x2="100" y2="255" stroke="black"/>
<text x="90" y="270" font-size="14">-2</text>
<line x1="150" y1="245" x2="150" y2="255" stroke="black"/>
<text x="140" y="270" font-size="14">-1</text>
<line x1="250" y1="245" x2="250" y2="255" stroke="black"/>
<text x="245" y="270" font-size="14">1</text>
<line x1="300" y1="245" x2="300" y2="255" stroke="black"/>
<text x="295" y="270" font-size="14">2</text>
<line x1="195" y1="200" x2="205" y2="200" stroke="black"/>
<text x="175" y="205" font-size="14">1</text>
<line x1="195" y1="150" x2="205" y2="150" stroke="black"/>
<text x="175" y="155" font-size="14">2</text>
<line x1="195" y1="100" x2="205" y2="100" stroke="black"/>
<text x="175" y="105" font-size="14">3</text>
<line x1="195" y1="50" x2="205" y2="50" stroke="black"/>
<text x="175" y="55" font-size="14">4</text>
<polyline points="90,30 150,150 250,150 310,30" fill="none" stroke="blue" stroke-width="2"/>
<text x="210" y="265" font-size="14">O</text>
<text x="355" y="255" font-size="14">x</text>
<text x="205" y="15" font-size="14">y</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'><rect width='320' height='220' fill='white'/><line x1='10' y1='120' x2='290' y2='120' stroke='black'/><line x1='100' y1='210' x2='100' y2='10' stroke='black'/><text x='295' y='124' font-size='12'>x</text><text x='104' y='14' font-size='12'>y</text><text x='155' y='135' font-size='10'>2</text><polyline points='40,60 100,120 160,60' fill='none' stroke='gray' stroke-dasharray='4 3'/><polyline points='100,60 160,120 220,60' fill='none' stroke='blue' stroke-dasharray='2 3'/><polyline points='100,180 160,120 220,180' fill='none' stroke='red' stroke-width='2'/><text x='25' y='55' font-size='11' fill='gray'>y=|x|</text><text x='225' y='55' font-size='11' fill='blue'>y=|x-2|</text><text x='225' y='195' font-size='11' fill='red'>y=-|x-2|</text></svg>

After

Width:  |  Height:  |  Size: 837 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240' width='320' height='240'><rect width='320' height='240' fill='white'/><g stroke='#999' stroke-width='1'><line x1='10' y1='170' x2='310' y2='170'/><line x1='76' y1='10' x2='76' y2='230'/></g><g stroke='#1e88e5' stroke-width='2' stroke-dasharray='5 4'><line x1='160' y1='20' x2='160' y2='220'/><line x1='20' y1='120' x2='300' y2='120'/></g><path d='M20 155 L48 164 L76 178 L98 200 L111 220' fill='none' stroke='#d32f2f' stroke-width='2'/><path d='M209 20 L216 33 L244 62 L300 85' fill='none' stroke='#d32f2f' stroke-width='2'/><text x='165' y='35' font-size='12' fill='#1e88e5'>x=3</text><text x='265' y='115' font-size='12' fill='#1e88e5'>y=2</text><text x='20' y='20' font-size='12' fill='#333'>y=(2x+1)/(x-3)</text></svg>

After

Width:  |  Height:  |  Size: 784 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='340' height='220' viewBox='0 0 340 220'><rect width='100%' height='100%' fill='white'/><line x1='20' y1='160' x2='320' y2='160' stroke='black' stroke-width='1.5'/><line x1='60' y1='20' x2='60' y2='200' stroke='black' stroke-width='1.5'/><polygon points='320,160 312,156 312,164' fill='black'/><polygon points='60,20 56,28 64,28' fill='black'/><text x='325' y='165' font-size='12'>x</text><text x='65' y='25' font-size='12'>y</text><line x1='180' y1='25' x2='180' y2='195' stroke='gray' stroke-dasharray='4 3'/><text x='184' y='35' font-size='12' fill='gray'>x=2</text><path d='M186 35 C195 70 220 110 300 135' fill='none' stroke='blue' stroke-width='2'/><text x='210' y='80' font-size='12' fill='blue'>递减</text><text x='185' y='175' font-size='12' fill='gray'>(2,+∞)</text><text x='20' y='210' font-size='12'>f(x)=(ax+1)/(x-2)</text><text x='20' y='15' font-size='12'>a&gt;-1/2</text></svg>

After

Width:  |  Height:  |  Size: 943 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 220' width='320' height='220'><rect width='320' height='220' fill='white'/><line x1='20' y1='170' x2='300' y2='170' stroke='black' stroke-width='1.5'/><line x1='160' y1='30' x2='160' y2='200' stroke='black' stroke-width='1.5'/><text x='295' y='185' font-size='12'>x</text><text x='165' y='35' font-size='12'>y</text><text x='92' y='185' font-size='11'>-2</text><text x='215' y='185' font-size='11'>2</text><line x1='30' y1='120' x2='290' y2='120' stroke='blue' stroke-width='1.5'/><text x='255' y='112' font-size='12' fill='blue'>y=1</text><polyline points='40,70 70,120 100,170 130,120 160,70 190,120 220,170 250,120 280,70' fill='none' stroke='red' stroke-width='2'/><circle cx='70' cy='120' r='3' fill='black'/><circle cx='130' cy='120' r='3' fill='black'/><circle cx='190' cy='120' r='3' fill='black'/><circle cx='250' cy='120' r='3' fill='black'/><text x='10' y='65' font-size='12' fill='red'>f(x)=||x|-2|</text></svg>

After

Width:  |  Height:  |  Size: 980 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 260'>...</svg>

After

Width:  |  Height:  |  Size: 71 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'><rect width='320' height='220' fill='white'/><line x1='30' y1='180' x2='270' y2='180' stroke='black' stroke-width='1.5'/><line x1='130' y1='30' x2='130' y2='200' stroke='black' stroke-width='1.5'/><polygon points='270,180 262,176 262,184' fill='black'/><polygon points='130,30 126,38 134,38' fill='black'/><text x='275' y='184' font-size='12'>x</text><text x='136' y='34' font-size='12'>y</text><polyline points='40,54 100,126 190,126 250,54' fill='none' stroke='blue' stroke-width='2.5'/><line x1='145' y1='45' x2='145' y2='180' stroke='red' stroke-width='1.5' stroke-dasharray='4 3'/><line x1='100' y1='126' x2='190' y2='126' stroke='green' stroke-width='1.5' stroke-dasharray='4 3'/><text x='150' y='55' font-size='12' fill='red'>x=0.5</text><text x='196' y='124' font-size='12' fill='green'>m=3</text><text x='210' y='45' font-size='12' fill='blue'>f(x)</text><text x='92' y='196' font-size='11'>-1</text><text x='182' y='196' font-size='11'>2</text></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="200" viewBox="-20 -100 240 200"><line x1="0" y1="0" x2="220" y2="0" stroke="black" stroke-width="1"/><line x1="120" y1="-90" x2="120" y2="90" stroke="black" stroke-width="1"/><polygon points="220,0 215,-3 215,3" fill="black"/><polygon points="120,-90 117,-85 123,-85" fill="black"/><text x="215" y="-5" font-size="12">x</text><text x="125" y="-85" font-size="12">y</text><polyline points="40,-160 80,-80 160,-80 200,-160" fill="none" stroke="purple" stroke-width="2"/><circle cx="80" cy="-80" r="3" fill="purple"/><circle cx="160" cy="-80" r="3" fill="purple"/><text x="75" y="15" font-size="10">-1</text><text x="155" y="15" font-size="10">1</text><text x="105" y="-75" font-size="10">2</text></svg>

After

Width:  |  Height:  |  Size: 760 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 400" width="400" height="320"><rect width="500" height="400" fill="#fff"/><path d="M 100 250 L 420 250" stroke="#000" stroke-width="1.5" fill="none"/><path d="M 415 245 L 420 250 L 415 255" stroke="#000" stroke-width="1.5" fill="none"/><path d="M 200 360 L 200 30" stroke="#000" stroke-width="1.5" fill="none"/><path d="M 195 35 L 200 30 L 205 35" stroke="#000" stroke-width="1.5" fill="none"/><path d="M 140 247 L 140 253 M 260 247 L 260 253 M 320 247 L 320 253 M 380 247 L 380 253" stroke="#000" stroke-width="1.5" fill="none"/><path d="M 197 190 L 203 190" stroke="#000" stroke-width="1.5" fill="none"/><text x="135" y="270" font-family="serif" font-size="16" text-anchor="middle">-1</text><text x="260" y="270" font-family="serif" font-size="16" text-anchor="middle">1</text><text x="320" y="270" font-family="serif" font-size="16" text-anchor="middle">2</text><text x="380" y="270" font-family="serif" font-size="16" text-anchor="middle">3</text><text x="185" y="195" font-family="serif" font-size="16" text-anchor="end">1</text><text x="190" y="270" font-family="serif" font-size="16" text-anchor="end">O</text><text x="430" y="255" font-family="serif" font-size="16" font-style="italic">x</text><text x="210" y="35" font-family="serif" font-size="16" font-style="italic">y</text><polyline points="140,70 146,93.4 152,115.6 158,136.6 164,156.4 170,175 176,192.4 182,208.6 188,223.6 194,237.4 200,250 212,228.4 224,211.6 236,199.6 248,192.4 260,190 272,192.4 284,199.6 296,211.6 308,228.4 320,250 326,237.4 332,223.6 338,208.6 344,192.4 350,175 356,156.4 362,136.6 368,115.6 374,93.4 380,70" stroke="#0055ff" stroke-width="2" fill="none"/><text x="310" y="100" font-family="serif" font-size="16" fill="#0055ff">y=|x^2-2x|</text></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1 @@
<svg ...></svg>

After

Width:  |  Height:  |  Size: 15 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 200'><rect width='300' height='200' fill='white'/><line x1='20' y1='130' x2='280' y2='130' stroke='black'/><line x1='40' y1='10' x2='40' y2='180' stroke='black'/><polyline points='40,22 80,94 120,130 140,135 160,130 200,94 240,22' fill='none' stroke='blue' stroke-width='2'/><circle cx='120' cy='130' r='3' fill='red'/><circle cx='160' cy='130' r='3' fill='red'/><text x='112' y='150' font-size='12'>2</text><text x='152' y='150' font-size='12'>3</text><text x='270' y='125' font-size='12'>x</text><text x='45' y='20' font-size='12'>y</text><text x='10' y='15' font-size='12'>y=x^2-5x+6</text></svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='320' height='240' viewBox='0 0 320 240'><rect width='320' height='240' fill='white'/><line x1='20' y1='160' x2='300' y2='160' stroke='black'/><line x1='40' y1='20' x2='40' y2='220' stroke='black'/><path d='M40,40 Q160,360 280,40' fill='none' stroke='blue' stroke-width='2'/><line x1='160' y1='160' x2='160' y2='200' stroke='gray' stroke-dasharray='4 3'/><line x1='40' y1='200' x2='160' y2='200' stroke='gray' stroke-dasharray='4 3'/><circle cx='160' cy='200' r='3' fill='red'/><text x='170' y='215' font-size='12' fill='red'>(2,-1)</text><text x='165' y='155' font-size='12'>x=2</text><text x='10' y='15' font-size='12'>f(x)=x²-4x+3</text><text x='290' y='175' font-size='12'>x</text><text x='45' y='25' font-size='12'>y</text><text x='150' y='232' font-size='12' fill='red'>最小值 -1</text></svg>

After

Width:  |  Height:  |  Size: 848 B

View File

@ -0,0 +1 @@
<svg ...></svg>

After

Width:  |  Height:  |  Size: 15 B

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='360' height='120' viewBox='0 0 360 120'><line x1='20' y1='60' x2='340' y2='60' stroke='#333' stroke-width='2'/><polygon points='340,60 330,55 330,65' fill='#333'/><polygon points='20,60 30,55 30,65' fill='#333'/><line x1='180' y1='50' x2='180' y2='70' stroke='#333' stroke-width='2'/><circle cx='180' cy='60' r='5' fill='white' stroke='#d62728' stroke-width='2'/><text x='180' y='88' font-size='14' text-anchor='middle' fill='#333'>0</text><text x='180' y='28' font-size='14' text-anchor='middle' fill='#d62728'>x ≠ 0</text><text x='180' y='108' font-size='13' text-anchor='middle' fill='#333'>定义域:(-∞,0) ∪ (0,+∞)</text></svg>

After

Width:  |  Height:  |  Size: 689 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150" viewBox="0 0 200 150">
<polygon points="40,120 160,120 160,40" fill="none" stroke="black" stroke-width="2"/>
<line x1="160" y1="40" x2="88" y2="88" stroke="black" stroke-width="1" stroke-dasharray="5,5"/>
<rect x="150" y="110" width="10" height="10" fill="none" stroke="black" stroke-width="1"/>
<text x="25" y="135" font-size="16">A</text>
<text x="165" y="135" font-size="16">B</text>
<text x="165" y="35" font-size="16">C</text>
<text x="75" y="80" font-size="16">D</text>
</svg>

After

Width:  |  Height:  |  Size: 561 B

29
docker-compose.yml 100644
View File

@ -0,0 +1,29 @@
services:
backend:
image: python:3.12-slim
working_dir: /app/backend
volumes:
- ./backend:/app/backend
- ./data:/app/data
environment:
DATABASE_URL: sqlite:////app/data/math.db
CORS_ORIGINS: http://localhost:5173,http://127.0.0.1:5173
command: >
sh -c "pip install --no-cache-dir -q -r requirements.txt &&
python -m seed &&
uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
ports:
- "8000:8000"
frontend:
image: node:22-alpine
working_dir: /app/frontend
volumes:
- ./frontend:/app/frontend
environment:
VITE_API_PROXY: http://backend:8000
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
ports:
- "5173:5173"
depends_on:
- backend

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数学学习系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2813
frontend/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "math-learning-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"predev": "node scripts/copy-pdfjs-wasm.mjs",
"dev": "vite",
"prebuild": "node scripts/copy-pdfjs-wasm.mjs",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.3.4",
"antd": "^6.6.2",
"epubjs": "^0.3.93",
"katex": "^0.18.6",
"pdfjs-dist": "^6.3.289",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2",
"vite": "^8.2.2"
}
}

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -0,0 +1,16 @@
/* THIS FILE IS GENERATED - DO NOT EDIT */
async function QuickJS(moduleArg={}){var moduleRtn;var e=moduleArg,aa=import.meta.url,h="",m;try{h=(new URL(".",aa)).href}catch{}m=async a=>{a=await fetch(a,{credentials:"same-origin"});if(a.ok)return a.arrayBuffer();throw Error(a.status+" : "+a.url);};var q=console.error.bind(console),r,t=!1,u,v,w,x=!1;function y(){var a=z.buffer;A=new Int8Array(a);new Int16Array(a);B=new Uint8Array(a);new Uint16Array(a);C=new Int32Array(a);D=new Uint32Array(a);new Float32Array(a);new Float64Array(a);new BigInt64Array(a);new BigUint64Array(a)}
function E(a){e.onAbort?.(a);a=`Aborted(${a})`;q(a);t=!0;a=new WebAssembly.RuntimeError(a+". Build with -sASSERTIONS for more info.");w?.(a);throw a;}var F;async function ba(a){if(!r)try{var b=await m(a);return new Uint8Array(b)}catch{}if(a==F&&r)a=new Uint8Array(r);else throw"both async and sync fetching of the wasm failed";return a}async function ca(a,b){try{var c=await ba(a);return await WebAssembly.instantiate(c,b)}catch(d){q(`failed to asynchronously prepare wasm: ${d}`),E(d)}}
async function da(a){var b=F;if(!r)try{var c=fetch(b,{credentials:"same-origin"});return await WebAssembly.instantiateStreaming(c,a)}catch(d){q(`wasm streaming compile failed: ${d}`),q("falling back to ArrayBuffer instantiation")}return ca(b,a)}class G{name="ExitStatus";constructor(a){this.message=`Program terminated with exit(${a})`;this.status=a}}
var C,A,D,B,H=a=>{for(;a.length>0;)a.shift()(e)},I=[],J=[],ea=()=>{var a=e.preRun.shift();J.push(a)},K=!0,L=0,fa=[0,31,60,91,121,152,182,213,244,274,305,335],ha=[0,31,59,90,120,151,181,212,243,273,304,334],M={},N=a=>{if(!(a instanceof G||a=="unwind"))throw a;},O=a=>{u=a;K||L>0||(e.onExit?.(a),t=!0);throw new G(a);},ia=a=>{if(!t)try{a()}catch(b){N(b)}finally{if(!(K||L>0))try{u=a=u,O(a)}catch(b){N(b)}}},P=(a,b,c)=>{var d=B;if(c>0){c=b+c-1;for(var g=0;g<a.length;++g){var f=a.codePointAt(g);if(f<=127){if(b>=
c)break;d[b++]=f}else if(f<=2047){if(b+1>=c)break;d[b++]=192|f>>6;d[b++]=128|f&63}else if(f<=65535){if(b+2>=c)break;d[b++]=224|f>>12;d[b++]=128|f>>6&63;d[b++]=128|f&63}else{if(b+3>=c)break;d[b++]=240|f>>18;d[b++]=128|f>>12&63;d[b++]=128|f>>6&63;d[b++]=128|f&63;g++}}d[b]=0}},ja=new TextDecoder,Q=a=>{if(a){for(var b=a,c=B,d=b+void 0;c[b]&&!(b>=d);)++b;a=ja.decode(B.subarray(a,b))}else a="";return a},R=a=>{for(var b=0,c=0;c<a.length;++c){var d=a.charCodeAt(c);d<=127?b++:d<=2047?b+=2:d>=55296&&d<=57343?
(b+=4,++c):b+=3}return b},T=a=>{var b=R(a)+1,c=S(b);c&&P(a,c,b);return c};function U(){}var la=(a,b,c,d)=>{var g={string:k=>{var n=0;if(k!==null&&k!==void 0&&k!==0){n=R(k)+1;var X=V(n);P(k,X,n);n=X}return n},array:k=>{var n=V(k.length);A.set(k,n);return n}};a=e["_"+a];var f=[],p=0;if(d)for(var l=0;l<d.length;l++){var Y=g[c[l]];Y?(p===0&&(p=W()),f[l]=Y(d[l])):f[l]=d[l]}c=a(...f);return c=function(k){p!==0&&ka(p);return b==="string"?Q(k):b==="boolean"?!!k:k}(c)};
U=(a,b,c)=>{a=Q(a);b=b!==null?JSON.parse(Q(b)):[];try{let d=e.externalCall(a,b);return d?T(d):null}catch(d){return e.HEAPU8[c]=1,T(d.message)}};e.noExitRuntime&&(K=e.noExitRuntime);e.printErr&&(q=e.printErr);e.wasmBinary&&(r=e.wasmBinary);if(e.preInit)for(typeof e.preInit=="function"&&(e.preInit=[e.preInit]);e.preInit.length>0;)e.preInit.shift()();e.ccall=la;e.cwrap=(a,b,c,d)=>{var g=!c||c.every(f=>f==="number"||f==="boolean");return b!=="string"&&g&&!d?e["_"+a]:(...f)=>la(a,b,c,f,d)};
e.stringToNewUTF8=T;
var S,ma,ka,V,W,z,na={e:()=>E(""),a:()=>{K=!1;L=0},b:function(a,b){a=a<-9007199254740992||a>9007199254740992?NaN:Number(a);a=new Date(a*1E3);C[b>>2]=a.getSeconds();C[b+4>>2]=a.getMinutes();C[b+8>>2]=a.getHours();C[b+12>>2]=a.getDate();C[b+16>>2]=a.getMonth();C[b+20>>2]=a.getFullYear()-1900;C[b+24>>2]=a.getDay();var c=a.getFullYear();C[b+28>>2]=(c%4!==0||c%100===0&&c%400!==0?ha:fa)[a.getMonth()]+a.getDate()-1|0;C[b+36>>2]=-(a.getTimezoneOffset()*60);c=(new Date(a.getFullYear(),6,1)).getTimezoneOffset();
var d=(new Date(a.getFullYear(),0,1)).getTimezoneOffset();C[b+32>>2]=(c!=d&&a.getTimezoneOffset()==Math.min(d,c))|0},i:(a,b)=>{M[a]&&(clearTimeout(M[a].id),delete M[a]);if(!b)return 0;var c=setTimeout(()=>{delete M[a];ia(()=>ma(a,performance.now()))},b);M[a]={id:c,A:b};return 0},c:(a,b,c,d)=>{var g=(new Date).getFullYear(),f=(new Date(g,0,1)).getTimezoneOffset();g=(new Date(g,6,1)).getTimezoneOffset();D[a>>2]=Math.max(f,g)*60;C[b>>2]=Number(f!=g);b=p=>{var l=Math.abs(p);return`UTC${p>=0?"-":"+"}${String(Math.floor(l/
60)).padStart(2,"0")}${String(l%60).padStart(2,"0")}`};a=b(f);b=b(g);g<f?(P(a,c,17),P(b,d,17)):(P(a,d,17),P(b,c,17))},g:U,f:function(a,b){a=Q(a);try{var c=window.JSON.parse(a)}catch(d){c=a}b!==0?window.alert(a):window.console.log("DUMP",c)},d:()=>Date.now(),j:a=>{var b=B.length;a>>>=0;if(a>2147483648)return!1;for(var c=1;c<=4;c*=2){var d=b*(1+.2/c);d=Math.min(d,a+100663296);a:{d=(Math.min(2147483648,Math.ceil(Math.max(a,d)/65536)*65536)-z.buffer.byteLength+65535)/65536|0;try{z.grow(d);y();var g=1;
break a}catch(f){}g=void 0}if(g)return!0}return!1},m:function(a){a=Q(a);window.console.log(a)},h:function(a){a=Q(a);return Date.parse(a)},l:function(a,b,c,d){a=Q(a);b=Q(b);c=Q(c);c=`Quickjs -- ${a}: ${b}\n${c}`;d!==0?window.alert(c):window.console.error(c)},k:O},Z;
Z=await (async function(){function a(c){c=Z=c.exports;e._evalInSandbox=c.p;e._nukeSandbox=c.q;e._init=c.r;e._commFun=c.s;e._dumpMemoryUse=c.t;S=c.u;e._free=c.v;ma=c.w;ka=c.x;V=c.y;W=c.z;z=c.n;y();return Z}var b={a:na};if(e.instantiateWasm)return new Promise(c=>{e.instantiateWasm(b,(d,g)=>{c(a(d,g))})});F??=e.locateFile?e.locateFile?e.locateFile("quickjs-eval.wasm",h):h+"quickjs-eval.wasm":(new URL("quickjs-eval.wasm",import.meta.url)).href;return function(c){return a(c.instance)}(await da(b))}());
(function(){function a(){e.calledRun=!0;if(!t){x=!0;Z.o();v?.(e);e.onRuntimeInitialized?.();if(e.postRun)for(typeof e.postRun=="function"&&(e.postRun=[e.postRun]);e.postRun.length;){var b=e.postRun.shift();I.push(b)}H(I)}}if(e.preRun)for(typeof e.preRun=="function"&&(e.preRun=[e.preRun]);e.preRun.length;)ea();H(J);e.setStatus?(e.setStatus("Running..."),setTimeout(()=>{setTimeout(()=>e.setStatus(""),1);a()},1)):a()})();x?moduleRtn=e:moduleRtn=new Promise((a,b)=>{v=a;w=b});
;return moduleRtn}export default QuickJS;

Binary file not shown.

View File

@ -0,0 +1,34 @@
/**
* pdf.js 解码 JBIG2 / JPEG2000 扫描页时会在 worker 里按 wasmUrl 取 wasm 运行时,
* 这个目录不由打包器管理,所以每次 dev/build 前把 node_modules 里的文件同步到 public。
*/
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontend = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const from = join(frontend, "node_modules/pdfjs-dist/wasm");
const to = join(frontend, "public/pdfjs-wasm");
mkdirSync(to, { recursive: true });
const files = readdirSync(from).filter((name) => !name.startsWith("LICENSE"));
let copied = 0;
for (const name of files) {
const source = join(from, name);
const dest = join(to, name);
// 内容没变就不重写:dev server 在跑时改 public 里的同名文件会触发整页刷新并弹覆盖层
if (existsSync(dest) && readFileSync(dest).equals(readFileSync(source))) continue;
copyFileSync(source, dest);
copied += 1;
}
console.log(
copied
? `[pdfjs-wasm] 已同步 ${copied}/${files.length} 个文件 -> public/pdfjs-wasm`
: "[pdfjs-wasm] public/pdfjs-wasm 已是最新",
);

View File

@ -0,0 +1,387 @@
import { useEffect, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { App as AntdApp, ConfigProvider } from "antd";
import enUS from "antd/locale/en_US";
import zhCN from "antd/locale/zh_CN";
import Layout, {
defaultViewForRole,
isAllowedView,
} from "./components/Layout";
import type { ViewId } from "./nav";
import { buildTheme } from "./theme";
import { PrefsProvider, usePrefs } from "./prefs";
import { clearAuth, readAuth, saveAuth } from "./api/client";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import KnowledgeMap from "./pages/KnowledgeMap";
import ChapterTest from "./pages/ChapterTest";
import Errors from "./pages/Errors";
import Records from "./pages/Records";
import Resources from "./pages/Resources";
import Daily from "./pages/Daily";
import UsersManage from "./pages/UsersManage";
import CatalogManage from "./pages/CatalogManage";
import CourseManage from "./pages/CourseManage";
import QuestionBank from "./pages/QuestionBank";
import LlmSettings from "./pages/LlmSettings";
import KnowledgeGraph from "./pages/KnowledgeGraph";
import Settings from "./pages/Settings";
import EbookReader from "./pages/EbookReader";
interface AuthState {
token: string;
role: string;
display_name: string;
}
/** 当前视图持久化:刷新后停在本页,不再回到默认首页。 */
const VIEW_KEY = "nex_math_view";
function readStoredView(): ViewId | null {
try {
return localStorage.getItem(VIEW_KEY) as ViewId | null;
} catch {
return null;
}
}
function rememberView(view: ViewId) {
try {
localStorage.setItem(VIEW_KEY, view);
} catch {
// 隐私模式下写入失败时忽略
}
}
/**
* 阅读器打开的是哪本书、要不要定位到某一页:与视图一起持久化,
* 刷新后能续读同一本,章节跳转的定位也还在。
*/
const READER_KEY = "nex_math_reader";
interface ReaderTarget {
id: number;
/** 章节定位页;0 表示按上次进度续读 */
page: number;
}
function readStoredReader(): ReaderTarget | null {
try {
const raw = localStorage.getItem(READER_KEY);
if (!raw) return null;
// 早期版本只存教材 id,兼容读取
if (!raw.startsWith("{")) {
const legacy = Number(raw);
return Number.isInteger(legacy) && legacy > 0 ? { id: legacy, page: 0 } : null;
}
const parsed = JSON.parse(raw) as Partial<ReaderTarget>;
const id = Number(parsed.id);
if (!Number.isInteger(id) || id <= 0) return null;
const page = Number(parsed.page);
return { id, page: Number.isFinite(page) && page > 0 ? page : 0 };
} catch {
return null;
}
}
function rememberReader(target: ReaderTarget | null) {
try {
if (target) localStorage.setItem(READER_KEY, JSON.stringify(target));
else localStorage.removeItem(READER_KEY);
} catch {
// 同上
}
}
interface PageContext {
questionBankPreset?: { textbook_id?: number; chapter_id?: number } | null;
testChapterId?: number | null;
/** 从总览分布图例带进来的掌握度档,为空表示不筛选 */
masteryBand: string | null;
/** 阅读器打开的教材 id,为空表示没有可用的阅读目标 */
readerId: number | null;
/** 打开时定位的页码,为空表示按上次进度续读 */
readerPage: number | null;
/** 打开电子书;page 为章节定位(PDF 页码 / EPUB 节序号),不传则续读上次进度 */
openReader: (textbookId: number, page?: number) => void;
exitReader: () => void;
/** 总览里点分布图例 / 「查看知识地图」:带着档位进知识地图 */
openKnowledgeMap: (band: string | null) => void;
}
function pageFor(
view: ViewId,
navigate: (view: ViewId) => void,
ctx: PageContext,
auth: AuthState,
setAuth: (updater: (current: AuthState | null) => AuthState) => void,
setReloadTick: (updater: (current: number) => number) => void,
openChapterQuestions: (textbookId: number, chapterId: number) => void,
startChapterTest: (chapterId: number) => void,
): ReactElement {
switch (view) {
case "settings":
return (
<Settings
role={auth.role}
displayName={auth.display_name}
onDisplayNameChange={(displayName) => {
setAuth((current) => {
const next = { ...(current as AuthState), display_name: displayName };
saveAuth(next);
return next;
});
}}
onDataImported={() => setReloadTick((value) => value + 1)}
/>
);
case "knowledge":
return (
<KnowledgeMap
key={`knowledge-${ctx.masteryBand ?? ""}`}
initialBand={ctx.masteryBand}
onStartChapterTest={startChapterTest}
onReadOnline={ctx.openReader}
/>
);
case "test":
return (
<ChapterTest
key={`test-${ctx.testChapterId ?? ""}`}
initialChapterId={ctx.testChapterId}
onNavigate={navigate}
onReadOnline={ctx.openReader}
/>
);
case "errors":
return <Errors />;
case "records":
return <Records />;
case "daily":
return (
<Daily
onStartTest={startChapterTest}
onReadOnline={ctx.openReader}
onNavigate={navigate}
/>
);
case "resources":
return (
<Resources
onStartChapterTest={startChapterTest}
onReadOnline={ctx.openReader}
/>
);
case "users":
return <UsersManage />;
case "catalog":
return (
<CatalogManage
onOpenQuestions={openChapterQuestions}
onReadOnline={ctx.openReader}
/>
);
case "courses":
return <CourseManage />;
case "reader":
if (ctx.readerId === null)
return (
<Dashboard
onNavigate={navigate}
onOpenKnowledgeMap={ctx.openKnowledgeMap}
/>
);
return (
<EbookReader
textbookId={ctx.readerId}
page={ctx.readerPage ?? undefined}
onExit={ctx.exitReader}
/>
);
case "bank":
return (
<QuestionBank
key={`bank-${ctx.questionBankPreset?.textbook_id ?? ""}-${
ctx.questionBankPreset?.chapter_id ?? ""
}`}
onNavigate={navigate}
preset={ctx.questionBankPreset}
/>
);
case "llm":
return <LlmSettings />;
case "graph":
return <KnowledgeGraph />;
case "dashboard":
return (
<Dashboard
onNavigate={navigate}
onOpenKnowledgeMap={ctx.openKnowledgeMap}
/>
);
}
return (
<Dashboard onNavigate={navigate} onOpenKnowledgeMap={ctx.openKnowledgeMap} />
);
}
/** antd 全局上下文:界面语言与明暗主题随偏好实时切换。 */
function Providers({ children }: { children: ReactNode }) {
const { mode, lang } = usePrefs();
return (
<ConfigProvider
locale={lang === "en" ? enUS : zhCN}
theme={buildTheme(mode)}
>
<AntdApp>{children}</AntdApp>
</ConfigProvider>
);
}
function Root() {
const [auth, setAuth] = useState<AuthState | null>(() => {
const stored = readAuth();
return stored
? {
token: stored.token,
role: stored.role,
display_name: stored.display_name,
}
: null;
});
const [view, setView] = useState<ViewId>(() => {
const stored = readAuth();
const role = stored?.role ?? "student";
const last = readStoredView();
// 越权、已下线,或只剩阅读器却没有书,都回默认首页
if (last === "reader" && !readStoredReader()) return defaultViewForRole(role);
return last && isAllowedView(last, role) ? last : defaultViewForRole(role);
});
const [questionBankPreset, setQuestionBankPreset] = useState<
{ textbook_id?: number; chapter_id?: number } | null
>(null);
const [testChapterId, setTestChapterId] = useState<number | null>(null);
const [masteryBand, setMasteryBand] = useState<string | null>(null);
const [reader, setReader] = useState<ReaderTarget | null>(readStoredReader);
// 导入学习档案后自增,用于重挂页面以刷新全部数据
const [reloadTick, setReloadTick] = useState(0);
useEffect(() => {
if (auth) rememberView(view);
}, [auth, view]);
useEffect(() => {
const onExpired = () => {
setAuth(null);
setReader(null);
setView(defaultViewForRole("student"));
};
window.addEventListener("auth:expired", onExpired);
return () => window.removeEventListener("auth:expired", onExpired);
}, []);
function handleLogin(next: AuthState) {
saveAuth(next);
setAuth(next);
setReader(null);
rememberReader(null);
setView(defaultViewForRole(next.role));
}
function navigate(viewId: ViewId) {
if (viewId !== "bank") setQuestionBankPreset(null);
if (viewId !== "test") setTestChapterId(null);
if (viewId !== "knowledge") setMasteryBand(null);
if (viewId !== "reader") {
setReader(null);
rememberReader(null);
}
setView(viewId);
}
function openReader(textbookId: number, page = 0) {
const target = { id: textbookId, page: page > 0 ? Math.round(page) : 0 };
setReader(target);
rememberReader(target);
setView("reader");
}
function exitReader() {
setReader(null);
rememberReader(null);
setView(auth?.role === "admin" ? "catalog" : "resources");
}
function openChapterQuestions(textbookId: number, chapterId: number) {
setQuestionBankPreset({ textbook_id: textbookId, chapter_id: chapterId });
setView("bank");
}
function openKnowledgeMap(band: string | null) {
setMasteryBand(band);
setView("knowledge");
}
function openChapterTest(chapterId: number) {
setTestChapterId(chapterId);
setView("test");
}
function handleLogout() {
clearAuth();
setAuth(null);
}
if (!auth) {
return (
<Providers>
<Login onSuccess={handleLogin} />
</Providers>
);
}
// 角色不可见的视图兜底回首页,避免直接访问越权页面出现空面板
const safeView = isAllowedView(view, auth.role) ? view : defaultViewForRole(auth.role);
return (
<Providers>
<Layout
view={safeView}
role={auth.role}
displayName={auth.display_name}
onNavigate={navigate}
onLogout={handleLogout}
>
<div key={`${safeView}-${reloadTick}`} className="view-fade">
{pageFor(
safeView,
navigate,
{
questionBankPreset,
testChapterId,
masteryBand,
readerId: reader?.id ?? null,
readerPage: reader?.page ?? null,
openReader,
exitReader,
openKnowledgeMap,
},
auth,
setAuth,
setReloadTick,
openChapterQuestions,
openChapterTest,
)}
</div>
</Layout>
</Providers>
);
}
export default function App() {
return (
<PrefsProvider>
<Root />
</PrefsProvider>
);
}

View File

@ -0,0 +1,429 @@
import type {
Analytics,
AnswerPayload,
AuthResult,
ChapterItem,
ChaptersGeneratePayload,
ChaptersGenerateResult,
DailyInfo,
EbookProgress,
EbookProgressPayload,
ErrorEntry,
GeneratePayload,
LlmChannel,
LlmChannelCreatePayload,
LlmChannelUpdatePayload,
LlmSettings,
LlmTaskResult,
Overview,
PracticeSession,
QuestionBankItem,
QuestionUpdatePayload,
RecordDetail,
RecordItem,
SubmitResult,
Course,
CourseCreatePayload,
CourseUpdatePayload,
Textbook,
TextbookCreatePayload,
TextbookUpdatePayload,
UserCreatePayload,
UserInfo,
UserUpdatePayload,
KnowledgePoint,
KnowledgeDetail,
KnowledgeGraphData,
KnowledgeGraphEdge,
KnowledgeGraphNode,
KnowledgeResourceLink,
KnowledgeNodeCreatePayload,
KnowledgeNodeUpdatePayload,
KnowledgeRelationPayload,
} from "../types";
import { backendText, tr } from "../i18n";
/** 上传电子书的响应:后端解析出的篇幅用于立刻刷新卡片信息。 */
export interface EbookUploadResult {
id: number;
ebook_format: string;
ebook_name: string;
ebook_size: number;
ebook_pages: number;
}
const AUTH_KEY = "nex_math_auth";
export interface StoredAuth {
token: string;
role: string;
display_name: string;
}
export function readAuth(): StoredAuth | null {
try {
const raw = localStorage.getItem(AUTH_KEY);
return raw ? (JSON.parse(raw) as StoredAuth) : null;
} catch {
return null;
}
}
export function saveAuth(auth: StoredAuth): void {
localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
}
export function clearAuth(): void {
localStorage.removeItem(AUTH_KEY);
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const auth = readAuth();
const headers = new Headers(init?.headers);
if (auth?.token) headers.set("Authorization", `Bearer ${auth.token}`);
if (init?.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(path, { ...init, headers });
if (res.status === 401 && !path.includes("/auth/login")) {
clearAuth();
window.dispatchEvent(new Event("auth:expired"));
}
if (!res.ok) {
let detail = tr("api.requestFailed", { status: res.status });
try {
const body = await res.json();
if (body.detail) detail = backendText(String(body.detail));
} catch {
// 保留默认错误信息
}
throw new Error(detail);
}
return res.json() as Promise<T>;
}
/** 上传电子书用 XHR:要进度回调,且不能给 FormData 设 JSON Content-Type。 */
function upload<T>(
path: string,
file: File,
onProgress?: (percent: number) => void,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const auth = readAuth();
const form = new FormData();
form.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", path);
if (auth?.token) xhr.setRequestHeader("Authorization", `Bearer ${auth.token}`);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as T);
} catch {
reject(new Error(tr("api.uploadFailed")));
}
return;
}
if (xhr.status === 401) {
clearAuth();
window.dispatchEvent(new Event("auth:expired"));
}
let detail = tr("api.uploadFailedStatus", { status: xhr.status });
try {
const body = JSON.parse(xhr.responseText);
if (body.detail) detail = backendText(String(body.detail));
} catch {
// 保留默认错误信息
}
reject(new Error(detail));
};
xhr.onerror = () => reject(new Error(tr("api.networkError")));
xhr.send(form);
});
}
export const api = {
login: (username: string, password: string) =>
request<AuthResult>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
me: () => request<UserInfo>("/api/auth/me"),
changePassword: (old_password: string, new_password: string) =>
request<{ message: string }>("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({ old_password, new_password }),
}),
updateProfile: (display_name: string) =>
request<UserInfo>("/api/auth/profile", {
method: "PUT",
body: JSON.stringify({ display_name }),
}),
users: () => request<UserInfo[]>("/api/users"),
createUser: (payload: UserCreatePayload) =>
request<UserInfo>("/api/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: number, payload: UserUpdatePayload) =>
request<UserInfo>(`/api/users/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
resetPassword: (id: number, password: string) =>
request<{ message: string }>(`/api/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify({ password }),
}),
roles: () => request<{ id: number; code: string; name: string }[]>("/api/users/roles"),
resetLearning: () =>
request<{ message: string }>("/api/users/reset-learning", {
method: "POST",
}),
overview: () => request<Overview>("/api/overview"),
knowledge: () => request<KnowledgePoint[]>("/api/knowledge"),
errors: () => request<ErrorEntry[]>("/api/errors"),
records: () => request<RecordItem[]>("/api/records"),
daily: () => request<DailyInfo>("/api/daily"),
completeDaily: (chapterId: number, rating: string) =>
request<DailyInfo>("/api/daily/complete", {
method: "POST",
body: JSON.stringify({ chapter_id: chapterId, rating }),
}),
/** 不传 userId 时取本人;管理员可传 userId 查看指定用户的学习分析。 */
analytics: (userId?: number) =>
request<Analytics>(
userId === undefined
? "/api/analytics"
: `/api/analytics?user_id=${userId}`,
),
recordDetail: (id: number) => request<RecordDetail>(`/api/records/${id}`),
knowledgeDetail: (id: number) =>
request<KnowledgeDetail>(`/api/knowledge/detail/${id}`),
chapterTest: (chapterId: number, variant: number, limit = 5) =>
request<PracticeSession>(
`/api/practice/chapter/${chapterId}?limit=${limit}&variant=${variant}`,
),
submit: (
mode: string,
answers: AnswerPayload[],
chapterId?: number | null,
) =>
request<SubmitResult>("/api/practice/submit", {
method: "POST",
body: JSON.stringify({ mode, chapter_id: chapterId, answers }),
}),
exportArchive: async (): Promise<Blob> => {
const auth = readAuth();
const headers = new Headers();
if (auth?.token) headers.set("Authorization", `Bearer ${auth.token}`);
const res = await fetch("/api/archive/export", { headers });
if (!res.ok) throw new Error(tr("api.exportFailed"));
return res.blob();
},
importArchive: (payload: unknown) =>
request<{ message: string }>("/api/archive/import", {
method: "POST",
body: JSON.stringify(payload),
}),
textbooks: () => request<Textbook[]>("/api/textbooks"),
createTextbook: (payload: TextbookCreatePayload) =>
request<Textbook>("/api/textbooks", {
method: "POST",
body: JSON.stringify(payload),
}),
updateTextbook: (id: number, payload: TextbookUpdatePayload) =>
request<Textbook>(`/api/textbooks/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteTextbook: (id: number) =>
request<{ deleted: number }>(`/api/textbooks/${id}`, { method: "DELETE" }),
/** 在线课程与教材分表:只有名称 / 开课方 / 链接 / 学段。 */
courses: () => request<Course[]>("/api/courses"),
createCourse: (payload: CourseCreatePayload) =>
request<Course>("/api/courses", {
method: "POST",
body: JSON.stringify(payload),
}),
updateCourse: (id: number, payload: CourseUpdatePayload) =>
request<Course>(`/api/courses/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteCourse: (id: number) =>
request<{ deleted: number }>(`/api/courses/${id}`, { method: "DELETE" }),
ebookFileUrl: (textbookId: number, file: string) =>
`/api/textbooks/${textbookId}/ebook/file/${encodeURIComponent(file)}`,
uploadEbook: (
textbookId: number,
file: File,
onProgress?: (percent: number) => void,
) =>
upload<EbookUploadResult>(
`/api/textbooks/${textbookId}/ebook`,
file,
onProgress,
),
deleteEbook: (textbookId: number) =>
request<{ deleted: number }>(`/api/textbooks/${textbookId}/ebook`, {
method: "DELETE",
}),
ebookProgressList: () => request<EbookProgress[]>("/api/ebooks/progress"),
saveEbookProgress: (textbookId: number, payload: EbookProgressPayload) =>
request<EbookProgress>(`/api/ebooks/${textbookId}/progress`, {
method: "PUT",
body: JSON.stringify(payload),
}),
createChapter: (textbookId: number, name: string, summary: string) =>
request<ChapterItem>(`/api/textbooks/${textbookId}/chapters`, {
method: "POST",
body: JSON.stringify({ name, summary }),
}),
deleteChapter: (chapterId: number) =>
request<{ deleted: number }>(`/api/textbooks/chapters/${chapterId}`, {
method: "DELETE",
}),
updateChapter: (
chapterId: number,
payload: {
name: string;
summary: string;
/** 电子书定位;PDF 填页码,EPUB 填分节序号 */
ebook_page?: number;
knowledge_names?: string[];
knowledge_ids?: number[];
},
) =>
request<ChapterItem>(`/api/textbooks/chapters/${chapterId}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
generateChapters: (
textbookId: number,
payload: ChaptersGeneratePayload,
) =>
request<ChaptersGenerateResult>(
`/api/textbooks/${textbookId}/chapters/generate`,
{
method: "POST",
body: JSON.stringify(payload),
},
),
updateQuestion: (id: number, payload: QuestionUpdatePayload) =>
request<QuestionBankItem>(`/api/question-bank/questions/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
bankQuestions: (params: {
textbook_id?: number;
chapter_id?: number;
}) => {
const search = new URLSearchParams();
if (params.textbook_id) search.set("textbook_id", String(params.textbook_id));
if (params.chapter_id) search.set("chapter_id", String(params.chapter_id));
const query = search.toString();
return request<QuestionBankItem[]>(
`/api/question-bank/questions${query ? `?${query}` : ""}`,
);
},
deleteQuestion: (id: number) =>
request<{ deleted: number }>(`/api/question-bank/questions/${id}`, {
method: "DELETE",
}),
generateQuestions: (payload: GeneratePayload) =>
request<QuestionBankItem[]>("/api/question-bank/generate", {
method: "POST",
body: JSON.stringify(payload),
}),
llmSettings: () => request<LlmSettings>("/api/llm/settings"),
createLlmTask: (kind: string, params: Record<string, unknown>) =>
request<LlmTaskResult>("/api/llm/tasks", {
method: "POST",
body: JSON.stringify({ kind, params }),
}),
getLlmTask: (id: number) =>
request<LlmTaskResult>(`/api/llm/tasks/${id}`),
knowledgeGraph: () =>
request<KnowledgeGraphData>("/api/knowledge/graph"),
createKnowledgeNode: (payload: KnowledgeNodeCreatePayload) =>
request<KnowledgeGraphNode>("/api/knowledge/nodes", {
method: "POST",
body: JSON.stringify(payload),
}),
updateKnowledgeNode: (
id: number,
payload: KnowledgeNodeUpdatePayload,
) =>
request<KnowledgeGraphNode>(`/api/knowledge/nodes/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteKnowledgeNode: (id: number) =>
request<{ deleted: number }>(`/api/knowledge/nodes/${id}`, {
method: "DELETE",
}),
addKnowledgeRelation: (payload: KnowledgeRelationPayload) =>
request<KnowledgeGraphEdge>("/api/knowledge/relations", {
method: "POST",
body: JSON.stringify(payload),
}),
removeKnowledgeRelation: (
source: number,
target: number,
relationType: string,
) =>
request<{ deleted: number }>(
`/api/knowledge/relations?source_id=${source}&target_id=${target}&relation_type=${encodeURIComponent(relationType)}`,
{ method: "DELETE" },
),
addKnowledgeResource: (
nodeId: number,
payload: {
kind?: string;
title?: string;
url?: string;
textbook_id?: number | null;
course_id?: number | null;
},
) =>
request<KnowledgeResourceLink>(`/api/knowledge/nodes/${nodeId}/resources`, {
method: "POST",
body: JSON.stringify(payload),
}),
removeKnowledgeResource: (resourceId: number) =>
request<{ deleted: number }>(`/api/knowledge/resources/${resourceId}`, {
method: "DELETE",
}),
createLlmChannel: (payload: LlmChannelCreatePayload) =>
request<LlmChannel>("/api/llm/channels", {
method: "POST",
body: JSON.stringify(payload),
}),
updateLlmChannel: (id: number, payload: LlmChannelUpdatePayload) =>
request<LlmChannel>(`/api/llm/channels/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteLlmChannel: (id: number) =>
request<{ deleted: number }>(`/api/llm/channels/${id}`, {
method: "DELETE",
}),
setDefaultLlmChannel: (id: number) =>
request<LlmSettings>(`/api/llm/channels/${id}/default`, {
method: "PUT",
}),
testLlmChannel: (id: number) =>
request<{ ok: boolean; message: string }>(`/api/llm/channels/${id}/test`, {
method: "POST",
}),
};

Some files were not shown because too many files have changed in this diff Show More