v 0.1.0
|
|
@ -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=
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
backend/.venv/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
.DS_Store
|
||||
data/*.db
|
||||
.env
|
||||
|
|
@ -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 生成题)并重新写入种子数据;
|
||||
用户、角色、教材目录与模型配置保留。演示账号的学习历史(练习记录、错题本)
|
||||
现在包含逐题明细,可直接展开查看每题作答。
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""API 路由模块。"""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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="学习档案导入成功。",
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"],
|
||||
),
|
||||
)
|
||||
|
|
@ -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)
|
||||
],
|
||||
)
|
||||
|
|
@ -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"
|
||||
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
|
||||
|
|
@ -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} 的密码。"}
|
||||
|
|
@ -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 = "前置"
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""初始化数据:知识点、题库、示例练习记录。"""
|
||||
|
|
@ -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)。")
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""业务逻辑服务。"""
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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": []}
|
||||
|
|
@ -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 格式不正确,请重试或调整模型")
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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": "请确认服务可用。"},
|
||||
],
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
)
|
||||
|
|
@ -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]]
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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<0</text><text x='-55' y='-30' font-size='9' fill='#2c3e50'>b>0</text></svg>
|
||||
|
After Width: | Height: | Size: 983 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 400 300'>...</svg>
|
||||
|
After Width: | Height: | Size: 71 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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>0递增</text></svg>
|
||||
|
After Width: | Height: | Size: 904 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
|||
<svg ...></svg>
|
||||
|
After Width: | Height: | Size: 15 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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>-1/2</text></svg>
|
||||
|
After Width: | Height: | Size: 943 B |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 260'>...</svg>
|
||||
|
After Width: | Height: | Size: 71 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
|||
<svg ...></svg>
|
||||
|
After Width: | Height: | Size: 15 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -0,0 +1 @@
|
|||
<svg ...></svg>
|
||||
|
After Width: | Height: | Size: 15 B |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 已是最新",
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
}),
|
||||
};
|
||||