diff --git a/.env.example b/.env.example deleted file mode 100644 index 46e07193..00000000 --- a/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -# ============================================================================ -# Flask 会话密钥(必须配置) -# ============================================================================ -SECRET_KEY=your-random-secret-key-change-this - -# ============================================================================ -# LangSmith 配置(可选,用于调试追踪) -# ============================================================================ -LANGSMITH_TRACING=false -# LANGSMITH_ENDPOINT=https://api.smith.langchain.com -# LANGSMITH_API_KEY=你的_langsmith_api_key -# LANGSMITH_PROJECT="error-correction" - -# ============================================================================ -# 输出目录配置(可选) -# ============================================================================ -# 目录配置由 config.py 集中管理(默认:backend/runtime_data/) -# 如需覆盖运行时输出的根目录,请设置 APP_RUNTIME_DIR(绝对路径) -# APP_RUNTIME_DIR= - -# ============================================================================ -# 注意:API Provider 配置(OpenAI / Anthropic / PaddleOCR)已迁移到数据库, -# 每个用户在系统设置页面独立管理自己的 API Key 和供应商配置。 -# ============================================================================ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b763b395 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +backend/models/weight/*.pth filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 513c3f4a..53d02f3d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ dataset/ # 环境变量 .env +backend/.env PR.md +TODO.md diff --git a/CLAUDE.md b/CLAUDE.md index fc92d1df..9f6f3310 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,12 @@ cd frontend && npm run dev # Vite dev server, proxy /api → :5001 ### 生产构建 ```bash -cd frontend && npm run build # 输出到 backend 静态目录 -cd backend && python web_app.py # Flask 托管前端产物,访问 :5001 +cd frontend && npm run build # 构建前端产物(目前仅用于部署,Flask 不再托管) +cd backend && python web_app.py # 启动纯 API 服务,仅监听 :5001 ``` +> **注意**:`web_app.py` 已重构为纯 API 服务器,不提供前端页面。前端必须通过 Vite dev server(`:5173`)或独立部署访问,直接访问 `:5001` 只会得到 JSON 404。 + ### 测试 ```bash @@ -47,7 +49,7 @@ cd frontend && npm install # 前端(Node 18+) ### 环境配置 -复制 `.env.example` → `.env`,至少配置一组 LLM API key(DeepSeek 或 ERNIE)和 PaddleOCR token。详见 `.env.example` 注释。 +复制 `backend/.env.example` → 项目根目录 `.env`,必须配置 `SECRET_KEY`。`core/config.py` 从项目根目录读取 `.env`。LLM API Provider 配置(OpenAI / Anthropic / PaddleOCR)已迁移到数据库,用户在系统设置页面管理。SMTP 邮件配置(注册验证码、找回密码)通过 `.env` 的 `APP_SMTP_*` 变量管理。 --- @@ -60,11 +62,13 @@ cd frontend && npm install # 前端(Node 18+) ``` 用户上传文件 → prepare_input(PDF 转图片) - → PaddleOCR API(异步任务,asyncio.gather 并行) + → [可选] EnsExam 擦除手写字迹(/api/erase,独立预处理步骤) + → PaddleOCR API(/api/ocr,异步并行,返回 bbox 预览) → simplify_ocr_results()(block_label 归一化,唯一转换点) → split_batch(2 页/批滑动窗口,ThreadPoolExecutor 并行,上限 3) → LLM Agent 分割题目(invoke_split) → _dedup_questions()(跨批次去重) + → _normalize_image_paths()(修复 LLM 篡改的图片路径) → LLM Agent 纠错(invoke_correction) → 导出 / 保存数据库 ``` @@ -72,23 +76,33 @@ cd frontend && npm install # 前端(Node 18+) ### 关键模块关系 - **`backend/src/workflow.py`** — LangGraph StateGraph 主工作流,串联所有处理步骤 -- **`backend/error_correction_agent/`** — 题目分割 + OCR 纠错 Agent(`create_agent` 工厂函数) -- **`backend/solve_agent/`** — 独立的解题 Agent -- **`backend/teach_agent/`** — 教学讲解 Agent,用于 AI 分析功能 -- **`backend/web_app.py`** — Flask 主应用,路由 + 全局会话状态(`session_lock` 保护) -- **`backend/config.py`** — 所有运行时路径集中管理,`ensure_dirs()` 显式初始化 -- **`backend/llm.py`** — `init_model()` 统一 LLM 初始化,支持 deepseek / ernie -- **`backend/db/`** — SQLite + SQLAlchemy ORM,错题库持久化 -- **`frontend/src/App.vue`** — 根组件,全局状态集中于此(无 Pinia/Vuex) +- **`backend/agents/error_correction/`** — 题目分割 + OCR 纠错 Agent(`create_inner_split_agent` / `create_inner_correct_agent`) +- **`backend/agents/solve/`** — 独立的解题 Agent(`invoke_solve`) +- **`backend/agents/teach/`** — 教学讲解 Agent,流式多轮对话(`stream_teach`) +- **`backend/agents/note/`** — 笔记整理 Agent,OCR → LLM 结构化(`invoke_note_organize`) +- **`backend/web_app.py`** — Flask 应用工厂,注册 7 个 Blueprint,全局 session 状态在 `state.py` +- **`backend/routes/`** — 7 个 Blueprint 模块(upload、questions、chat、stats、auth、settings、notes) +- **`backend/core/state.py`** — 全局会话状态(`session_files`、`session_lock`) +- **`backend/core/mail.py`** — SMTP 邮件发送(注册验证码、找回密码) +- **`backend/core/config.py`** — 所有运行时路径 + LLM provider 注册集中管理,`ensure_dirs()` 显式初始化 +- **`backend/core/llm.py`** — `init_model()` 统一 LLM 初始化,支持多 provider +- **`backend/db/`** — SQLite + SQLAlchemy ORM,错题库持久化;`db/crud/providers.py` 管理用户存储的 API key ### Flask 路由 -- `GET /` → 介绍页 | `GET /app` → Vue 工作台 -- `POST /api/upload` / `/api/split` / `/api/export` / `/api/cancel_file` — 核心工作流 API -- `GET /api/status` — 系统状态(OCR 配置、可用模型列表) -- `/api/error-bank` / `/api/subjects` / `/api/question-types` / `/api/stats` — 错题库 CRUD + 统计 -- `/api/chat` / `/api/chat//messages` / `/api/chat//stream` — AI 对话(SSE 流式) -- `/api/ai-analysis` — AI 分析(teach_agent) +路由实现在 `backend/routes/` 的 7 个 Blueprint 模块中,`web_app.py` 仅做注册: + +- `POST /api/upload` / `/api/erase` / `/api/ocr` / `/api/split` / `/api/export` / `/api/cancel_file` — 核心工作流 API(upload.py) +- `GET /api/image/` — 图片资源访问(upload.py) +- `GET /api/status` — 系统状态(OCR 配置、可用模型列表)(upload.py) +- `GET /api/split-records` — 分割历史记录(upload.py) +- `/api/error-bank` / `/api/subjects` / `/api/question-types` — 错题库 CRUD(questions.py) +- `/api/stats` — 统计数据(stats.py) +- `/api/chat` / `/api/chat//messages` / `/api/chat//stream` — AI 对话(SSE 流式)(chat.py) +- `/api/ai-analysis` — AI 分析(teach_agent)(chat.py) +- `/api/auth` / `/api/auth/send-code` / `/api/auth/reset-password` — 认证 + 邮箱验证码(auth.py) +- `/api/settings` — LLM provider 配置(settings.py) +- `/api/notes` — 笔记 CRUD(notes.py) --- @@ -107,63 +121,95 @@ cd frontend && npm install # 前端(Node 18+) ### 技术栈 - Vue 3 + ` + + + + +``` + - UI 文案使用中文,API 路径前缀 `/api/` - 新功能提取为独立 `.vue` 组件放 `src/components/` +- 认证组件放 `components/auth/`,首页组件放 `components/home/` - 关键 DOM 元素添加语义类名或 `data-testid` +- Modal/Dropdown 使用 `useClickOutside` 处理外部点击关闭 + +### Vite 配置 + +- API 代理:`/api`、`/images`、`/download`、`/erased`、`/uploads` → `http://localhost:5001` +- SPA 路由:`/auth*`、`/app*` → `app.html` +- 构建产物:`dist/` --- @@ -209,7 +340,7 @@ cd frontend && npm install # 前端(Node 18+) ### 模块级副作用 - **禁止在模块顶层执行有副作用的操作**(创建目录、写文件、启动连接等) -- `config.py` 目录创建通过 `ensure_dirs()` 显式调用 +- `core/config.py` 目录创建通过 `ensure_dirs()` 显式调用 - `load_dotenv()` 仅在入口文件或需要环境变量的模块中调用 ### 数据库 @@ -220,7 +351,7 @@ cd frontend && npm install # 前端(Node 18+) ### 线程安全 -- Flask 全局状态修改必须在 `session_lock` 保护下 +- 全局会话状态集中在 `core/state.py`,修改必须在 `session_lock` 保护下 - 锁内用原地修改(`.remove()`, `.append()`),**不要用推导式重新赋值** - Agent 缓存通过 `_agent_cache_lock` 保护并发初始化 @@ -228,9 +359,9 @@ cd frontend && npm install # 前端(Node 18+) - 结构化输出优先用 `create_agent` + `ToolStrategy` - 已知问题:`ToolStrategy` 与 DeepSeek 的 `handle_errors` 重试不兼容,大数据量可能触发 400 -- ernie 用 `model.with_structured_output()`(不支持 function calling) -- 轻量任务用 `DEEPSEEK_LIGHT_MODEL_NAME` / `ERNIE_LIGHT_MODEL_NAME` 小模型 +- 不支持 function calling 的模型用 `model.with_structured_output()` - `invoke_split` / `invoke_correction` 是统一调用入口,屏蔽 provider 差异 +- API Provider 配置(key、模型名)存储在数据库中,用户通过系统设置页面管理 ### OCR 数据处理 @@ -240,14 +371,18 @@ cd frontend && npm install # 前端(Node 18+) ### 文件与路径 -- 所有运行时路径通过 `config.py` 集中管理,禁止硬编码 +- 所有运行时路径通过 `core/config.py` 集中管理,禁止硬编码 - 文件名解析用 `os.path.splitext()`,**不要用 `rsplit('.', 1)`** - 文件上传用 `uuid.uuid4().hex` 生成安全文件名 ### 环境变量 -- `.env` 不入版本控制,`.env.example` 作为模板 -- 新增配置项必须同步更新 `.env.example` +- `.env` 放项目根目录,不入版本控制;`backend/.env.example` 作为模板 +- `core/config.py` 的 `Settings` 使用 `env_prefix="APP_"`,从项目根目录 `.env` 读取 +- LLM API Provider 配置已迁移到数据库,不再通过 `.env` 管理 +- Flask 级配置(`SECRET_KEY`、`FLASK_DEBUG`、`LANGSMITH_*`)仍通过 `.env` 管理 +- SMTP 邮件配置通过 `APP_SMTP_*` 变量管理(`core/mail.py`) +- 新增 `.env` 配置项必须同步更新 `backend/.env.example` - 可选项用 `os.getenv("KEY")` + 代码中提供默认值 --- diff --git a/README.md b/README.md index 66e80ad5..e0b214e7 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,59 @@ -# 错题本生成系统 +# 智卷 — 智能错题本生成系统 -基于 PaddleOCR + LangChain Agent 的智能错题本生成系统。上传试卷 PDF 或图片,自动识别文档结构、智能分割题目、OCR 纠错,导出为 Markdown 错题本并存入错题库,支持 AI 解题与教学讲解。 +基于 PaddleOCR + LangChain Agent 的智能错题本生成系统。上传试卷 PDF 或图片,自动识别文档结构、智能分割题目、OCR 纠错,导出为 Markdown 错题本并存入错题库。支持 AI 解题与教学讲解、笔记整理、独立 AI 对话、错题复习计划与数据统计。 ## 功能 -- **智能分割**:LLM Agent 自动识别题目边界,支持批次并行处理 +- **智能分割**:LLM Agent 自动识别题目边界,支持批次并行处理,跨页去重 - **OCR 纠错**:LLM 对识别结果进行结构化纠错,还原原题格式 -- **错题库**:SQLite 持久化存储,支持按科目、题型、日期筛选和统计 +- **手写擦除**:EnsExam 模型自动擦除手写字迹,提升 OCR 精度 +- **OCR 预览**:识别结果 bbox 框标注叠加在原图上,直观预览 +- **错题库**:SQLite 持久化存储,支持按科目、题型、知识点筛选和统计 +- **笔记整理**:上传手写笔记图片,OCR + LLM 自动结构化整理为 Markdown - **AI 解题**:独立解题 Agent,逐步给出解题过程 -- **AI 分析**:教学讲解 Agent,深度分析题目知识点 -- **AI 对话**:多轮对话,支持 SSE 流式输出 -- **导出**:导出为 Markdown 文件 +- **AI 教学**:教学讲解 Agent,绑定错题上下文的一对一辅导 +- **AI 对话**:独立多轮对话,支持 SSE 流式输出和深度思考(DeepSeek Reasoner) +- **复习计划**:间隔复习,追踪错题掌握状态 +- **数据统计**:Dashboard 可视化,按科目/题型/时间维度分析 +- **导出**:导出为 Markdown 文件,按大题分组 ## 项目结构 ``` -├── backend/ # Flask 后端(API + 静态资源托管) -│ ├── config.py # 集中配置(路径、目录) -│ ├── llm.py # LLM 初始化(DeepSeek / 文心) -│ ├── web_app.py # Flask 主应用(路由 + 会话状态) +├── backend/ # Flask 后端(纯 API 服务) +│ ├── core/ # 核心模块 +│ │ ├── config.py # 集中配置(路径、Settings) +│ │ ├── llm.py # LLM 初始化(多 provider 支持) +│ │ ├── state.py # 全局会话状态(session_files、锁) +│ │ └── mail.py # SMTP 邮件发送 +│ ├── web_app.py # Flask 应用工厂 + Blueprint 注册 +│ ├── routes/ # 7 个 Blueprint(upload、questions、chat、stats、auth、settings、notes) │ ├── src/ # 核心模块(LangGraph workflow、OCR 客户端、工具函数) -│ ├── error_correction_agent/ # LangChain Agent(题目分割、OCR 纠错、科目识别) -│ ├── solve_agent/ # 解题 Agent -│ ├── teach_agent/ # 教学讲解 Agent(AI 分析功能) -│ ├── benchmark/ # 模型评测(数据采集、评测运行、指标计算) -│ ├── db/ # SQLite 数据库(ORM 模型、CRUD) -│ └── tests/ # 后端测试(19 个测试模块) -├── frontend/ # Vue 3 + Vite + Tailwind CSS 前端 -│ ├── index.html # 介绍落地页 -│ ├── app.html # Vue 工作台入口 +│ ├── agents/ # LangChain Agent +│ │ ├── error_correction/ # 题目分割 + OCR 纠错 +│ │ ├── solve/ # 解题 Agent +│ │ ├── teach/ # 教学讲解 Agent +│ │ └── note/ # 笔记整理 Agent +│ ├── db/ # SQLite + SQLAlchemy ORM +│ │ ├── models.py # 数据模型(User、Question、Note、Chat 等) +│ │ ├── crud/ # CRUD 模块化包 +│ │ └── migrate.py # 数据库自动迁移 +│ ├── benchmark/ # 模型评测 +│ └── tests/ # 后端测试 +├── frontend/ # Vue 3 + Vite + Tailwind CSS +│ ├── app.html # SPA 入口 │ └── src/ -│ ├── App.vue # 根组件(侧边栏布局 + 视图路由) -│ ├── components/ # 18 个功能组件 +│ ├── views/ # 页面级组件(HomeView、WorkspaceView) +│ ├── components/ # 55+ 功能组件 +│ │ ├── auth/ # 认证(登录、注册、找回密码) +│ │ └── home/ # 首页组件 +│ ├── composables/ # 组合式函数(useAuth、useTheme 等) +│ ├── router/ # Vue Router 路由配置 +│ ├── api.js # 集中式 API 层 +│ ├── utils.js # 工具函数(Markdown 渲染、MathJax、DOMPurify) │ └── __tests__/ # 前端测试(Vitest) -├── example_uploads/ # 示例测试文件(图片 + PDF) -├── .env.example # 环境变量模板 +├── example_uploads/ # 示例测试文件 +├── backend/.env.example # 环境变量模板 └── requirements.txt # Python 依赖 ``` @@ -55,27 +74,15 @@ cd frontend && npm install ### 2. 配置环境变量 ```bash -copy .env.example .env +# 复制模板到项目根目录 +cp backend/.env.example .env ``` -编辑 `.env`,填写以下必需项: +编辑 `.env`,必须配置 `SECRET_KEY`。 -```dotenv -# LLM API(OpenAI 兼容接口,支持 OpenAI / DeepSeek / Qwen / Moonshot 等) -OPENAI_API_KEY=your_key -# OPENAI_BASE_URL=https://api.deepseek.com # 留空则使用 OpenAI 官方;填写后可切换 DeepSeek 等供应商 -# OPENAI_MODEL_NAME=gpt-4o-mini # 默认模型(可选) +LLM API Provider(OpenAI / Anthropic / PaddleOCR)配置已迁移到数据库,启动后在系统设置页面管理。 -# Anthropic API(可选,与 OpenAI 兼容接口二选一或同时配置) -# ANTHROPIC_API_KEY=your_key - -# PaddleOCR API(文档结构解析,V2 异步任务接口) -PADDLEOCR_API_URL=https://paddleocr.aistudio-app.com/api/v2/ocr/jobs -PADDLEOCR_API_TOKEN=your_token -PADDLEOCR_MODEL=PaddleOCR-VL-1.5 -``` - -> LLM 至少配置一种(OpenAI 兼容接口或 Anthropic),前端会自动检测可用模型。轻量模型、LangSmith 追踪、PaddleOCR 特性开关等可选配置见 `.env.example`。 +SMTP 邮件配置(注册验证码、找回密码)通过 `.env` 的 `APP_SMTP_*` 变量管理,详见 `backend/.env.example`。 ### 3. 启动 @@ -89,19 +96,11 @@ cd backend && python web_app.py cd frontend && npm run dev ``` -前端开发服务器会自动将 `/api`、`/images`、`/download` 请求代理到后端 `localhost:5001`。 - -**生产模式**(Flask 托管前端静态资源): +前端开发服务器会自动将 `/api`、`/images`、`/download`、`/erased`、`/uploads` 请求代理到后端 `localhost:5001`。 -```bash -# 构建前端 -cd frontend && npm run build - -# 启动后端(自动托管构建产物) -cd backend && python web_app.py -``` +访问 **http://localhost:5173** 即可使用。 -访问 **http://localhost:5001** 即可使用。 +> **注意**:后端已重构为纯 API 服务器,不提供前端页面。直接访问 `:5001` 只会得到 JSON 404。 ## 支持的文件格式 diff --git a/TODO.md b/TODO.md deleted file mode 100644 index ca6fc76d..00000000 --- a/TODO.md +++ /dev/null @@ -1,12 +0,0 @@ -# TODO - -## 用户级 API Key 存储 - -每个用户独立配置自己的 LLM API Key(AES-256 加密存 DB),替代系统全局 .env 配置。 - -- [ ] DB:用户表新增加密 api_key 字段(per provider) -- [ ] 后端:加解密工具函数(AES-256,密钥从 .env 读取) -- [ ] 后端:设置接口(POST 保存 / DELETE 删除 / GET 状态查询,不返回明文) -- [ ] 后端:调用 LLM 时优先使用用户 key,fallback 到系统全局 key -- [ ] 前端:设置页 API Key 管理 UI(输入、保存、删除、状态显示) -- [ ] .env.example:新增 API_KEY_ENCRYPTION_SECRET 配置项 diff --git a/WEB_APP_GUIDE.md b/WEB_APP_GUIDE.md deleted file mode 100644 index ea5324fe..00000000 --- a/WEB_APP_GUIDE.md +++ /dev/null @@ -1,443 +0,0 @@ -# Web应用使用指南 - -## 📱 功能简介 - -Web应用提供了一个可视化界面,用于测试错题本生成系统的完整工作流程。 - -### 主要功能 - -1. ✅ **文件上传** - 支持拖拽或点击上传PDF/图片 -2. ✅ **自动OCR解析** - 调用PaddleOCR API进行文档结构化解析 -3. ✅ **AI题目分割** - 使用DeepSeek Agent智能分割题目 -4. ✅ **题目预览** - 可视化展示分割后的题目 -5. ✅ **选择导出** - 勾选需要的题目并导出为Markdown格式 - ---- - -## 🚀 快速启动 - -### 1. 确保环境配置完成 - -检查 `.env` 文件是否包含必要的配置: - -```bash -# 必需配置 -PADDLEOCR_API_URL=https://... -PADDLEOCR_API_TOKEN=your_token -DEEPSEEK_API_KEY=your_key - -# 可选配置 -LANGSMITH_TRACING=true -LANGSMITH_API_KEY=your_key -``` - -### 2. 启动Web服务 - -```bash -python web_app.py -``` - -### 3. 访问界面 - -打开浏览器访问:**http://localhost:5001** - ---- - -## 📋 使用流程 - -### 步骤1: 上传文件 - -- **方式1**: 点击上传区域选择文件 -- **方式2**: 拖拽文件到上传区域 - -**支持格式**: -- PDF: `.pdf` -- 图片: `.png`, `.jpg`, `.jpeg`, `.bmp`, `.tiff`, `.webp` - -**文件大小**: 最大 50MB - -### 步骤2: OCR解析 - -文件上传后,系统会自动: -1. 将PDF转换为图片(如果是PDF) -2. 调用PaddleOCR API进行文档结构化解析 -3. 保存OCR结果到 `output/struct/` 目录 - -**进度显示**: -- 图片数量 -- OCR解析完成状态 - -### 步骤3: 分割题目 - -点击 **"🤖 开始分割题目"** 按钮: - -1. 系统调用DeepSeek Agent -2. Agent分析OCR结果 -3. 智能识别题号、题型、选项 -4. 保存分割结果到 `results/questions.json` - -**预计时间**: 10-30秒/页(取决于API响应速度) - -### 步骤4: 预览和导出 - -分割完成后: - -1. **预览题目**: 查看所有识别的题目 -2. **选择题目**: 勾选需要导出的题目 -3. **导出错题本**: 点击 **"📥 导出错题本"** 按钮 -4. **下载文件**: 点击下载链接获取Markdown文件 - ---- - -## 🔧 API端点说明 - -### 1. 系统状态检查 - -```http -GET /api/status -``` - -**响应**: -```json -{ - "success": true, - "status": { - "paddleocr_configured": true, - "deepseek_configured": true, - "langsmith_enabled": false, - "output_dirs": { - "pages": "output/pages", - "struct": "output/struct", - "results": "results" - } - } -} -``` - -### 2. 文件上传和OCR解析 - -```http -POST /api/upload -Content-Type: multipart/form-data - -file: <文件> -``` - -**响应**: -```json -{ - "success": true, - "message": "文件处理成功", - "result": { - "image_count": 3, - "ocr_count": 3, - "image_paths": ["output/pages/test_page_001.png", ...] - } -} -``` - -### 3. Agent分割题目 - -```http -POST /api/split -``` - -**响应**: -```json -{ - "success": true, - "message": "成功分割 5 道题目", - "questions": [ - { - "question_id": "1", - "question_type": "选择题", - "content_blocks": [...], - "options": ["A. ...", "B. ..."], - ... - } - ] -} -``` - -### 4. 导出错题本 - -```http -POST /api/export -Content-Type: application/json - -{ - "selected_ids": ["1", "2", "3"] -} -``` - -**响应**: -```json -{ - "success": true, - "message": "错题本导出成功", - "output_path": "results/wrongbook.md" -} -``` - -### 5. 获取题目列表 - -```http -GET /api/questions -``` - -**响应**: -```json -{ - "success": true, - "questions": [...] -} -``` - -### 6. 下载文件 - -```http -GET /download/wrongbook.md -``` - -直接下载Markdown文件 - ---- - -## 🎨 界面功能说明 - -### 系统状态指示器 - -顶部显示系统配置状态: -- ✓ **PaddleOCR**: API已配置 -- ✓ **DeepSeek**: API已配置 -- ✓ **LangSmith追踪**: 调试追踪已启用 - -### 步骤指示器 - -4步流程可视化: -1. **上传文件** - 上传PDF或图片 -2. **OCR解析** - 文档结构化识别 -3. **分割题目** - AI智能分割 -4. **预览导出** - 选择和导出 - -### 题目卡片 - -每道题目显示: -- 题号(如"题目 1") -- 题型标签(选择题/填空题/解答题) -- 题目内容(文本、公式、图片引用) -- 选项(如果是选择题) -- 复选框(用于选择导出) - ---- - -## ⚙️ 配置说明 - -### 端口配置 - -默认端口: **5001** - -修改端口(在 `web_app.py` 最后一行): -```python -app.run(host='0.0.0.0', port=8080, debug=True) -``` - -### 文件大小限制 - -默认: **50MB** - -修改限制(在 `web_app.py` 配置部分): -```python -app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB -``` - -### 上传目录 - -默认: `uploads/` - -修改目录(在 `web_app.py` 配置部分): -```python -UPLOAD_FOLDER = 'my_uploads' -``` - ---- - -## 🐛 常见问题 - -### Q1: 上传文件后长时间没有响应? - -**原因**: PaddleOCR API响应较慢或网络问题 - -**解决**: -1. 检查网络连接 -2. 查看浏览器控制台的网络请求 -3. 检查 `.env` 中的API配置是否正确 - -### Q2: Agent分割失败? - -**原因**: DeepSeek API配置错误或OCR结果为空 - -**解决**: -1. 检查 `DEEPSEEK_API_KEY` 是否正确 -2. 查看 `output/struct/` 目录下是否有OCR结果 -3. 检查 `results/split_issues.jsonl` 查看Agent记录的问题 - -### Q3: 题目预览显示为空? - -**原因**: Agent未能正确识别题目或保存失败 - -**解决**: -1. 检查 `results/questions.json` 是否存在 -2. 手动查看JSON文件内容 -3. 启用LangSmith追踪查看Agent执行日志 - -### Q4: 导出的Markdown文件在哪里? - -**位置**: `results/wrongbook.md` - -**下载**: 点击页面上的下载链接,或直接访问 `/download/wrongbook.md` - ---- - -## 🔍 调试技巧 - -### 1. 查看系统日志 - -启动Web应用后,终端会显示所有请求日志: -``` -127.0.0.1 - - [27/Jan/2026 10:30:15] "POST /api/upload HTTP/1.1" 200 - -127.0.0.1 - - [27/Jan/2026 10:30:45] "POST /api/split HTTP/1.1" 200 - -``` - -### 2. 检查中间结果 - -**标准化图片**: -```bash -ls output/pages/ -# test_page_001.png, test_page_002.png, ... -``` - -**OCR结果**: -```bash -ls output/struct/ -# test_page_001_struct.json, ... -``` - -**题目JSON**: -```bash -cat results/questions.json -``` - -**问题日志**: -```bash -cat results/split_issues.jsonl -``` - -### 3. 启用LangSmith追踪 - -在 `.env` 中设置: -```bash -LANGSMITH_TRACING=true -LANGSMITH_API_KEY=your_key -LANGSMITH_PROJECT=error-correction-web -``` - -然后访问 https://smith.langchain.com 查看Agent执行轨迹。 - -### 4. 浏览器开发者工具 - -按 `F12` 打开开发者工具: -- **Console**: 查看JavaScript错误 -- **Network**: 查看API请求和响应 -- **Application**: 查看本地存储 - ---- - -## 📊 性能优化 - -### 1. 减少处理时间 - -**方法1**: 降低图片DPI -```python -# 在 src/utils.py 的 prepare_input 函数中 -images = convert_from_path(file_path, dpi=200) # 默认300 -``` - -**方法2**: 关闭部分OCR功能 -```bash -# .env -PADDLEOCR_USE_DOC_ORIENTATION=false -PADDLEOCR_USE_DOC_UNWARPING=false -PADDLEOCR_USE_CHART_RECOGNITION=false -``` - -### 2. 提高并发处理能力 - -使用 gunicorn 代替 Flask 开发服务器: -```bash -pip install gunicorn -gunicorn -w 4 -b 0.0.0.0:5001 web_app:app -``` - -### 3. 添加缓存 - -对于相同文件,可以缓存OCR结果避免重复调用API。 - ---- - -## 🔒 安全注意事项 - -### 1. 文件上传安全 - -- ✅ 已限制文件扩展名 -- ✅ 已使用 `secure_filename` 处理文件名 -- ✅ 已限制文件大小(50MB) - -### 2. API密钥保护 - -- ⚠️ 不要将 `.env` 文件提交到版本控制 -- ⚠️ 生产环境使用更安全的密钥管理方案 - -### 3. 部署建议 - -如果部署到公网: -- 添加用户认证 -- 使用HTTPS -- 限制请求频率 -- 定期清理临时文件 - ---- - -## 🎯 扩展功能建议 - -### 1. 批量处理 - -支持一次上传多个文件并批量处理 - -### 2. 历史记录 - -保存处理历史,支持查看和重新导出 - -### 3. 自定义模板 - -允许用户自定义错题本的Markdown模板 - -### 4. 在线编辑 - -在导出前支持在线编辑题目内容 - ---- - -## 📞 技术支持 - -如遇到问题: - -1. 查看本文档的"常见问题"部分 -2. 检查 `docs/PROJECT_STRUCTURE.md` 了解系统架构 -3. 查看终端日志和浏览器控制台 -4. 启用LangSmith追踪进行调试 - ---- - -**文档版本**: v1.0.0 -**最后更新**: 2026-01-27 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..0bf1ebb6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,70 @@ +# ============================================================================ +# Flask API 服务配置 SECRET_KEY必须配置! +# ============================================================================ +SECRET_KEY=your-random-secret-key-change-this +# 开发模式开关(true 启用热重载和调试信息,生产环境务必设为 false) +FLASK_DEBUG=false + +# ============================================================================ +# LangSmith 配置(可选,用于调试追踪) +# ============================================================================ +LANGSMITH_TRACING=false +# LANGSMITH_ENDPOINT=https://api.smith.langchain.com +# LANGSMITH_API_KEY=你的_langsmith_api_key +# LANGSMITH_PROJECT="error-correction" + +# ============================================================================ +# 输出目录配置(可选) +# ============================================================================ +# 目录配置由 config.py 集中管理(默认:backend/runtime_data/) +# 如需覆盖运行时输出的根目录,请设置 APP_RUNTIME_DIR(绝对路径) +# APP_RUNTIME_DIR= + +# ============================================================================ +# 文字擦除模型权重(可选) +# ============================================================================ +# 默认路径:backend/models/weight/best.pth +# 如需指定其他位置,取消注释并填写绝对路径 +# APP_MODEL_PATH=/path/to/your/best.pth + +# ============================================================================ + +# ============================================================================ +# 验证码邮件 — SMTP 配置(注册验证码 + 找回密码共用) +# +# 【必填项】APP_SMTP_HOST / APP_SMTP_USER / APP_SMTP_PASSWORD / APP_SMTP_FROM +# 未填写时无法发送验证码邮件。 +# 开发调试时可设置 FLASK_DEBUG=true,验证码会打印到后端日志,无需真实 SMTP。 +# +# 【发件账号说明】 +# APP_SMTP_USER — 发件邮箱地址(同时作为 SMTP 登录用户名) +# APP_SMTP_FROM — 邮件显示的发件人地址,通常与 USER 相同 +# APP_SMTP_PASSWORD — SMTP 授权码,不是邮箱登录密码! +# · QQ 邮箱:邮箱设置 → 账户 → POP3/IMAP/SMTP → 开启服务 → 生成授权码 +# · 163 邮箱:设置 → POP3/SMTP/IMAP → 开启 → 设置客户端授权密码 +# · Gmail:需开启两步验证后生成「应用专用密码」 +# +# 【端口与加密方式】 +# APP_SMTP_PORT=587 + APP_SMTP_USE_TLS=true → STARTTLS(推荐,先明文握手再加密) +# APP_SMTP_PORT=465 + APP_SMTP_USE_TLS=false → SSL/TLS(直连加密,代码自动切换 SMTP_SSL) +# +# 常用邮件服务商: +# QQ 邮箱 smtp.qq.com 587(STARTTLS) 或 465(SSL) +# 163 邮箱 smtp.163.com 587(STARTTLS) 或 465(SSL) +# Gmail smtp.gmail.com 587(STARTTLS) 或 465(SSL) +# Outlook smtp.office365.com 587(STARTTLS) +# +# 【频率与安全控制】 +# APP_REGISTRATION_CODE_TTL_MINUTES — 验证码有效期(分钟),默认 10 +# APP_REGISTRATION_SEND_INTERVAL_SECONDS — 同一邮箱重发最短间隔(秒),默认 60 +# APP_REGISTRATION_MAX_ATTEMPTS — 验证码最大错误次数,超出后需重新获取,默认 5 +# ============================================================================ +APP_SMTP_HOST=smtp.qq.com +APP_SMTP_PORT=587 +APP_SMTP_USER=your-email@qq.com +APP_SMTP_PASSWORD=your-smtp-authorization-code +APP_SMTP_FROM=your-email@qq.com +APP_SMTP_USE_TLS=true +APP_REGISTRATION_CODE_TTL_MINUTES=10 +APP_REGISTRATION_SEND_INTERVAL_SECONDS=60 +APP_REGISTRATION_MAX_ATTEMPTS=5 diff --git a/backend/error_correction_agent/__init__.py b/backend/agents/__init__.py similarity index 100% rename from backend/error_correction_agent/__init__.py rename to backend/agents/__init__.py diff --git a/backend/agents/error_correction/__init__.py b/backend/agents/error_correction/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/error_correction_agent/agent.py b/backend/agents/error_correction/agent.py similarity index 72% rename from backend/error_correction_agent/agent.py rename to backend/agents/error_correction/agent.py index f1c60318..c1947199 100644 --- a/backend/error_correction_agent/agent.py +++ b/backend/agents/error_correction/agent.py @@ -9,8 +9,8 @@ from langchain.agents.structured_output import ToolStrategy from langchain_core.messages import SystemMessage, HumanMessage -from config import settings -from llm import init_model as _init_model +from core.config import settings +from core.llm import init_model as _init_model from .prompts import SPLIT_PROMPT, SPLIT_PROMPT_LITE, CORRECTION_PROMPT from .schemas import QuestionSplitResult, CorrectionResult @@ -21,6 +21,35 @@ _agent_cache_lock = threading.Lock() +def _should_avoid_tool_strategy(provider: str, model_name: str | None, cfg) -> bool: + """判断是否应避免 ToolStrategy(避免发送 tool_choice 到不兼容模型)""" + if provider != "openai": + return False + + effective_model = (cfg.resolve_model_name(model_name) or "").lower() + base_url = (getattr(cfg, "base_url", "") or "").lower() + + # DeepSeek 推理模型(如 deepseek-reasoner)不支持 tool_choice + if "reasoner" in effective_model and "deepseek" in (effective_model + base_url): + return True + return False + + +def _invoke_with_json_schema_fallback(*, prompt: str, provider: str, model_name: str | None, temperature: float, schema, system_prompt: str): + """不使用 ToolStrategy 的结构化调用兜底路径。""" + model = _init_model(temperature=temperature, provider=provider, model_name=model_name) + messages = [ + SystemMessage(content=system_prompt), + HumanMessage(content=prompt), + ] + try: + # 对 OpenAI-compatible(含 DeepSeek)优先使用 json_mode,避免 tool_choice。 + return model.with_structured_output(schema, method="json_mode").invoke(messages) + except TypeError: + # 部分模型/版本不支持 method 参数。 + return model.with_structured_output(schema).invoke(messages) + + def detect_subject_via_llm(ocr_text_sample: str, db_subjects: list, provider: str = "openai") -> str: """通过轻量 LLM 识别试卷科目 @@ -123,12 +152,18 @@ def _invoke_structured( """通用结构化输出调用,屏蔽 ToolStrategy vs with_structured_output 差异""" cfg = settings.get_provider(provider) - if not cfg.supports_function_calling: - model = _init_model(temperature=temperature, provider=provider, model_name=model_name) - return model.with_structured_output(schema).invoke([ - SystemMessage(content=system_prompt_fallback), - HumanMessage(content=prompt), - ]) + if (not cfg.supports_function_calling) or _should_avoid_tool_strategy(provider, model_name, cfg): + if _should_avoid_tool_strategy(provider, model_name, cfg): + effective = cfg.resolve_model_name(model_name) + logger.info("模型 %s 不兼容 tool_choice,切换为 JSON 结构化兜底路径", effective) + return _invoke_with_json_schema_fallback( + prompt=prompt, + provider=provider, + model_name=model_name, + temperature=temperature, + schema=schema, + system_prompt=system_prompt_fallback, + ) full_key = f"{cache_key}_{provider}_{model_name or 'default'}" with _agent_cache_lock: diff --git a/backend/error_correction_agent/prompts.py b/backend/agents/error_correction/prompts.py similarity index 68% rename from backend/error_correction_agent/prompts.py rename to backend/agents/error_correction/prompts.py index 5e8c5c02..c17ae3f4 100644 --- a/backend/error_correction_agent/prompts.py +++ b/backend/agents/error_correction/prompts.py @@ -13,9 +13,11 @@ ## 最重要的规则(必须遵守) 1. **子题禁止拆分**:含(1)(2)(3)的大题必须作为一道题输出,question_id 用大题号(如 "17"),不要输出 "17-1"、"17-2" -2. **跨页连续性**:一道题的内容可能跨越两页。如果某页开头的内容块没有新题号(如 1. 2. 3.),它一定属于上一页最后一道题: +2. **主处理页与上下文页**:每页带有 `is_primary` 字段。`is_primary: true` 的页必须提取其中开始的所有题目;`is_primary: false` 的页仅作跨页补全参考,不要提取只在该页开始的独立题目。 +3. **跨页连续性**:一道题的内容可能跨越两页。如果某页开头的内容块没有新题号(如 1. 2. 3.),它一定属于上一页最后一道题: - 页首出现 A/B/C/D 选项但无题号 → 是上一题的选项 - 页首出现(1)(2)小问但无大题号 → 是上一题的子问内容 + - **页首出现 image block 且其后才出现新题号 → 该图片属于上一题,加入上一题 content_blocks 末尾,不归属本页第一道题** - **绝对不要把这类无题号内容创建为新题目** 3. **content_blocks 的 block_type 只能填 text 或 image**,不能填 paragraph_title 等其他值 4. **question_type 只能填**:选择题、填空题、解答题、判断题 @@ -31,9 +33,11 @@ ## 各字段填写 - question_id:题号,如 "1"、"2"、"17" +- section_title:该题所属的大题标题,即题目之前最近出现的 `paragraph_title` block 的文字,如"四、我会计算。";若试卷无大题结构则填 null - question_type:选择题/填空题/解答题/判断题 - content_blocks:题干内容,每项 {"block_type": "text"/"image", "content": "..."} - options:选择题的选项列表,其他题型填 null +- option_images:选项对应的图片路径列表,与 options 按索引一一对应。若某个选项的内容是一张图片(如实验装置图),将图片路径填入对应位置,无图片的选项填空字符串 ""。非选择题或选项无图片时填 null - has_formula:含公式填 true - has_image:含图片填 true - knowledge_tags:1-3 个知识点标签,如 ["三角函数", "诱导公式"] @@ -46,7 +50,8 @@ ## 表格处理 -内容含表格数据时,转为 HTML 放入 text 块:`
...
...
`,单元格内公式仍用 LaTeX。 +- 输入 block 的 `block_label` 为 `table` 时:将其 `block_content`(已是完整 HTML)原样作为一个 `block_type: text` 的 content_block 输出,禁止拆散单元格;若 HTML 含 `` 标签,路径加入 `image_refs`,设 `has_image: true` +- 纯文本表格数据需转换时:使用 `
...
...
` 结构放入 text 块,单元格内公式仍用 LaTeX ## OCR 变量下标还原 @@ -63,89 +68,25 @@ 你是一个专业的试卷题目分割专家。你的任务是分析OCR识别的文档结构,智能分割出独立的题目。 -## 核心任务 - -从PaddleOCR解析的结构化数据中: -1. 识别题目边界(题号是关键标志) -2. 提取题干内容(公式用 LaTeX 标记嵌入文本中) -3. 识别并提取选项(如果是选择题) -4. 识别图片块并保留引用 -5. 按阅读顺序组织内容 - -## 题目类型识别 - -### 1. 选择题 -- 特征:包含选项标记(A. B. C. D. 或 A) B) C) D)) -- 需提取:题号、题干、选项列表、公式、图片 - -### 2. 填空题 -- 特征:包含下划线 `_____` 或 `( )` -- 需提取:题号、题干、公式、图片 - -### 3. 解答题 -- 特征:要求"解答"、"证明"、"计算"等 -- 需提取:题号、题干、公式、图片 - -### 4. 判断题 -- 特征:要求判断"正确"、"错误"或"对"、"错" -- 需提取:题号、题干 - -## 题号识别规则 +## 题目类型与题号 -常见题号格式: -- `1.` `2.` `3.` ... -- `1、` `2、` `3、` ... -- `(1)` `(2)` `(3)` ... -- `一、` `二、` `三、` ... +**题型**(只能填以下之一):选择题(含 A/B/C/D 选项)、填空题(含 `___` 或 `( )`)、解答题(计算/证明/解答)、判断题 -注意: -- 题号通常在段落开头 -- 题号后跟随题干内容 -- 大题号可能包含子题号(如 1. (1) (2)) +**题号格式**:`1.` `1、` `(1)` 等阿拉伯数字编号为小题号;`一、` `二、` 等汉字序号为大题头(见下方注意事项) ## 工作流程 -1. **分析block顺序** - - 使用 block_order 确定阅读顺序 - - 按顺序扫描所有 block - -2. **识别题目起点** - - 查找包含题号的 text 或 paragraph_title block - - 记录题号和题型 - -3. **收集题目内容** - - 从题号开始,收集后续的 block - - 遇到下一个题号时停止 - - **content_blocks 的 block_type 只允许 `text` 或 `image` 两个值**。即使 OCR 原始数据中有 `paragraph_title`、`doc_title` 等标签,输出时一律归为 `text` - - 公式不单独成块,直接用 LaTeX 标记嵌入 text 的 content 中(行内 `$...$`,独占行 `$$...$$`) - - **图片必须嵌入 content_blocks**:所有属于该题目的 image block 都必须作为 `{"block_type": "image", "content": "/images/..."}` 加入 content_blocks,即使图片与选项关联(如实验操作图)。不要只放在 image_refs 中而不嵌入 content_blocks - -4. **结构化输出** - - 将每道题目组织为结构化数据 - - 输出格式由系统自动约束,请按 schema 字段填写 +1. 按 block_order 顺序扫描所有 block +2. 遇到 `paragraph_title` block(如"四、我会计算。")时,更新当前大题标题(`section_title`);该 block **不输出为题目** +3. 遇到阿拉伯数字题号(1. 2. 3.)时,标记为新题目起点,并将当前 `section_title` 写入该题 +4. 收集题目内容直到下一个题号:`content_blocks` 只允许 `text`/`image`;公式用 LaTeX 嵌入 text;图片必须作为 image block 加入 content_blocks +5. 按 schema 字段组织结构化输出 ## 知识点标注 -为每道题目标注 1-3 个知识点标签(填写到 `knowledge_tags` 字段),要求: - -1. **粒度为二级知识点**:使用具体的知识点名称,不要过于宽泛也不要过于细碎 - - 正确:`["三角函数", "诱导公式"]` - - 过于宽泛:`["数学"]` - - 过于细碎:`["三角函数的诱导公式在特殊角求值中的应用"]` -2. **使用中文短语**,4-8 个字为宜 -3. **优先复用已有标签**:如果调用时提供了 `existing_tags`(已有知识点标签池),请优先从中选取匹配的标签,只有确实没有匹配项时才创建新标签 -4. **示例**(数学): - - 复数求共轭 → `["复数", "复数运算"]` - - 求圆锥体积 → `["立体几何", "圆锥"]` - - 等差数列求和 → `["数列", "等差数列"]` - - 三角函数图像变换 → `["三角函数", "函数图像变换"]` - - 线性规划最值 → `["不等式", "线性规划"]` -5. **示例**(物理): - - 牛顿第二定律应用 → `["力学", "牛顿第二定律"]` - - 电场强度计算 → `["电磁学", "电场强度"]` -6. **示例**(化学): - - 离子方程式判断 → `["离子反应", "离子方程式"]` - - 化学平衡移动 → `["化学平衡", "平衡移动"]` +为每道题目标注 1-3 个知识点标签(`knowledge_tags`):使用二级知识点短语(4-8 字),有 `existing_tags` 时优先复用。 + +示例:等差数列求和 → `["数列", "等差数列"]`;牛顿第二定律 → `["力学", "牛顿第二定律"]`;离子方程式 → `["离子反应", "离子方程式"]` ## OCR 错误标记 @@ -186,10 +127,20 @@ - 含子题的大题:「17. 已知函数 f(x)…(1)求…(2)证明…」是同一道题的多问 → 输出 1 道题 - 板块标题本身不输出为任何题目,其分值说明也无需保留 -4. **跨页连续性** +4. **主处理页与上下文页(重要)** + - 每页数据带有 `is_primary` 字段 + - `is_primary: true`(主处理页):**必须**提取该页中出现的所有题目(含跨页延续到 context 页的题目) + - `is_primary: false`(上下文页):**仅**用于补全主处理页题目跨页的内容,**不要**提取只在该页开始的独立题目 + - 判断"题目在哪页开始":题号(阿拉伯数字)首次出现在哪页,就算在哪页开始 + +5. **跨页连续性** - 一道题的内容可能跨越两页。如果某页开头的内容块没有新题号,它属于上一页最后一道题 - 典型情况:页首出现 A/B/C/D 选项但无题号 → 是上一题的选项;页首出现(1)(2)小问但无大题号 → 是上一题的子问 - 不要把这类无题号的跨页内容创建为新题目 + - **页首图片的归属(极其重要)**:若某页最先出现的 block 是 image,且该图片**之前没有出现任何阿拉伯数字题号**,则该图片**属于上一道题**,必须将其加入上一题的 content_blocks 末尾,而不是归入本页第一道题。 + - 判断依据:图片归属看的是"该图片出现在哪道题的题号之后"。图片在题号之前 → 归上一题;图片在题号之后 → 归该题。 + - 即使本页第一道题的题干中含"如图所示",也不能把题号之前出现的图片归给它。 + - 示例:页面顺序为 `[image: 电路图] → [text: "21. 桔槔是..."]`,该电路图在题号 21 之前出现,应归属题号 20(上一题),而非题目 21。 5. **准确性优先** - 不确定的边界,宁可保守(保留更多内容) @@ -217,14 +168,19 @@ - 对于选择题,如果你已经把选项提取到 `options` 数组中,则必须从 `content_blocks` 的文本中移除选项部分 - `content_blocks` 只保留题干(题目正文),不包含选项文本 - 例如原始文本为 "下列说法正确的是()\\nA. xxx B. yyy",则 content_blocks 中的 text 只保留 "下列说法正确的是()",选项 "A. xxx"、"B. yyy" 放入 options 数组 + - **所有选项必须完整提取**:选择题通常有 4 个选项(A/B/C/D),务必逐一检查,不要遗漏任何选项。如果某个选项的 OCR 文本与图片分散在不同 block 中,仍需完整识别并提取 9. **表格处理(重要)** - - 如果题目内容中包含表格数据(如实验数据、统计数据、对比数据),必须将其转换为 HTML 表格 - - 使用如下结构,放入 `text` 类型的 content_block: - `
列1列2
值1值2
` - - 表格单元格内的公式仍然用 LaTeX 标记(如 `$x_i$`) - - 每行数据对应一个 ``,表头行放在 `` 中,数据行放在 `` 中 - - 例如:试验序号/伸缩率数据应输出为包含 `` 的 HTML 字符串,而不是纯文本 + + **情况一:输入 block 已是 table 类型(`block_label: "table"`)** + - 其 `block_content` 已是完整 HTML,**必须将整段 HTML 原样作为一个 `block_type: text` 的 content_block 输出** + - 禁止拆散单元格,禁止把表格中的图片单独提取为独立的 image block + - 若 HTML 中含有 `` 标签,将所有 src 路径加入 `image_refs`,并设 `has_image: true` + + **情况二:纯文本表格数据需要转换** + - 如果题目含有表格数据但输入为纯文本(如实验数据、统计数据、对比数据),将其转换为 HTML 表格,放入 `text` 类型的 content_block + - 使用结构:`
列1列2
值1值2
` + - 表格单元格内的公式仍用 LaTeX 标记(如 `$x_i$`) ## 示例 diff --git a/backend/error_correction_agent/schemas.py b/backend/agents/error_correction/schemas.py similarity index 80% rename from backend/error_correction_agent/schemas.py rename to backend/agents/error_correction/schemas.py index 5bff6bb6..ae7dc6b6 100644 --- a/backend/error_correction_agent/schemas.py +++ b/backend/agents/error_correction/schemas.py @@ -16,9 +16,11 @@ class ContentBlock(BaseModel): class Question(BaseModel): """单道题目""" question_id: str = Field(description="题号,如 '1', '2', '(1)' 等") + section_title: Optional[str] = Field(default=None, description="该题所属的大题标题,即题目之前最近出现的 paragraph_title block 的文字,如'四、我会计算。';若试卷无大题结构则为 null") question_type: Literal["选择题", "填空题", "解答题", "判断题"] = Field(description="题目类型") content_blocks: List[ContentBlock] = Field(description="题干内容块列表(不含选项)") options: Optional[List[str]] = Field(default=None, description="选项列表,仅选择题需要,如 ['A. xxx', 'B. yyy']") + option_images: Optional[List[str]] = Field(default=None, description="选项对应的图片路径列表,与 options 按索引一一对应;无图片的选项填 null 或空字符串") has_formula: bool = Field(default=False, description="是否包含数学公式") has_image: bool = Field(default=False, description="是否包含图片") image_refs: Optional[List[str]] = Field(default=None, description="图片引用路径列表") @@ -35,9 +37,11 @@ class QuestionSplitResult(BaseModel): class CorrectedQuestion(BaseModel): """纠错后的单道题目""" question_id: str = Field(description="题号") + section_title: Optional[str] = Field(default=None, description="所属大题标题,透传自分割结果,不需要修改") question_type: Literal["选择题", "填空题", "解答题", "判断题"] = Field(description="题目类型") content_blocks: List[ContentBlock] = Field(description="纠错后的题干内容块列表") options: Optional[List[str]] = Field(default=None, description="纠错后的选项列表") + option_images: Optional[List[str]] = Field(default=None, description="选项对应的图片路径列表,与 options 按索引一一对应") has_formula: bool = Field(default=False, description="是否包含数学公式") has_image: bool = Field(default=False, description="是否包含图片") image_refs: Optional[List[str]] = Field(default=None, description="图片引用路径列表") diff --git a/backend/error_correction_agent/tools/__init__.py b/backend/agents/error_correction/tools/__init__.py similarity index 100% rename from backend/error_correction_agent/tools/__init__.py rename to backend/agents/error_correction/tools/__init__.py diff --git a/backend/error_correction_agent/tools/file_tools.py b/backend/agents/error_correction/tools/file_tools.py similarity index 100% rename from backend/error_correction_agent/tools/file_tools.py rename to backend/agents/error_correction/tools/file_tools.py diff --git a/backend/error_correction_agent/tools/question_tools.py b/backend/agents/error_correction/tools/question_tools.py similarity index 99% rename from backend/error_correction_agent/tools/question_tools.py rename to backend/agents/error_correction/tools/question_tools.py index aa1943d1..37b95fd4 100644 --- a/backend/error_correction_agent/tools/question_tools.py +++ b/backend/agents/error_correction/tools/question_tools.py @@ -9,7 +9,7 @@ from typing import List, Dict, Any from dotenv import load_dotenv from langchain_core.tools import tool -from config import settings +from core.config import settings from src.utils import simplify_ocr_results, run_async load_dotenv() diff --git a/backend/agents/note/__init__.py b/backend/agents/note/__init__.py new file mode 100644 index 00000000..cc59c13f --- /dev/null +++ b/backend/agents/note/__init__.py @@ -0,0 +1 @@ +from .agent import invoke_note_organize diff --git a/backend/agents/note/agent.py b/backend/agents/note/agent.py new file mode 100644 index 00000000..7ef827da --- /dev/null +++ b/backend/agents/note/agent.py @@ -0,0 +1,136 @@ +""" +笔记整理 Agent — 将 OCR 文本整理为结构化 Markdown 笔记 + +支持: + - 重试机制(指数退避,最多 3 次) + - 多页分批处理(超过阈值时分批整理再合并) +""" + +import logging +import time + +from langchain_core.messages import SystemMessage, HumanMessage + +from core.config import settings +from core.llm import init_model +from .prompts import NOTE_ORGANIZE_PROMPT +from .schemas import OrganizedNote + +logger = logging.getLogger(__name__) + +# 单次 LLM 调用的文本长度上限(字符数),超过则分批 +BATCH_CHAR_LIMIT = 6000 +MAX_RETRIES = 3 + + +def _invoke_once(model, ocr_text: str) -> OrganizedNote: + """单次 LLM 调用,返回结构化笔记""" + structured_model = model.with_structured_output(OrganizedNote, method="function_calling") + return structured_model.invoke([ + SystemMessage(content=NOTE_ORGANIZE_PROMPT), + HumanMessage(content=f"以下是 OCR 识别出的课堂笔记原始文本,请整理为结构化笔记:\n\n{ocr_text}"), + ]) + + +def _invoke_with_retry(model, ocr_text: str) -> OrganizedNote: + """带指数退避的重试调用""" + last_error = None + for attempt in range(1, MAX_RETRIES + 1): + try: + result = _invoke_once(model, ocr_text) + if result: + return result + last_error = "LLM 未返回有效结果" + except Exception as e: + last_error = str(e) + logger.warning(f"笔记整理: 第 {attempt}/{MAX_RETRIES} 次失败: {last_error}") + + if attempt < MAX_RETRIES: + wait = 2 ** attempt # 2s, 4s + logger.info(f"笔记整理: {wait}s 后重试...") + time.sleep(wait) + + raise RuntimeError(f"笔记整理失败(重试 {MAX_RETRIES} 次): {last_error}") + + +def _split_text_into_batches(text: str) -> list: + """按行将文本拆分为多个批次,每批不超过 BATCH_CHAR_LIMIT 字符""" + lines = text.split('\n') + batches = [] + current = [] + current_len = 0 + + for line in lines: + if current_len + len(line) + 1 > BATCH_CHAR_LIMIT and current: + batches.append('\n'.join(current)) + current = [] + current_len = 0 + current.append(line) + current_len += len(line) + 1 + + if current: + batches.append('\n'.join(current)) + return batches + + +def _merge_notes(notes: list) -> OrganizedNote: + """合并多批次的笔记结果""" + # 用第一批的标题和科目 + title = notes[0].title + subject = notes[0].subject + + # 合并 Markdown 内容 + all_markdown = '\n\n'.join(n.content_markdown for n in notes) + + # 合并去重标签 + all_tags = [] + seen = set() + for n in notes: + for tag in n.knowledge_tags: + if tag not in seen: + all_tags.append(tag) + seen.add(tag) + + return OrganizedNote( + title=title, + subject=subject, + content_markdown=all_markdown, + knowledge_tags=all_tags[:5], # 最多 5 个 + ) + + +def invoke_note_organize( + ocr_text: str, + provider: str = "openai", + model_name: str = None, +) -> OrganizedNote: + """调用 LLM 将 OCR 文本整理为结构化笔记 + + 文本较短时直接整理,较长时分批处理再合并。 + 每次调用带 3 次重试(指数退避)。 + + Args: + ocr_text: OCR 识别出的原始文本 + provider: 模型供应商 + model_name: 指定模型名称,None 使用默认 + + Returns: + OrganizedNote 结构化结果 + """ + model = init_model(temperature=0.3, provider=provider, model_name=model_name) + + # 短文本:直接整理 + if len(ocr_text) <= BATCH_CHAR_LIMIT: + return _invoke_with_retry(model, ocr_text) + + # 长文本:分批处理 + batches = _split_text_into_batches(ocr_text) + logger.info(f"笔记文本较长({len(ocr_text)} 字符),分 {len(batches)} 批处理") + + results = [] + for i, batch in enumerate(batches): + logger.info(f"处理第 {i + 1}/{len(batches)} 批({len(batch)} 字符)") + result = _invoke_with_retry(model, batch) + results.append(result) + + return _merge_notes(results) diff --git a/backend/agents/note/prompts.py b/backend/agents/note/prompts.py new file mode 100644 index 00000000..09a9d270 --- /dev/null +++ b/backend/agents/note/prompts.py @@ -0,0 +1,52 @@ +""" +笔记整理 Prompt +""" + +NOTE_ORGANIZE_PROMPT = """你是一位专业的学习笔记整理助手。 + +## 任务 +将 OCR 识别出的课堂笔记原始文本,整理为结构清晰、条理分明的 Markdown 格式知识点笔记。 + +## 输入 +用户会提供 OCR 识别出的原始文本,可能包含: +- 手写笔记内容 +- 板书内容 +- 课堂重点标注 +- 公式、定理、定义 +- 例题和解题步骤 + +## 整理要求 + +### 标题 +- 根据笔记内容自动生成一个简洁的标题 +- 格式:【科目】主题,如 "高中数学 - 三角函数基本性质" + +### 内容整理 +1. **识别知识结构**:提取定义、定理、公式、性质、方法等 +2. **层级组织**:用 Markdown 标题(##、###)组织层级关系 +3. **公式还原**:将 OCR 可能识别不完整的数学公式用 LaTeX 标记还原(行内 $...$,独占行 $$...$$) +4. **要点标注**:用 **加粗** 标注重点,用 > 引用块标注注意事项 +5. **列表归纳**:零散的知识点用有序/无序列表归纳 +6. **纠正错误**:修正 OCR 识别中的明显错误(如乱码、错别字、符号错误) + +### 知识点标签 +- 提取 2-5 个核心知识点作为标签 +- 标签用中文,4-8 个字,如 "三角函数"、"诱导公式" +- 优先使用通用教材中的标准术语 + +### 科目识别 +- 根据内容判断科目,格式:学段+科目(如 "高中数学"、"初中物理") +- 无法判断时输出 "未知" + +### 图片处理 +- 输入文本中的 `[图片: /images/xxx.jpg]` 表示 OCR 识别到的图片(如几何图形、示意图、图表等) +- 在 Markdown 中对应位置插入图片引用:`![图片描述](/images/xxx.jpg)` +- 根据上下文给图片添加合适的描述文字(如 "平行四边形示意图"、"函数图像" 等) +- 不要遗漏任何图片 + +## 注意 +- 保留原始笔记中有价值的内容,不要遗漏 +- 不要添加原文中没有的知识内容 +- 如果原文有例题,保留例题和解法 +- 输出纯 Markdown,不要包含 ```markdown``` 代码块标记 +""" diff --git a/backend/agents/note/schemas.py b/backend/agents/note/schemas.py new file mode 100644 index 00000000..ef036f05 --- /dev/null +++ b/backend/agents/note/schemas.py @@ -0,0 +1,14 @@ +""" +笔记整理结构化输出 Schema +""" + +from typing import List, Optional +from pydantic import BaseModel, Field + + +class OrganizedNote(BaseModel): + """LLM 整理后的笔记结构""" + title: str = Field(description="笔记标题,根据内容自动生成,简洁明确") + subject: str = Field(description="科目,如 '高中数学'、'初中物理',无法判断则输出 '未知'") + content_markdown: str = Field(description="整理后的 Markdown 格式笔记内容,包含知识点归纳、公式、要点等") + knowledge_tags: List[str] = Field(description="知识点标签列表(2-5 个),如 ['三角函数', '诱导公式']") diff --git a/backend/solve_agent/__init__.py b/backend/agents/solve/__init__.py similarity index 100% rename from backend/solve_agent/__init__.py rename to backend/agents/solve/__init__.py diff --git a/backend/solve_agent/agent.py b/backend/agents/solve/agent.py similarity index 98% rename from backend/solve_agent/agent.py rename to backend/agents/solve/agent.py index bfa233d0..478b0fcb 100644 --- a/backend/solve_agent/agent.py +++ b/backend/agents/solve/agent.py @@ -11,7 +11,7 @@ from langchain_core.messages import SystemMessage, HumanMessage -from llm import init_model +from core.llm import init_model from .prompts import SOLVE_PROMPT from .schemas import SolveBatchResult diff --git a/backend/solve_agent/prompts.py b/backend/agents/solve/prompts.py similarity index 100% rename from backend/solve_agent/prompts.py rename to backend/agents/solve/prompts.py diff --git a/backend/solve_agent/schemas.py b/backend/agents/solve/schemas.py similarity index 100% rename from backend/solve_agent/schemas.py rename to backend/agents/solve/schemas.py diff --git a/backend/teach_agent/__init__.py b/backend/agents/teach/__init__.py similarity index 100% rename from backend/teach_agent/__init__.py rename to backend/agents/teach/__init__.py diff --git a/backend/teach_agent/agent.py b/backend/agents/teach/agent.py similarity index 55% rename from backend/teach_agent/agent.py rename to backend/agents/teach/agent.py index e8765051..67aca635 100644 --- a/backend/teach_agent/agent.py +++ b/backend/agents/teach/agent.py @@ -7,7 +7,7 @@ from langchain_core.messages import SystemMessage, HumanMessage, AIMessage -from llm import init_model +from core.llm import init_model from .prompts import build_teach_prompt logger = logging.getLogger(__name__) @@ -37,9 +37,15 @@ def _build_question_text(question: Dict[str, Any]) -> str: return "\n".join(parts) +GENERAL_SYSTEM_PROMPT = """你是一位专业的学习辅导助手,擅长数学、物理、化学、英语等学科。 +请用清晰、有条理的方式回答学生的问题。 +如果涉及数学公式,使用 LaTeX 标记(行内 $...$,独占行 $$...$$)。 +回答要准确、简洁,适合中学生和大学生理解。""" + + def stream_teach( *, - question: Dict[str, Any], + question: Dict[str, Any] = None, messages: List[Dict[str, str]], provider: str = "openai", model_name: str | None = None, @@ -47,8 +53,7 @@ def stream_teach( """流式教学对话 Args: - question: 题目数据(含 content_json, options_json, answer, question_type, - subject, knowledge_tags 等字段) + question: 题目数据(可选,None 为独立对话) messages: 对话历史 [{"role": "user"|"assistant", "content": "..."}] provider: 模型供应商 model_name: 指定模型名称,为 None 时使用 provider 默认模型 @@ -56,17 +61,20 @@ def stream_teach( Yields: 逐 token 的文本片段 """ - subject = question.get("subject") or "未知科目" - knowledge_tags = question.get("knowledge_tags") or [] - answer_text = question.get("answer") or "暂无答案" - question_text = _build_question_text(question) - - system_prompt = build_teach_prompt( - subject=subject, - knowledge_tags=knowledge_tags, - question_text=question_text, - answer_text=answer_text, - ) + if question: + subject = question.get("subject") or "未知科目" + knowledge_tags = question.get("knowledge_tags") or [] + answer_text = question.get("answer") or "暂无答案" + question_text = _build_question_text(question) + + system_prompt = build_teach_prompt( + subject=subject, + knowledge_tags=knowledge_tags, + question_text=question_text, + answer_text=answer_text, + ) + else: + system_prompt = GENERAL_SYSTEM_PROMPT # 构建 LangChain 消息列表 lc_messages = [SystemMessage(content=system_prompt)] @@ -79,6 +87,16 @@ def stream_teach( model = init_model(temperature=0.4, provider=provider, model_name=model_name) for chunk in model.stream(lc_messages): + # DeepSeek reasoner 模型会在 additional_kwargs 里返回 reasoning_content + reasoning = None + if hasattr(chunk, 'additional_kwargs'): + reasoning = chunk.additional_kwargs.get('reasoning_content') + token = chunk.content - if token: - yield token + # 安全处理:确保 token 是字符串(某些模型返回 list) + if isinstance(token, list): + token = ''.join(str(t) for t in token if t) + if reasoning and isinstance(reasoning, str): + yield {"type": "reasoning", "content": reasoning} + if token and isinstance(token, str): + yield {"type": "content", "content": token} diff --git a/backend/teach_agent/prompts.py b/backend/agents/teach/prompts.py similarity index 100% rename from backend/teach_agent/prompts.py rename to backend/agents/teach/prompts.py diff --git a/backend/benchmark/collect.py b/backend/benchmark/collect.py index 7026d402..68d2334a 100644 --- a/backend/benchmark/collect.py +++ b/backend/benchmark/collect.py @@ -29,7 +29,7 @@ if str(BACKEND_ROOT) not in sys.path: sys.path.insert(0, str(BACKEND_ROOT)) -from config import settings +from core.config import settings logger = logging.getLogger(__name__) @@ -93,7 +93,7 @@ def ocr_and_split(image_paths: List[str], provider: str = "openai") -> List[Dict batches = _build_overlapping_batches(ocr_data, batch_size=2, overlap=1) # 分割 - from error_correction_agent.tools import split_batch + from agents.error_correction.tools import split_batch all_questions = [] for batch_data in batches: diff --git a/backend/benchmark/evaluate.py b/backend/benchmark/evaluate.py index b9c64023..eca4e4ae 100644 --- a/backend/benchmark/evaluate.py +++ b/backend/benchmark/evaluate.py @@ -62,7 +62,7 @@ def run_evaluation(provider: str, targets: List[Dict[str, Any]]) -> Dict[str, An Returns: 评测报告 dict """ - from solve_agent import invoke_solve + from agents.solve import invoke_solve # 提取题目数据 questions = [] diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/config.py b/backend/core/config.py similarity index 94% rename from backend/config.py rename to backend/core/config.py index dd11c106..f6c37683 100644 --- a/backend/config.py +++ b/backend/core/config.py @@ -12,7 +12,7 @@ _CONNECTION_CACHE_TTL = 60 # 连通性检测缓存有效期(秒) _providers_lock = threading.Lock() # 保护 llm_providers 并发读写 -_BACKEND_ROOT = Path(__file__).resolve().parent +_BACKEND_ROOT = Path(__file__).resolve().parent.parent # backend/core/ → backend/ _PROJECT_ROOT = _BACKEND_ROOT.parent _ENV_FILE = _PROJECT_ROOT / ".env" @@ -180,6 +180,17 @@ class Settings(BaseSettings): max_file_size_mb: int = 50 allowed_extensions: set[str] = {"pdf", "png", "jpg", "jpeg", "bmp", "tiff", "webp"} + # 注册验证码邮件(SMTP,环境变量前缀仍为 APP_,如 APP_SMTP_HOST) + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_use_tls: bool = True + registration_code_ttl_minutes: int = 10 + registration_send_interval_seconds: int = 60 + registration_max_attempts: int = 5 + # LLM 供应商注册表 llm_providers: dict[str, LLMProviderConfig] | None = None @@ -198,7 +209,7 @@ def _resolve_defaults(self): if self.erased_dir is None: self.erased_dir = self.runtime_dir / "erased" if self.model_path is None: - self.model_path = self.runtime_dir / "models" / "latest.pth" + self.model_path = _BACKEND_ROOT / "models" / "weight" / "best.pth" # 初始化 LLM 供应商注册表 if self.llm_providers is None: @@ -271,7 +282,7 @@ def load_providers_from_db(self, user_id: int) -> None: def _clear_agent_cache(self): """清除 agent 缓存""" try: - from error_correction_agent.agent import _agent_cache, _agent_cache_lock + from agents.error_correction.agent import _agent_cache, _agent_cache_lock with _agent_cache_lock: _agent_cache.clear() except ImportError: diff --git a/backend/llm.py b/backend/core/llm.py similarity index 94% rename from backend/llm.py rename to backend/core/llm.py index e361ed6a..91c44865 100644 --- a/backend/llm.py +++ b/backend/core/llm.py @@ -5,7 +5,7 @@ 供应商配置和工厂方法统一在 config.py 的 LLMProviderConfig 子类中定义。 """ -from config import settings +from core.config import settings def init_model( @@ -33,7 +33,7 @@ def init_model( """ if api_key: # 用传入的凭据动态构建配置,跳过环境变量 - from config import OpenAICompatibleConfig, AnthropicCompatibleConfig + from core.config import OpenAICompatibleConfig, AnthropicCompatibleConfig if provider == "anthropic": cfg = AnthropicCompatibleConfig( api_key=api_key, diff --git a/backend/core/mail.py b/backend/core/mail.py new file mode 100644 index 00000000..8e21fbe1 --- /dev/null +++ b/backend/core/mail.py @@ -0,0 +1,59 @@ +"""SMTP 发信(注册验证码等)。""" + +import logging +import smtplib +import threading +from email.mime.text import MIMEText + +logger = logging.getLogger(__name__) + + +def _send_sync( + host, port, user, password, mail_from, use_tls, to_addr, msg, +): + if port == 465: + smtp = smtplib.SMTP_SSL(host, port, timeout=15) + else: + smtp = smtplib.SMTP(host, port, timeout=15) + if use_tls: + smtp.starttls() + + with smtp: + if user and password: + smtp.login(user, password) + smtp.sendmail(mail_from, [to_addr], msg.as_string()) + + logger.info("已发送邮件至 %s", to_addr) + + +def send_smtp_email( + *, + host: str, + port: int, + user: str, + password: str, + mail_from: str, + use_tls: bool, + to_addr: str, + subject: str, + body: str, + async_send: bool = True, +) -> None: + if not host or not mail_from: + raise ValueError("SMTP 未配置:需要 smtp_host 与 smtp_from") + + msg = MIMEText(body, "plain", "utf-8") + msg["Subject"] = subject + msg["From"] = mail_from + msg["To"] = to_addr + + if async_send: + t = threading.Thread( + target=_send_sync, + args=(host, port, user, password, mail_from, use_tls, to_addr, msg), + daemon=True, + ) + t.start() + logger.info("邮件发送任务已提交(异步): %s", to_addr) + else: + _send_sync(host, port, user, password, mail_from, use_tls, to_addr, msg) diff --git a/backend/core/state.py b/backend/core/state.py new file mode 100644 index 00000000..1e3d85ab --- /dev/null +++ b/backend/core/state.py @@ -0,0 +1,42 @@ +""" +全局会话状态 — 供各 Blueprint 模块共享 + +所有需要跨路由访问的运行时变量集中在此模块, +通过 session_lock 保护并发写操作。 + +会话状态按 user_id 隔离,每个用户拥有独立的上传/分割上下文。 +""" + +import threading + +from src.workflow import build_workflow + +# 全局工作流图(无状态,可安全共享) +workflow_graph = build_workflow() + +# 线程锁(全局共享) +session_lock = threading.Lock() + +# ── 按用户隔离的会话状态 ────────────────────────────────── + +_user_sessions: dict = {} + + +def get_user_session(user_id) -> dict: + """获取或创建指定用户的会话状态""" + key = user_id if user_id is not None else "anon" + if key not in _user_sessions: + _user_sessions[key] = { + "current_thread_id": None, + "session_files": {}, # file_key → {filename, filepath} + "session_file_order": [], # 上传顺序 + "cancelled_file_keys": set(), # 已取消的文件 key + "erased_file_paths": None, # 擦除后的文件路径列表 + } + return _user_sessions[key] + + +def clear_user_session(user_id): + """清除指定用户的会话状态""" + key = user_id if user_id is not None else "anon" + _user_sessions.pop(key, None) diff --git a/backend/db/__init__.py b/backend/db/__init__.py index e53e5ae7..1904304c 100644 --- a/backend/db/__init__.py +++ b/backend/db/__init__.py @@ -9,7 +9,7 @@ # 添加 backend 目录到路径以支持导入 config sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from config import settings +from core.config import settings # 确保数据库目录存在 db_dir = settings.db_path.parent @@ -41,6 +41,16 @@ def _migrate_schema(): if 'answer' not in columns: cursor.execute("ALTER TABLE questions ADD COLUMN answer TEXT") conn.commit() + + # chat_sessions 表:question_id 需要改为 nullable + # SQLite 不支持 ALTER COLUMN,需要重建表 + cursor.execute("PRAGMA table_info(chat_sessions)") + cs_columns = {row[1]: row for row in cursor.fetchall()} + if 'title' not in cs_columns: + # 旧表结构,需要重建 + cursor.execute("DROP TABLE IF EXISTS chat_messages") + cursor.execute("DROP TABLE IF EXISTS chat_sessions") + conn.commit() finally: conn.close() diff --git a/backend/db/crud.py b/backend/db/crud.py deleted file mode 100644 index e79a56ed..00000000 --- a/backend/db/crud.py +++ /dev/null @@ -1,1102 +0,0 @@ -""" -数据库 CRUD 操作 -""" - -import hashlib -import json -import logging -import re -from datetime import datetime -from typing import List, Dict, Any, Optional, Tuple - -from sqlalchemy.orm import Session, joinedload, selectinload -from sqlalchemy import func - -logger = logging.getLogger(__name__) - -from db.models import UploadBatch, Question, KnowledgeTag, QuestionTagMapping, ChatSession, ChatMessage, SplitRecord, User, ProviderConfig - - -def _filter_by_subject(query, subject: Optional[str]): - """如有学科筛选,为 Question 查询追加 JOIN UploadBatch 过滤""" - if subject: - return query.join(UploadBatch).filter(UploadBatch.subject == subject) - return query - - - -def _filter_by_user(query, user_id): - if user_id is not None: - query = query.filter(Question.user_id == user_id) - return query - - -# ============================================================ -# 用户认证 CRUD -# ============================================================ - -def create_user(db, email, password_hash, username, is_admin=False): - """创建用户""" - user = User(email=email, password_hash=password_hash, username=username, is_admin=is_admin) - db.add(user) - try: - db.commit() - db.refresh(user) - return user - except Exception as e: - db.rollback() - raise - - -def get_user_by_email(db, email): - """按邮箱查询用户""" - return db.query(User).filter(User.email == email).first() - - -def get_user_by_id(db, user_id): - """按 ID 查询用户""" - return db.query(User).filter(User.id == user_id).first() - - -def get_user_by_login(db, identifier): - """按邮箱或用户名查询用户(登录用,大小写不敏感邮箱)""" - identifier = identifier.strip() - return db.query(User).filter( - (func.lower(User.email) == identifier.lower()) | (User.username == identifier) - ).first() - - -def _parse_tag_list(knowledge_tag: str) -> List[str]: - """将逗号分隔的标签字符串拆分为去空白的非空列表""" - return [t.strip() for t in knowledge_tag.split(',') if t.strip()] - - -def compute_content_hash(content_blocks: List[Dict]) -> str: - """ - 基于 content_blocks 计算去重哈希 - 使用题目文本内容计算 SHA256 - """ - text_parts = [] - for block in content_blocks: - if block.get("block_type") == "text": - text_parts.append(block.get("content", "")) - - text = " ".join(text_parts).strip() - if not text: - # 如果没有文本内容,使用整个 content_blocks 的 JSON 作为哈希源 - text = json.dumps(content_blocks, ensure_ascii=False) - - return hashlib.sha256(text.encode()).hexdigest() - - -def get_existing_subjects(db, user_id=None): - """获取数据库中已有的所有科目名称(去重)""" - query = db.query(UploadBatch.subject).distinct().filter( - UploadBatch.subject.isnot(None), - UploadBatch.subject != "", - ) - if user_id is not None: - query = query.filter(UploadBatch.user_id == user_id) - return [r[0] for r in query.all()] - - -def get_existing_tag_names(db: Session, subject: Optional[str] = None) -> List[str]: - """获取数据库中已有的知识点标签名称列表(字符串)""" - query = db.query(KnowledgeTag.tag_name).distinct() - if subject: - query = query.filter(KnowledgeTag.subject == subject) - rows = query.order_by(KnowledgeTag.tag_name).all() - return [r[0] for r in rows] - - -def get_or_create_tag(db: Session, tag_name: str, subject: str) -> KnowledgeTag: - """获取或创建知识点标签""" - tag = db.query(KnowledgeTag).filter_by( - tag_name=tag_name, - subject=subject - ).first() - - if not tag: - tag = KnowledgeTag(tag_name=tag_name, subject=subject) - db.add(tag) - db.flush() - - return tag - - -def question_exists(db, content_hash, user_id=None): - """检查题目是否已存在(通过内容哈希 + 用户隔离)""" - q = db.query(Question).filter(Question.content_hash == content_hash) - if user_id is not None: - q = q.filter(Question.user_id == user_id) - return q.first() - - -def save_questions_to_db( - db: Session, - questions: List[Dict], - batch_info: Dict[str, Any], - user_id=None, -) -> Dict[str, int]: - """ - 批量入库题目 - - Args: - db: 数据库会话 - questions: 题目列表(字典格式,来自 questions.json) - batch_info: 批次信息,包含 original_filename, file_path 等 - - Returns: - dict: {"created": 新增数量, "duplicates": 重复数量} - """ - # 科目由编排智能体识别,不再使用关键词匹配 - subject = batch_info.get("subject") or "未知" - - # 创建批次记录 - batch = UploadBatch( - user_id=user_id, - original_filename=batch_info.get("original_filename", "未知"), - subject=subject, - file_path=batch_info.get("file_path", ""), - upload_time=batch_info.get("upload_time") or datetime.utcnow() - ) - db.add(batch) - db.flush() # 获取 batch.id - - created = 0 - duplicates = 0 - - for q in questions: - content_blocks = q.get("content_blocks", []) - if not content_blocks: - continue - - content_hash = compute_content_hash(content_blocks) - - # 检查是否已存在 - if question_exists(db, content_hash, user_id=user_id): - duplicates += 1 - continue - - # 创建题目记录 - question = Question( - user_id=user_id, - batch_id=batch.id, - content_hash=content_hash, - question_type=q.get("question_type"), - content_json=json.dumps(content_blocks, ensure_ascii=False), - options_json=json.dumps(q.get("options"), ensure_ascii=False) if q.get("options") else None, - has_formula=q.get("has_formula", False), - has_image=q.get("has_image", False), - image_refs_json=json.dumps(q.get("image_refs"), ensure_ascii=False) if q.get("image_refs") else None, - needs_correction=q.get("needs_correction", False), - ocr_issues_json=json.dumps(q.get("ocr_issues"), ensure_ascii=False) if q.get("ocr_issues") else None, - answer=q.get("answer") or None, - user_answer=q.get("user_answer") or None, - ) - db.add(question) - db.flush() - - # 处理知识点标签 - knowledge_tags = q.get("knowledge_tags") or [] - for tag_name in knowledge_tags: - tag = get_or_create_tag(db, tag_name, subject) - mapping = QuestionTagMapping( - question_id=question.id, - tag_id=tag.id - ) - db.add(mapping) - - created += 1 - - db.commit() - - return {"created": created, "duplicates": duplicates} - - -def get_questions_by_subject( - db: Session, - subject: str, - limit: int = 100, - offset: int = 0 -) -> List[Question]: - """按科目查询题目""" - return db.query(Question).join(UploadBatch).filter( - UploadBatch.subject == subject - ).order_by(Question.created_at.desc()).offset(offset).limit(limit).all() - - -def get_questions_by_tag( - db: Session, - tag_name: str, - limit: int = 100, - offset: int = 0 -) -> List[Question]: - """按标签查询题目""" - return db.query(Question).join(QuestionTagMapping).join(KnowledgeTag).filter( - KnowledgeTag.tag_name == tag_name - ).order_by(Question.created_at.desc()).offset(offset).limit(limit).all() - - -def get_all_tags(db: Session, subject: Optional[str] = None) -> List[KnowledgeTag]: - """获取所有标签(可按科目筛选)""" - query = db.query(KnowledgeTag) - if subject: - query = query.filter(KnowledgeTag.subject == subject) - return query.order_by(KnowledgeTag.tag_name).all() - - -def get_statistics(db: Session, subject: Optional[str] = None, user_id=None) -> Dict[str, Any]: - """获取统计信息""" - q_query = _filter_by_subject(db.query(func.count(Question.id)), subject) - if user_id is not None: - q_query = q_query.filter(Question.user_id == user_id) - total_questions = q_query.scalar() - - batch_query = db.query(func.count(UploadBatch.id)) - if user_id is not None: - batch_query = batch_query.filter(UploadBatch.user_id == user_id) - total_batches = batch_query.scalar() - - total_tags = db.query(func.count(KnowledgeTag.id)).scalar() - - # 按科目统计 - subject_q = db.query(UploadBatch.subject, func.count(Question.id)).join(Question) - if user_id is not None: - subject_q = subject_q.filter(Question.user_id == user_id) - subject_stats = subject_q.group_by(UploadBatch.subject).all() - - return { - "total_questions": total_questions or 0, - "total_batches": total_batches or 0, - "total_tags": total_tags or 0, - "by_subject": {s: c for s, c in subject_stats} - } - - -def get_history_questions( - db: Session, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - page: int = 1, - page_size: int = 20 -) -> Tuple[List[Question], int]: - """ - 分页查询历史题目(全部题目) - - Args: - db: 数据库会话 - start_date: 开始日期筛选 - end_date: 结束日期筛选 - page: 页码(从1开始) - page_size: 每页数量 - - Returns: - (题目列表, 总数) - """ - query = db.query(Question).join(UploadBatch) - - if start_date: - query = query.filter(UploadBatch.upload_time >= start_date) - if end_date: - from datetime import timedelta - query = query.filter(UploadBatch.upload_time < end_date + timedelta(days=1)) - - # 获取总数 - total = query.count() - - # 分页查询 - offset = (page - 1) * page_size - questions = ( - query.options(selectinload(Question.batch), selectinload(Question.tags).selectinload(QuestionTagMapping.tag)) - .order_by(Question.created_at.desc()) - .offset(offset) - .limit(page_size) - .all() - ) - - return questions, total - - -def search_questions( - db: Session, - keyword: Optional[str] = None, - knowledge_tag: Optional[str] = None, - question_type: Optional[str] = None, - page: int = 1, - page_size: int = 20 -) -> Tuple[List[Question], int]: - """ - 搜索题目(知识点/题型/关键字) - - Args: - db: 数据库会话 - keyword: 关键字搜索(匹配题目内容 content_json) - knowledge_tag: 知识点标签筛选 - question_type: 题型筛选 - page: 页码(从1开始) - page_size: 每页数量 - - Returns: - (题目列表, 总数) - """ - query = db.query(Question) - - # 关键字搜索:匹配 content_json 中的内容 - if keyword: - escaped = re.sub(r"([%_\\])", r"\\\1", keyword) - query = query.filter(Question.content_json.ilike(f"%{escaped}%")) - - # 题型筛选 - if question_type: - query = query.filter(Question.question_type == question_type) - - # 知识点标签筛选(支持逗号分隔多选,OR 语义) - if knowledge_tag: - tag_list = _parse_tag_list(knowledge_tag) - if tag_list: - query = query.join(QuestionTagMapping).join(KnowledgeTag).filter( - KnowledgeTag.tag_name.in_(tag_list) - ) - - # 获取总数(需要先去除distinct,因为join可能产生重复) - total = query.distinct().count() - - # 分页查询 - offset = (page - 1) * page_size - questions = ( - query.distinct() - .options(selectinload(Question.batch), selectinload(Question.tags).selectinload(QuestionTagMapping.tag)) - .order_by(Question.created_at.desc()) - .offset(offset) - .limit(page_size) - .all() - ) - - return questions, total - - -def get_knowledge_stats(db: Session, subject: Optional[str] = None, limit: Optional[int] = None, user_id=None) -> List[Dict]: - """ - 获取知识点统计信息 - - Args: - subject: 可选,按学科筛选 - limit: 可选,返回前 N 条 - user_id: 可选,按用户隔离 - - Returns: - [{"tag_name": "xxx", "count": 10}, ...] - """ - query = db.query( - KnowledgeTag.tag_name, - func.count(QuestionTagMapping.question_id).label("count") - ).join( - QuestionTagMapping, QuestionTagMapping.tag_id == KnowledgeTag.id - ) - - if user_id is not None: - query = query.join(Question, Question.id == QuestionTagMapping.question_id).filter( - Question.user_id == user_id - ) - - if subject: - query = query.filter(KnowledgeTag.subject == subject) - - query = query.group_by( - KnowledgeTag.id, KnowledgeTag.tag_name - ).order_by( - func.count(QuestionTagMapping.question_id).desc() - ) - - if limit: - query = query.limit(limit) - - stats = query.all() - - return [{"tag_name": tag_name, "count": count} for tag_name, count in stats] - - -def delete_question(db: Session, question_id: int) -> bool: - """ - 删除题目 - - Args: - db: 数据库会话 - question_id: 题目ID - - Returns: - 是否删除成功 - """ - question = db.query(Question).filter(Question.id == question_id).first() - if not question: - return False - - try: - # 删除关联的标签映射 - db.query(QuestionTagMapping).filter(QuestionTagMapping.question_id == question_id).delete() - - # 删除题目 - db.delete(question) - db.commit() - except Exception as e: - db.rollback() - logger.error(f"删除题目 {question_id} 失败: {e}") - raise - - return True - - -def query_questions( - db: Session, - subject: Optional[str] = None, - knowledge_tag: Optional[str] = None, - question_type: Optional[str] = None, - keyword: Optional[str] = None, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - review_status: Optional[str] = None, - page: int = 1, - page_size: int = 20, - user_id=None, -) -> Tuple[List[Question], int]: - """ - 统一查询题目(合并 get_history_questions 和 search_questions 的能力) - - 支持所有筛选条件任意组合。 - """ - query = db.query(Question).join(UploadBatch) - - if user_id is not None: - query = query.filter(Question.user_id == user_id) - - if subject: - query = query.filter(UploadBatch.subject == subject) - - if question_type: - query = query.filter(Question.question_type == question_type) - - if keyword: - escaped = re.sub(r"([%_\\])", r"\\\1", keyword) - query = query.filter(Question.content_json.ilike(f"%{escaped}%")) - - if knowledge_tag: - tag_list = _parse_tag_list(knowledge_tag) - if tag_list: - query = query.join(QuestionTagMapping).join(KnowledgeTag).filter( - KnowledgeTag.tag_name.in_(tag_list) - ) - - if start_date: - query = query.filter(Question.created_at >= start_date) - if end_date: - from datetime import timedelta - query = query.filter(Question.created_at < end_date + timedelta(days=1)) - - if review_status: - query = query.filter(Question.review_status == review_status) - - total = query.distinct().count() - - offset = (page - 1) * page_size - questions = ( - query.distinct() - .options(selectinload(Question.batch), selectinload(Question.tags).selectinload(QuestionTagMapping.tag)) - .order_by(Question.created_at.desc()) - .offset(offset) - .limit(page_size) - .all() - ) - - return questions, total - - -def update_user_answer(db: Session, question_id: int, user_answer: str) -> Optional[Question]: - """更新用户答案""" - question = db.query(Question).filter(Question.id == question_id).first() - if not question: - return None - - try: - question.user_answer = user_answer - question.updated_at = datetime.utcnow() - db.commit() - db.refresh(question) - return question - except Exception as e: - db.rollback() - logger.error(f"更新题目 {question_id} 答案失败: {e}") - raise - - -def get_existing_question_types(db: Session, user_id=None) -> List[str]: - """获取数据库中已有的所有题型(去重)""" - query = db.query(Question.question_type).distinct().filter( - Question.question_type.isnot(None), - Question.question_type != "", - ) - if user_id is not None: - query = query.filter(Question.user_id == user_id) - return [r[0] for r in query.all()] - - -def get_questions_by_ids(db: Session, question_ids: List[int]) -> List[Question]: - """按 ID 列表批量查询题目""" - if not question_ids: - return [] - return ( - db.query(Question) - .options(joinedload(Question.batch), joinedload(Question.tags).joinedload(QuestionTagMapping.tag)) - .filter(Question.id.in_(question_ids)) - .all() - ) - - -VALID_REVIEW_STATUSES = ('待复习', '复习中', '已掌握') - - -def update_review_status(db: Session, question_id: int, review_status: str) -> Optional[Question]: - """更新题目复习状态""" - if review_status not in VALID_REVIEW_STATUSES: - raise ValueError(f"无效的复习状态: {review_status},可选值: {VALID_REVIEW_STATUSES}") - - question = db.query(Question).filter(Question.id == question_id).first() - if not question: - return None - - try: - question.review_status = review_status - question.updated_at = datetime.utcnow() - db.commit() - db.refresh(question) - return question - except Exception as e: - db.rollback() - logger.error(f"更新题目 {question_id} 复习状态失败: {e}") - raise - - -def get_review_status_stats(db: Session, subject: Optional[str] = None, user_id=None) -> Dict[str, int]: - """按复习状态分组统计数量""" - query = _filter_by_subject(db.query( - Question.review_status, - func.count(Question.id) - ), subject) - if user_id is not None: - query = query.filter(Question.user_id == user_id) - - rows = query.group_by(Question.review_status).all() - - result = {s: 0 for s in VALID_REVIEW_STATUSES} - for status, count in rows: - key = status or '待复习' - if key in result: - result[key] += count - else: - result['待复习'] += count - return result - - -def get_daily_counts(db: Session, days: int = 7, subject: Optional[str] = None, user_id=None) -> List[Dict[str, Any]]: - """获取最近 N 天每日新增题目数 + 每日新增已掌握数""" - from datetime import timedelta - cutoff = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days - 1) - - # SQLite 兼容:使用 func.date() 提取日期字符串 - date_expr = func.date(Question.created_at) - - # 新增题目数 - query = _filter_by_subject(db.query( - date_expr.label('date'), - func.count(Question.id).label('count') - ).filter(Question.created_at >= cutoff), subject) - if user_id is not None: - query = query.filter(Question.user_id == user_id) - rows = query.group_by(date_expr).order_by(date_expr).all() - date_map = {str(row.date): row.count for row in rows} - - # 每日新增已掌握数(按 updated_at 统计) - mastered_date_expr = func.date(Question.updated_at) - mq = _filter_by_subject(db.query( - mastered_date_expr.label('date'), - func.count(Question.id).label('count') - ).filter( - Question.updated_at >= cutoff, - Question.review_status == '已掌握', - ), subject) - if user_id is not None: - mq = mq.filter(Question.user_id == user_id) - mastered_rows = mq.group_by(mastered_date_expr).order_by(mastered_date_expr).all() - mastered_map = {str(row.date): row.count for row in mastered_rows} - - # 填充缺失的日期 - result = [] - for i in range(days): - d = cutoff + timedelta(days=i) - date_key = d.strftime('%Y-%m-%d') - date_str = d.strftime('%m-%d') - result.append({ - 'date': date_str, - 'count': date_map.get(date_key, 0), - 'mastered': mastered_map.get(date_key, 0), - }) - - return result - - -def get_tag_status_stats(db: Session, subject: Optional[str] = None, limit: int = 10, user_id=None) -> List[Dict]: - """ - 知识点 × 掌握状态统计(堆叠柱状图用) - - Returns: - [{"tag_name": "函数", "待复习": 3, "复习中": 2, "已掌握": 5}, ...] - """ - query = db.query( - KnowledgeTag.tag_name, - Question.review_status, - func.count(Question.id).label('count') - ).join( - QuestionTagMapping, QuestionTagMapping.tag_id == KnowledgeTag.id - ).join( - Question, Question.id == QuestionTagMapping.question_id - ) - - if user_id is not None: - query = query.filter(Question.user_id == user_id) - if subject: - query = query.filter(KnowledgeTag.subject == subject) - - rows = query.group_by( - KnowledgeTag.tag_name, Question.review_status - ).all() - - # 按 tag 聚合 - tag_data = {} - tag_totals = {} - for tag_name, status, count in rows: - if tag_name not in tag_data: - tag_data[tag_name] = {'tag_name': tag_name, **{s: 0 for s in VALID_REVIEW_STATUSES}} - tag_totals[tag_name] = 0 - key = status or '待复习' - if key in VALID_REVIEW_STATUSES: - tag_data[tag_name][key] += count - else: - tag_data[tag_name]['待复习'] += count - tag_totals[tag_name] += count - - # 按总数降序,取 top N - sorted_tags = sorted(tag_data.keys(), key=lambda t: tag_totals[t], reverse=True)[:limit] - return [tag_data[t] for t in sorted_tags] - - -def get_tag_type_stats(db: Session, subject: Optional[str] = None, tag_limit: int = 8, user_id=None) -> Dict[str, Any]: - """ - 知识点 × 题型交叉统计(热力图用) - - Returns: - {"tags": ["函数", ...], "types": ["选择题", ...], "data": [[3, 1, ...], ...]} - """ - query = db.query( - KnowledgeTag.tag_name, - Question.question_type, - func.count(Question.id).label('count') - ).join( - QuestionTagMapping, QuestionTagMapping.tag_id == KnowledgeTag.id - ).join( - Question, Question.id == QuestionTagMapping.question_id - ).filter( - Question.question_type.isnot(None), - Question.question_type != '', - ) - - if user_id is not None: - query = query.filter(Question.user_id == user_id) - if subject: - query = query.filter(KnowledgeTag.subject == subject) - - rows = query.group_by( - KnowledgeTag.tag_name, Question.question_type - ).all() - - # 收集所有 tag 和 type - tag_totals = {} - type_set = set() - cross = {} - for tag_name, q_type, count in rows: - tag_totals[tag_name] = tag_totals.get(tag_name, 0) + count - type_set.add(q_type) - cross[(tag_name, q_type)] = count - - # 按总数取 top N tag - sorted_tags = sorted(tag_totals.keys(), key=lambda t: tag_totals[t], reverse=True)[:tag_limit] - sorted_types = sorted(type_set) - - # 构建矩阵 - data = [] - for tag in sorted_tags: - row = [cross.get((tag, t), 0) for t in sorted_types] - data.append(row) - - return {"tags": sorted_tags, "types": sorted_types, "data": data} - - -# ============================================================ -# 教学辅导对话 CRUD -# ============================================================ - - -def update_question_answer(db: Session, question_id: int, answer: str) -> Optional[Question]: - """保存/更新题目答案(Markdown 格式)""" - question = db.query(Question).filter(Question.id == question_id).first() - if not question: - return None - - try: - question.answer = answer - question.updated_at = datetime.utcnow() - db.commit() - db.refresh(question) - return question - except Exception as e: - db.rollback() - logger.error(f"保存题目 {question_id} 答案失败: {e}") - raise - - -def create_chat_session(db: Session, question_id: int) -> ChatSession: - """为题目创建新的对话会话""" - session = ChatSession(question_id=question_id) - db.add(session) - try: - db.commit() - db.refresh(session) - return session - except Exception as e: - db.rollback() - logger.error(f"创建对话会话失败: {e}") - raise - - -_VALID_ROLES = ('user', 'assistant') - - -def add_chat_message(db: Session, session_id: int, role: str, content: str) -> ChatMessage: - """向对话中追加一条消息""" - if role not in _VALID_ROLES: - raise ValueError(f"无效的消息角色: {role}(可选: {', '.join(_VALID_ROLES)})") - msg = ChatMessage(session_id=session_id, role=role, content=content) - db.add(msg) - try: - # 同步更新会话的 updated_at(直接 UPDATE 避免加载整行) - db.query(ChatSession).filter(ChatSession.id == session_id).update( - {"updated_at": datetime.utcnow()}, synchronize_session=False - ) - db.commit() - db.refresh(msg) - return msg - except Exception as e: - db.rollback() - logger.error(f"添加对话消息失败: {e}") - raise - - -def get_chat_messages( - db: Session, - session_id: int, - limit: int = 30, - before_id: Optional[int] = None, -) -> Dict[str, Any]: - """ - 游标分页获取对话消息 - - Args: - session_id: 会话 ID - limit: 每次返回的消息数 - before_id: 游标,返回 ID 小于此值的消息 - - Returns: - {"messages": [...], "hasMore": bool} - """ - query = db.query(ChatMessage).filter(ChatMessage.session_id == session_id) - if before_id: - query = query.filter(ChatMessage.id < before_id) - - # 按 ID 降序取 limit+1 条,判断是否还有更早消息 - rows = query.order_by(ChatMessage.id.desc()).limit(limit + 1).all() - - has_more = len(rows) > limit - messages = rows[:limit] - messages.reverse() # 恢复正序 - - return { - "messages": [ - {"id": m.id, "role": m.role, "content": m.content, "created_at": m.created_at.isoformat() if m.created_at else None} - for m in messages - ], - "hasMore": has_more, - } - - -def get_chat_sessions_by_question(db: Session, question_id: int, limit: int = 50) -> List[ChatSession]: - """获取某道题目的对话会话(按更新时间降序,默认最多 50 条)""" - return ( - db.query(ChatSession) - .filter(ChatSession.question_id == question_id) - .order_by(ChatSession.updated_at.desc()) - .limit(limit) - .all() - ) - - -# ============================================================ -# 分割历史记录 CRUD -# ============================================================ - - -MAX_SPLIT_RECORDS = 20 - - -def save_split_record( - db: Session, - subject: Optional[str], - model_provider: str, - file_names: List[str], - questions: List[Dict], - user_id=None, -) -> SplitRecord: - """保存一次分割操作的完整结果,超过上限自动清理最旧记录""" - record = SplitRecord( - user_id=user_id, - subject=subject, - model_provider=model_provider, - file_names_json=json.dumps(file_names, ensure_ascii=False), - questions_json=json.dumps(questions, ensure_ascii=False), - question_count=len(questions), - ) - db.add(record) - try: - db.commit() - db.refresh(record) - _cleanup_old_split_records(db) - return record - except Exception as e: - db.rollback() - logger.error(f"保存分割记录失败: {e}") - raise - - -def _cleanup_old_split_records(db: Session): - """删除超出上限的最旧分割记录""" - count = db.query(func.count(SplitRecord.id)).scalar() - if count <= MAX_SPLIT_RECORDS: - return - overflow = count - MAX_SPLIT_RECORDS - old_ids = [ - row[0] for row in - db.query(SplitRecord.id) - .order_by(SplitRecord.created_at.asc()) - .limit(overflow) - .all() - ] - try: - db.query(SplitRecord).filter(SplitRecord.id.in_(old_ids)).delete(synchronize_session=False) - db.commit() - logger.info(f"已清理 {overflow} 条过期分割记录") - except Exception as e: - db.rollback() - logger.error(f"清理分割记录失败: {e}") - - -def get_recent_split_records(db, limit: int = 10, user_id=None): - """获取最近 N 条分割记录(不加载 questions_json 大字段)""" - from sqlalchemy.orm import defer - return ( - db.query(SplitRecord) - .options(defer(SplitRecord.questions_json)) - .order_by(SplitRecord.created_at.desc()) - .limit(limit) - .all() - ) - - -def get_split_record_by_id(db: Session, record_id: int) -> Optional[SplitRecord]: - """按 ID 获取单条分割记录(含完整 questions_json)""" - return db.query(SplitRecord).filter(SplitRecord.id == record_id).first() - - -def get_all_chat_sessions( - db: Session, - page: int = 1, - page_size: int = 20, -) -> Tuple[List[ChatSession], int]: - """分页获取所有对话会话""" - query = db.query(ChatSession) - total = query.count() - offset = (page - 1) * page_size - sessions = ( - query.options(selectinload(ChatSession.question)) - .order_by(ChatSession.updated_at.desc()) - .offset(offset) - .limit(page_size) - .all() - ) - return sessions, total - - -# ── Provider Config CRUD ────────────────────────────────────── - - -def _mask_secret(value: str, visible: int = 4) -> str: - """脱敏显示密钥""" - if not value: - return "" - if len(value) <= visible: - return "*" * len(value) - return "*" * 4 + value[-visible:] - - -def _serialize_provider(p: ProviderConfig) -> dict: - """序列化 ProviderConfig 为前端可用 dict(密钥脱敏)""" - d = { - "id": p.id, - "name": p.name, - "is_active": p.is_active, - "api_key_set": bool(p.api_key), - "api_key_hint": _mask_secret(p.api_key), - "base_url": p.base_url or "", - "model_name": p.model_name or "", - } - if p.category == "openai": - d["light_model_name"] = p.light_model_name or "" - d["supports_function_calling"] = p.supports_function_calling - elif p.category == "paddleocr": - d["use_doc_orientation"] = p.use_doc_orientation - d["use_doc_unwarping"] = p.use_doc_unwarping - d["use_chart_recognition"] = p.use_chart_recognition - return d - - -def get_user_providers(db: Session, user_id: int) -> dict: - """获取用户的所有 provider 配置,按类别分组返回""" - providers = db.query(ProviderConfig).filter( - ProviderConfig.user_id == user_id - ).order_by(ProviderConfig.created_at).all() - - result = { - "openai_providers": [], - "anthropic_providers": [], - "paddleocr_providers": [], - "active_openai_id": None, - "active_anthropic_id": None, - "active_paddleocr_id": None, - } - category_key = { - "openai": "openai_providers", - "anthropic": "anthropic_providers", - "paddleocr": "paddleocr_providers", - } - active_key = { - "openai": "active_openai_id", - "anthropic": "active_anthropic_id", - "paddleocr": "active_paddleocr_id", - } - - for p in providers: - key = category_key.get(p.category) - if key: - result[key].append(_serialize_provider(p)) - if p.is_active: - ak = active_key.get(p.category) - if ak: - result[ak] = p.id - - return result - - -def save_user_providers(db: Session, user_id: int, data: dict) -> None: - """保存用户的 provider 配置(全量覆盖) - - data 结构: - openai_providers: [{ id, name, api_key?, base_url, model_name, ... }] - anthropic_providers: [...] - paddleocr_providers: [...] - active_openai_id: str | None - active_anthropic_id: str | None - active_paddleocr_id: str | None - """ - active_ids = { - "openai": data.get("active_openai_id"), - "anthropic": data.get("active_anthropic_id"), - "paddleocr": data.get("active_paddleocr_id"), - } - - # 读取已有配置(用于保留未重新提交的 api_key) - existing = { - p.id: p for p in db.query(ProviderConfig).filter( - ProviderConfig.user_id == user_id - ).all() - } - - # 收集本次提交的所有 ID - submitted_ids = set() - items_to_save = [] - - category_map = { - "openai_providers": "openai", - "anthropic_providers": "anthropic", - "paddleocr_providers": "paddleocr", - } - - for list_key, category in category_map.items(): - for item in data.get(list_key, []): - pid = item.get("id") - submitted_ids.add(pid) - old = existing.get(pid) - - # API Key:前端提交了新值则更新,否则保留旧值 - new_api_key = item.get("api_key") or item.get("api_token") or "" - if not new_api_key and old: - new_api_key = old.api_key or "" - - base_url = item.get("base_url") or item.get("api_url") or "" - model_name = item.get("model_name") or item.get("model") or "" - - items_to_save.append({ - "id": pid, - "category": category, - "name": item.get("name", ""), - "is_active": pid == active_ids.get(category), - "api_key": new_api_key, - "base_url": base_url, - "model_name": model_name, - "light_model_name": item.get("light_model_name", ""), - "supports_function_calling": item.get("supports_function_calling", True), - "use_doc_orientation": item.get("use_doc_orientation", False), - "use_doc_unwarping": item.get("use_doc_unwarping", False), - "use_chart_recognition": item.get("use_chart_recognition", False), - }) - - # 删除已移除的 provider - for pid in set(existing.keys()) - submitted_ids: - db.delete(existing[pid]) - - # 更新或新增 - for item in items_to_save: - old = existing.get(item["id"]) - if old: - for k, v in item.items(): - setattr(old, k, v) - else: - p = ProviderConfig(user_id=user_id, **item) - db.add(p) - - db.commit() - - -def get_active_provider(db: Session, user_id: int, category: str) -> Optional[ProviderConfig]: - """获取用户指定类别的激活 provider(用于后端调用 LLM/OCR 时读取凭据)""" - return db.query(ProviderConfig).filter( - ProviderConfig.user_id == user_id, - ProviderConfig.category == category, - ProviderConfig.is_active == True, - ).first() diff --git a/backend/db/crud/__init__.py b/backend/db/crud/__init__.py new file mode 100644 index 00000000..bd8967e2 --- /dev/null +++ b/backend/db/crud/__init__.py @@ -0,0 +1,187 @@ +""" +数据库 CRUD 操作 + +所有公共函数从子模块汇总导出,保持 ``from db import crud; crud.xxx()`` 的使用方式不变。 +""" + +from typing import Optional + +from db.models import UploadBatch, Question + + +# ── 共享过滤辅助函数 ────────────────────────────────────────── + +def _filter_by_subject(query, subject: Optional[str]): + """如有学科筛选,为 Question 查询追加 JOIN UploadBatch 过滤""" + if subject: + return query.join(UploadBatch).filter(UploadBatch.subject == subject) + return query + + +def _filter_by_user(query, user_id): + if user_id is not None: + query = query.filter(Question.user_id == user_id) + return query + + +# ── 子模块导出 ──────────────────────────────────────────────── + +from db.crud.users import ( + create_user, + get_user_by_email, + get_user_by_id, + get_user_by_login, + update_user_password, +) + +from db.crud.email_verification import ( + get_verification_by_email, + upsert_registration_code, + delete_verification_by_email, + increment_verification_attempts, +) + +from db.crud.tags import ( + _parse_tag_list, + get_or_create_tag, + get_existing_tag_names, + get_all_tags, +) + +from db.crud.questions import ( + compute_content_hash, + question_exists, + save_questions_to_db, + get_questions_by_subject, + get_questions_by_tag, + get_history_questions, + search_questions, + query_questions, + get_questions_by_ids, + delete_question, + update_user_answer, + update_question_answer, + update_review_status, + get_existing_subjects, + get_existing_question_types, + VALID_REVIEW_STATUSES, +) + +from db.crud.stats import ( + get_statistics, + get_knowledge_stats, + get_review_status_stats, + get_today_mastered_count, + get_daily_counts, + get_tag_status_stats, + get_tag_type_stats, +) + +from db.crud.chat import ( + create_chat_session, + add_chat_message, + get_chat_messages, + get_chat_sessions_by_question, + get_all_chat_sessions, + get_user_chat_sessions, + update_chat_session_title, + delete_chat_session, +) + +from db.crud.split_records import ( + MAX_SPLIT_RECORDS, + save_split_record, + _cleanup_old_split_records, + get_recent_split_records, + get_split_record_by_id, +) + +from db.crud.providers import ( + _mask_secret, + _serialize_provider, + get_user_providers, + save_user_providers, + get_active_provider, +) + +from db.crud.notes import ( + save_note, + get_notes, + get_note_by_id, + update_note, + delete_note, +) + +__all__ = [ + # shared helpers + "_filter_by_subject", + "_filter_by_user", + # users + "create_user", + "get_user_by_email", + "get_user_by_id", + "get_user_by_login", + "update_user_password", + # email verification + "get_verification_by_email", + "upsert_registration_code", + "delete_verification_by_email", + "increment_verification_attempts", + # tags + "_parse_tag_list", + "get_or_create_tag", + "get_existing_tag_names", + "get_all_tags", + # questions + "compute_content_hash", + "question_exists", + "save_questions_to_db", + "get_questions_by_subject", + "get_questions_by_tag", + "get_history_questions", + "search_questions", + "query_questions", + "get_questions_by_ids", + "delete_question", + "update_user_answer", + "update_question_answer", + "update_review_status", + "get_existing_subjects", + "get_existing_question_types", + "VALID_REVIEW_STATUSES", + # stats + "get_statistics", + "get_knowledge_stats", + "get_review_status_stats", + "get_today_mastered_count", + "get_daily_counts", + "get_tag_status_stats", + "get_tag_type_stats", + # chat + "create_chat_session", + "add_chat_message", + "get_chat_messages", + "get_chat_sessions_by_question", + "get_all_chat_sessions", + "get_user_chat_sessions", + "update_chat_session_title", + "delete_chat_session", + # split_records + "MAX_SPLIT_RECORDS", + "save_split_record", + "_cleanup_old_split_records", + "get_recent_split_records", + "get_split_record_by_id", + # providers + "_mask_secret", + "_serialize_provider", + "get_user_providers", + "save_user_providers", + "get_active_provider", + # notes + "save_note", + "get_notes", + "get_note_by_id", + "update_note", + "delete_note", +] diff --git a/backend/db/crud/chat.py b/backend/db/crud/chat.py new file mode 100644 index 00000000..83fc3556 --- /dev/null +++ b/backend/db/crud/chat.py @@ -0,0 +1,176 @@ +"""对话 CRUD(支持题目绑定对话和独立对话)""" + +import logging +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +from sqlalchemy.orm import Session, selectinload + +from db.models import ChatSession, ChatMessage + +logger = logging.getLogger(__name__) + + +def create_chat_session( + db: Session, + question_id: int = None, + user_id: int = None, + title: str = "新对话", +) -> ChatSession: + """创建对话会话 + + Args: + question_id: 绑定的题目 ID(可选,None 为独立对话) + user_id: 用户 ID + title: 对话标题 + """ + session = ChatSession(question_id=question_id, user_id=user_id, title=title) + db.add(session) + try: + db.commit() + db.refresh(session) + return session + except Exception as e: + db.rollback() + logger.error(f"创建对话会话失败: {e}") + raise + + +_VALID_ROLES = ('user', 'assistant') + + +def add_chat_message(db: Session, session_id: int, role: str, content: str, user_id=None) -> ChatMessage: + """向对话中追加一条消息""" + if role not in _VALID_ROLES: + raise ValueError(f"无效的消息角色: {role}(可选: {', '.join(_VALID_ROLES)})") + # 验证 session 归属 + if user_id is not None: + owner_check = db.query(ChatSession).filter( + ChatSession.id == session_id, ChatSession.user_id == user_id + ).first() + if not owner_check: + raise ValueError("对话不存在或无权操作") + msg = ChatMessage(session_id=session_id, role=role, content=content) + db.add(msg) + try: + db.query(ChatSession).filter(ChatSession.id == session_id).update( + {"updated_at": datetime.utcnow()}, synchronize_session=False + ) + db.commit() + db.refresh(msg) + return msg + except Exception as e: + db.rollback() + logger.error(f"添加对话消息失败: {e}") + raise + + +def get_chat_messages( + db: Session, + session_id: int, + limit: int = 30, + before_id: Optional[int] = None, + user_id=None, +) -> Dict[str, Any]: + """游标分页获取对话消息""" + # 验证 session 归属 + if user_id is not None: + owner_check = db.query(ChatSession).filter( + ChatSession.id == session_id, ChatSession.user_id == user_id + ).first() + if not owner_check: + return {"messages": [], "hasMore": False} + query = db.query(ChatMessage).filter(ChatMessage.session_id == session_id) + if before_id: + query = query.filter(ChatMessage.id < before_id) + + rows = query.order_by(ChatMessage.id.desc()).limit(limit + 1).all() + has_more = len(rows) > limit + messages = rows[:limit] + messages.reverse() + + return { + "messages": [ + {"id": m.id, "role": m.role, "content": m.content, "created_at": m.created_at.isoformat() if m.created_at else None} + for m in messages + ], + "hasMore": has_more, + } + + +def get_chat_sessions_by_question(db: Session, question_id: int, limit: int = 50, user_id=None) -> List[ChatSession]: + """获取某道题目的对话会话""" + query = db.query(ChatSession).filter(ChatSession.question_id == question_id) + if user_id is not None: + query = query.filter(ChatSession.user_id == user_id) + return query.order_by(ChatSession.updated_at.desc()).limit(limit).all() + + +def get_user_chat_sessions( + db: Session, + user_id: int, + page: int = 1, + page_size: int = 20, +) -> Tuple[List[ChatSession], int]: + """获取用户的独立对话列表(不含题目绑定的对话)""" + query = db.query(ChatSession).filter( + ChatSession.user_id == user_id, + ChatSession.question_id == None, + ) + total = query.count() + sessions = ( + query.order_by(ChatSession.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return sessions, total + + +def get_all_chat_sessions( + db: Session, + page: int = 1, + page_size: int = 20, + user_id=None, +) -> Tuple[List[ChatSession], int]: + """分页获取对话会话(user_id 非 None 时按用户过滤)""" + query = db.query(ChatSession) + if user_id is not None: + query = query.filter(ChatSession.user_id == user_id) + total = query.count() + sessions = ( + query.options(selectinload(ChatSession.question)) + .order_by(ChatSession.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return sessions, total + + +def update_chat_session_title(db: Session, session_id: int, title: str, user_id=None) -> Optional[ChatSession]: + """更新对话标题""" + query = db.query(ChatSession).filter(ChatSession.id == session_id) + if user_id is not None: + query = query.filter(ChatSession.user_id == user_id) + session = query.first() + if not session: + return None + session.title = title + db.commit() + db.refresh(session) + return session + + +def delete_chat_session(db: Session, session_id: int, user_id=None) -> bool: + """删除对话(级联删除消息)""" + query = db.query(ChatSession).filter(ChatSession.id == session_id) + if user_id is not None: + query = query.filter(ChatSession.user_id == user_id) + session = query.first() + if not session: + return False + db.query(ChatMessage).filter(ChatMessage.session_id == session_id).delete() + db.delete(session) + db.commit() + return True diff --git a/backend/db/crud/email_verification.py b/backend/db/crud/email_verification.py new file mode 100644 index 00000000..b5d845b7 --- /dev/null +++ b/backend/db/crud/email_verification.py @@ -0,0 +1,52 @@ +"""注册邮箱验证码 CRUD""" + +from datetime import datetime + +from db.models import EmailVerification + + +def get_verification_by_email(db, email: str): + return db.query(EmailVerification).filter(EmailVerification.email == email).first() + + +def upsert_registration_code( + db, + email: str, + code_hash: str, + expires_at: datetime, + last_sent_at: datetime, +) -> EmailVerification: + row = get_verification_by_email(db, email) + if row: + row.code_hash = code_hash + row.expires_at = expires_at + row.last_sent_at = last_sent_at + # 不重置 attempts:防止攻击者反复请求新验证码来绕过尝试次数限制 + else: + row = EmailVerification( + email=email, + code_hash=code_hash, + expires_at=expires_at, + last_sent_at=last_sent_at, + attempts=0, + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def delete_verification_by_email(db, email: str) -> None: + row = get_verification_by_email(db, email) + if row: + db.delete(row) + db.commit() + + +def increment_verification_attempts(db, email: str) -> int: + row = get_verification_by_email(db, email) + if not row: + return 0 + row.attempts = (row.attempts or 0) + 1 + db.commit() + return row.attempts diff --git a/backend/db/crud/notes.py b/backend/db/crud/notes.py new file mode 100644 index 00000000..89b0bf9b --- /dev/null +++ b/backend/db/crud/notes.py @@ -0,0 +1,180 @@ +""" +笔记 CRUD 操作 +""" + +import json +import logging +from typing import List, Dict, Any, Optional + +from sqlalchemy.orm import Session, selectinload + +from db.models import Note, NoteTagMapping, KnowledgeTag + +logger = logging.getLogger(__name__) + + +def save_note( + db: Session, + title: str, + subject: str, + content_markdown: str, + source_images: List[str], + ocr_text: str, + knowledge_tags: List[str] = None, + user_id: int = None, +) -> Note: + """保存一条笔记到数据库 + + Args: + title: 笔记标题 + subject: 科目 + content_markdown: LLM 整理后的 Markdown 内容 + source_images: 原始上传图片路径列表 + ocr_text: OCR 识别的原始文本 + knowledge_tags: 知识点标签名称列表 + user_id: 用户 ID + + Returns: + 创建的 Note 对象 + """ + note = Note( + user_id=user_id, + title=title, + subject=subject, + content_markdown=content_markdown, + source_images_json=json.dumps(source_images, ensure_ascii=False), + ocr_text=ocr_text, + ) + db.add(note) + db.flush() # 获取 note.id + + # 创建知识点标签关联 + if knowledge_tags: + from db.crud.tags import get_or_create_tag + for tag_name in knowledge_tags: + tag_name = tag_name.strip() + if not tag_name: + continue + tag = get_or_create_tag(db, tag_name, subject or "") + mapping = NoteTagMapping(note_id=note.id, tag_id=tag.id) + db.add(mapping) + + db.commit() + db.refresh(note) + return note + + +def get_notes( + db: Session, + user_id: int = None, + subject: str = None, + knowledge_tag: str = None, + keyword: str = None, + page: int = 1, + page_size: int = 20, +) -> tuple: + """分页查询笔记列表 + + Returns: + (笔记列表, 总数) + """ + query = db.query(Note).options( + selectinload(Note.tags).selectinload(NoteTagMapping.tag), + ) + + if user_id is not None: + query = query.filter(Note.user_id == user_id) + if subject: + query = query.filter(Note.subject == subject) + if keyword: + query = query.filter( + Note.title.ilike(f"%{keyword}%") | + Note.content_markdown.ilike(f"%{keyword}%") + ) + if knowledge_tag: + query = query.join(NoteTagMapping).join(KnowledgeTag).filter( + KnowledgeTag.tag_name == knowledge_tag + ) + + total = query.count() + notes = ( + query.order_by(Note.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return notes, total + + +def get_note_by_id(db: Session, note_id: int, user_id=None) -> Optional[Note]: + """根据 ID 获取单条笔记""" + query = db.query(Note).options( + selectinload(Note.tags).selectinload(NoteTagMapping.tag), + ).filter(Note.id == note_id) + if user_id is not None: + query = query.filter(Note.user_id == user_id) + return query.first() + + +def update_note( + db: Session, + note_id: int, + title: str = None, + content_markdown: str = None, + subject: str = None, + knowledge_tags: List[str] = None, + user_id=None, +) -> Optional[Note]: + """更新笔记内容 + + Args: + note_id: 笔记 ID + title: 新标题(None 不更新) + content_markdown: 新 Markdown 内容(None 不更新) + subject: 新科目(None 不更新) + knowledge_tags: 新标签列表(None 不更新,空列表清空标签) + + Returns: + 更新后的 Note 对象,不存在返回 None + """ + query = db.query(Note).filter(Note.id == note_id) + if user_id is not None: + query = query.filter(Note.user_id == user_id) + note = query.first() + if not note: + return None + + if title is not None: + note.title = title + if content_markdown is not None: + note.content_markdown = content_markdown + if subject is not None: + note.subject = subject + + # 更新标签:先删后建 + if knowledge_tags is not None: + db.query(NoteTagMapping).filter(NoteTagMapping.note_id == note_id).delete() + from db.crud.tags import get_or_create_tag + for tag_name in knowledge_tags: + tag_name = tag_name.strip() + if not tag_name: + continue + tag = get_or_create_tag(db, tag_name, note.subject or "") + db.add(NoteTagMapping(note_id=note.id, tag_id=tag.id)) + + db.commit() + db.refresh(note) + return note + + +def delete_note(db: Session, note_id: int, user_id=None) -> bool: + """删除笔记(级联删除标签关联)""" + query = db.query(Note).filter(Note.id == note_id) + if user_id is not None: + query = query.filter(Note.user_id == user_id) + note = query.first() + if not note: + return False + db.delete(note) + db.commit() + return True diff --git a/backend/db/crud/providers.py b/backend/db/crud/providers.py new file mode 100644 index 00000000..49388aac --- /dev/null +++ b/backend/db/crud/providers.py @@ -0,0 +1,166 @@ +"""Provider Config CRUD""" + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from db.models import ProviderConfig + +logger = logging.getLogger(__name__) + + +def _mask_secret(value: str, visible: int = 4) -> str: + """脱敏显示密钥""" + if not value: + return "" + if len(value) <= visible: + return "*" * len(value) + return "*" * 4 + value[-visible:] + + +def _serialize_provider(p: ProviderConfig) -> dict: + """序列化 ProviderConfig 为前端可用 dict(密钥脱敏)""" + d = { + "id": p.id, + "name": p.name, + "is_active": p.is_active, + "api_key_set": bool(p.api_key), + "api_key_hint": _mask_secret(p.api_key), + "base_url": p.base_url or "", + "model_name": p.model_name or "", + } + if p.category == "openai": + d["light_model_name"] = p.light_model_name or "" + d["supports_function_calling"] = p.supports_function_calling + elif p.category == "paddleocr": + d["use_doc_orientation"] = p.use_doc_orientation + d["use_doc_unwarping"] = p.use_doc_unwarping + d["use_chart_recognition"] = p.use_chart_recognition + return d + + +def get_user_providers(db: Session, user_id: int) -> dict: + """获取用户的所有 provider 配置,按类别分组返回""" + providers = db.query(ProviderConfig).filter( + ProviderConfig.user_id == user_id + ).order_by(ProviderConfig.created_at).all() + + result = { + "openai_providers": [], + "anthropic_providers": [], + "paddleocr_providers": [], + "active_openai_id": None, + "active_anthropic_id": None, + "active_paddleocr_id": None, + } + category_key = { + "openai": "openai_providers", + "anthropic": "anthropic_providers", + "paddleocr": "paddleocr_providers", + } + active_key = { + "openai": "active_openai_id", + "anthropic": "active_anthropic_id", + "paddleocr": "active_paddleocr_id", + } + + for p in providers: + key = category_key.get(p.category) + if key: + result[key].append(_serialize_provider(p)) + if p.is_active: + ak = active_key.get(p.category) + if ak: + result[ak] = p.id + + return result + + +def save_user_providers(db: Session, user_id: int, data: dict) -> None: + """保存用户的 provider 配置(全量覆盖) + + data 结构: + openai_providers: [{ id, name, api_key?, base_url, model_name, ... }] + anthropic_providers: [...] + paddleocr_providers: [...] + active_openai_id: str | None + active_anthropic_id: str | None + active_paddleocr_id: str | None + """ + active_ids = { + "openai": data.get("active_openai_id"), + "anthropic": data.get("active_anthropic_id"), + "paddleocr": data.get("active_paddleocr_id"), + } + + # 读取已有配置(用于保留未重新提交的 api_key) + existing = { + p.id: p for p in db.query(ProviderConfig).filter( + ProviderConfig.user_id == user_id + ).all() + } + + # 收集本次提交的所有 ID + submitted_ids = set() + items_to_save = [] + + category_map = { + "openai_providers": "openai", + "anthropic_providers": "anthropic", + "paddleocr_providers": "paddleocr", + } + + for list_key, category in category_map.items(): + for item in data.get(list_key, []): + pid = item.get("id") + submitted_ids.add(pid) + old = existing.get(pid) + + # API Key:前端提交了新值则更新,否则保留旧值 + new_api_key = item.get("api_key") or item.get("api_token") or "" + if not new_api_key and old: + new_api_key = old.api_key or "" + + base_url = item.get("base_url") or item.get("api_url") or "" + model_name = item.get("model_name") or item.get("model") or "" + + items_to_save.append({ + "id": pid, + "category": category, + "name": item.get("name", ""), + "is_active": pid == active_ids.get(category), + "api_key": new_api_key, + "base_url": base_url, + "model_name": model_name, + "light_model_name": item.get("light_model_name", ""), + "supports_function_calling": item.get("supports_function_calling", True), + "use_doc_orientation": item.get("use_doc_orientation", False), + "use_doc_unwarping": item.get("use_doc_unwarping", False), + "use_chart_recognition": item.get("use_chart_recognition", False), + }) + + # 删除已移除的 provider + for pid in set(existing.keys()) - submitted_ids: + db.delete(existing[pid]) + + # 更新或新增 + for item in items_to_save: + old = existing.get(item["id"]) + if old: + for k, v in item.items(): + setattr(old, k, v) + else: + p = ProviderConfig(user_id=user_id, **item) + db.add(p) + + db.commit() + + +def get_active_provider(db: Session, user_id: int, category: str) -> Optional[ProviderConfig]: + """获取用户指定类别的激活 provider(用于后端调用 LLM/OCR 时读取凭据)""" + return db.query(ProviderConfig).filter( + ProviderConfig.user_id == user_id, + ProviderConfig.category == category, + ProviderConfig.is_active == True, + ).first() diff --git a/backend/db/crud/questions.py b/backend/db/crud/questions.py new file mode 100644 index 00000000..69c954ae --- /dev/null +++ b/backend/db/crud/questions.py @@ -0,0 +1,477 @@ +"""题目 CRUD""" + +import hashlib +import json +import logging +import re +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple + +from sqlalchemy.orm import Session, joinedload, selectinload + +from db.models import UploadBatch, Question, KnowledgeTag, QuestionTagMapping +from db.crud.tags import _parse_tag_list, get_or_create_tag + +logger = logging.getLogger(__name__) + + +def _get_filters(): + """延迟导入共享过滤函数,避免循环导入""" + from db.crud import _filter_by_subject, _filter_by_user + return _filter_by_subject, _filter_by_user + + +def compute_content_hash(content_blocks: List[Dict]) -> str: + """ + 基于 content_blocks 计算去重哈希 + 使用题目文本内容计算 SHA256 + """ + text_parts = [] + for block in content_blocks: + if block.get("block_type") == "text": + text_parts.append(block.get("content", "")) + + text = " ".join(text_parts).strip() + if not text: + # 如果没有文本内容,使用整个 content_blocks 的 JSON 作为哈希源 + text = json.dumps(content_blocks, ensure_ascii=False) + + return hashlib.sha256(text.encode()).hexdigest() + + +def question_exists(db, content_hash, user_id=None): + """检查题目是否已存在(通过内容哈希 + 用户隔离)""" + q = db.query(Question).filter(Question.content_hash == content_hash) + if user_id is not None: + q = q.filter(Question.user_id == user_id) + return q.first() + + +def save_questions_to_db( + db: Session, + questions: List[Dict], + batch_info: Dict[str, Any], + user_id=None, +) -> Dict[str, int]: + """ + 批量入库题目 + + Args: + db: 数据库会话 + questions: 题目列表(字典格式,来自 questions.json) + batch_info: 批次信息,包含 original_filename, file_path 等 + + Returns: + dict: {"created": 新增数量, "duplicates": 重复数量} + """ + # 科目由编排智能体识别,不再使用关键词匹配 + subject = batch_info.get("subject") or "未知" + + # 创建批次记录 + batch = UploadBatch( + user_id=user_id, + original_filename=batch_info.get("original_filename", "未知"), + subject=subject, + file_path=batch_info.get("file_path", ""), + upload_time=batch_info.get("upload_time") or datetime.utcnow() + ) + db.add(batch) + db.flush() # 获取 batch.id + + created = 0 + duplicates = 0 + + for q in questions: + content_blocks = q.get("content_blocks", []) + if not content_blocks: + continue + + content_hash = compute_content_hash(content_blocks) + + # 检查是否已存在 + if question_exists(db, content_hash, user_id=user_id): + duplicates += 1 + continue + + # 创建题目记录 + question = Question( + user_id=user_id, + batch_id=batch.id, + content_hash=content_hash, + question_type=q.get("question_type"), + content_json=json.dumps(content_blocks, ensure_ascii=False), + options_json=json.dumps(q.get("options"), ensure_ascii=False) if q.get("options") else None, + has_formula=q.get("has_formula", False), + has_image=q.get("has_image", False), + image_refs_json=json.dumps(q.get("image_refs"), ensure_ascii=False) if q.get("image_refs") else None, + needs_correction=q.get("needs_correction", False), + ocr_issues_json=json.dumps(q.get("ocr_issues"), ensure_ascii=False) if q.get("ocr_issues") else None, + answer=q.get("answer") or None, + user_answer=q.get("user_answer") or None, + ) + db.add(question) + db.flush() + + # 处理知识点标签 + knowledge_tags = q.get("knowledge_tags") or [] + for tag_name in knowledge_tags: + tag = get_or_create_tag(db, tag_name, subject) + mapping = QuestionTagMapping( + question_id=question.id, + tag_id=tag.id + ) + db.add(mapping) + + created += 1 + + db.commit() + + return {"created": created, "duplicates": duplicates} + + +def get_questions_by_subject( + db: Session, + subject: str, + limit: int = 100, + offset: int = 0, + user_id=None, +) -> List[Question]: + """按科目查询题目""" + query = db.query(Question).join(UploadBatch).filter( + UploadBatch.subject == subject + ) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + return query.order_by(Question.created_at.desc()).offset(offset).limit(limit).all() + + +def get_questions_by_tag( + db: Session, + tag_name: str, + limit: int = 100, + offset: int = 0, + user_id=None, +) -> List[Question]: + """按标签查询题目""" + query = db.query(Question).join(QuestionTagMapping).join(KnowledgeTag).filter( + KnowledgeTag.tag_name == tag_name + ) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + return query.order_by(Question.created_at.desc()).offset(offset).limit(limit).all() + + +def get_history_questions( + db: Session, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + page: int = 1, + page_size: int = 20, + user_id=None, +) -> Tuple[List[Question], int]: + """ + 分页查询历史题目(全部题目) + + Args: + db: 数据库会话 + start_date: 开始日期筛选 + end_date: 结束日期筛选 + page: 页码(从1开始) + page_size: 每页数量 + + Returns: + (题目列表, 总数) + """ + query = db.query(Question).join(UploadBatch) + + if user_id is not None: + query = query.filter(Question.user_id == user_id) + + if start_date: + query = query.filter(UploadBatch.upload_time >= start_date) + if end_date: + from datetime import timedelta + query = query.filter(UploadBatch.upload_time < end_date + timedelta(days=1)) + + # 获取总数 + total = query.count() + + # 分页查询 + offset = (page - 1) * page_size + questions = ( + query.options(selectinload(Question.batch), selectinload(Question.tags).selectinload(QuestionTagMapping.tag)) + .order_by(Question.created_at.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + return questions, total + + +def search_questions( + db: Session, + keyword: Optional[str] = None, + knowledge_tag: Optional[str] = None, + question_type: Optional[str] = None, + page: int = 1, + page_size: int = 20, + user_id=None, +) -> Tuple[List[Question], int]: + """ + 搜索题目(知识点/题型/关键字) + + Args: + db: 数据库会话 + keyword: 关键字搜索(匹配题目内容 content_json) + knowledge_tag: 知识点标签筛选 + question_type: 题型筛选 + page: 页码(从1开始) + page_size: 每页数量 + + Returns: + (题目列表, 总数) + """ + query = db.query(Question) + + if user_id is not None: + query = query.filter(Question.user_id == user_id) + + # 关键字搜索:匹配 content_json 中的内容 + if keyword: + escaped = re.sub(r"([%_\\])", r"\\\1", keyword) + query = query.filter(Question.content_json.ilike(f"%{escaped}%")) + + # 题型筛选 + if question_type: + query = query.filter(Question.question_type == question_type) + + # 知识点标签筛选(支持逗号分隔多选,OR 语义) + if knowledge_tag: + tag_list = _parse_tag_list(knowledge_tag) + if tag_list: + query = query.join(QuestionTagMapping).join(KnowledgeTag).filter( + KnowledgeTag.tag_name.in_(tag_list) + ) + + # 获取总数(需要先去除distinct,因为join可能产生重复) + total = query.distinct().count() + + # 分页查询 + offset = (page - 1) * page_size + questions = ( + query.distinct() + .options(selectinload(Question.batch), selectinload(Question.tags).selectinload(QuestionTagMapping.tag)) + .order_by(Question.created_at.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + return questions, total + + +def query_questions( + db: Session, + subject: Optional[str] = None, + knowledge_tag: Optional[str] = None, + question_type: Optional[str] = None, + keyword: Optional[str] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + review_status: Optional[str] = None, + page: int = 1, + page_size: int = 20, + user_id=None, +) -> Tuple[List[Question], int]: + """ + 统一查询题目(合并 get_history_questions 和 search_questions 的能力) + + 支持所有筛选条件任意组合。 + """ + query = db.query(Question).join(UploadBatch) + + if user_id is not None: + query = query.filter(Question.user_id == user_id) + + # 未筛选的总收录数(仅按用户隔离) + grand_total = query.distinct().count() + + if subject: + query = query.filter(UploadBatch.subject == subject) + + if question_type: + query = query.filter(Question.question_type == question_type) + + if keyword: + escaped = re.sub(r"([%_\\])", r"\\\1", keyword) + query = query.filter(Question.content_json.ilike(f"%{escaped}%")) + + if knowledge_tag: + tag_list = _parse_tag_list(knowledge_tag) + if tag_list: + query = query.join(QuestionTagMapping).join(KnowledgeTag).filter( + KnowledgeTag.tag_name.in_(tag_list) + ) + + if start_date: + query = query.filter(Question.created_at >= start_date) + if end_date: + from datetime import timedelta + query = query.filter(Question.created_at < end_date + timedelta(days=1)) + + if review_status: + query = query.filter(Question.review_status == review_status) + + total = query.distinct().count() + + offset = (page - 1) * page_size + questions = ( + query.distinct() + .options(selectinload(Question.batch), selectinload(Question.tags).selectinload(QuestionTagMapping.tag)) + .order_by(Question.created_at.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + return questions, total, grand_total + + +def get_questions_by_ids(db: Session, question_ids: List[int], user_id=None) -> List[Question]: + """按 ID 列表批量查询题目""" + if not question_ids: + return [] + query = ( + db.query(Question) + .options(joinedload(Question.batch), joinedload(Question.tags).joinedload(QuestionTagMapping.tag)) + .filter(Question.id.in_(question_ids)) + ) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + return query.all() + + +def delete_question(db: Session, question_id: int, user_id=None) -> bool: + """ + 删除题目 + + Args: + db: 数据库会话 + question_id: 题目ID + user_id: 用户ID(非 None 时校验归属) + + Returns: + 是否删除成功 + """ + query = db.query(Question).filter(Question.id == question_id) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + question = query.first() + if not question: + return False + + try: + # 删除关联的标签映射 + db.query(QuestionTagMapping).filter(QuestionTagMapping.question_id == question_id).delete() + + # 删除题目 + db.delete(question) + db.commit() + except Exception as e: + db.rollback() + logger.error(f"删除题目 {question_id} 失败: {e}") + raise + + return True + + +def update_user_answer(db: Session, question_id: int, user_answer: str, user_id=None) -> Optional[Question]: + """更新用户答案""" + query = db.query(Question).filter(Question.id == question_id) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + question = query.first() + if not question: + return None + + try: + question.user_answer = user_answer + question.updated_at = datetime.utcnow() + db.commit() + db.refresh(question) + return question + except Exception as e: + db.rollback() + logger.error(f"更新题目 {question_id} 答案失败: {e}") + raise + + +def update_question_answer(db: Session, question_id: int, answer: str, user_id=None) -> Optional[Question]: + """保存/更新题目答案(Markdown 格式)""" + query = db.query(Question).filter(Question.id == question_id) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + question = query.first() + if not question: + return None + + try: + question.answer = answer + question.updated_at = datetime.utcnow() + db.commit() + db.refresh(question) + return question + except Exception as e: + db.rollback() + logger.error(f"保存题目 {question_id} 答案失败: {e}") + raise + + +VALID_REVIEW_STATUSES = ('待复习', '复习中', '已掌握') + + +def update_review_status(db: Session, question_id: int, review_status: str, user_id=None) -> Optional[Question]: + """更新题目复习状态""" + if review_status not in VALID_REVIEW_STATUSES: + raise ValueError(f"无效的复习状态: {review_status},可选值: {VALID_REVIEW_STATUSES}") + + query = db.query(Question).filter(Question.id == question_id) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + question = query.first() + if not question: + return None + + try: + question.review_status = review_status + question.updated_at = datetime.utcnow() + db.commit() + db.refresh(question) + return question + except Exception as e: + db.rollback() + logger.error(f"更新题目 {question_id} 复习状态失败: {e}") + raise + + +def get_existing_subjects(db, user_id=None): + """获取数据库中已有的所有科目名称(去重)""" + query = db.query(UploadBatch.subject).distinct().filter( + UploadBatch.subject.isnot(None), + UploadBatch.subject != "", + ) + if user_id is not None: + query = query.filter(UploadBatch.user_id == user_id) + return [r[0] for r in query.all()] + + +def get_existing_question_types(db: Session, user_id=None) -> List[str]: + """获取数据库中已有的所有题型(去重)""" + query = db.query(Question.question_type).distinct().filter( + Question.question_type.isnot(None), + Question.question_type != "", + ) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + return [r[0] for r in query.all()] diff --git a/backend/db/crud/split_records.py b/backend/db/crud/split_records.py new file mode 100644 index 00000000..d80e1b4d --- /dev/null +++ b/backend/db/crud/split_records.py @@ -0,0 +1,91 @@ +"""分割历史记录 CRUD""" + +import json +import logging +from typing import List, Dict, Optional + +from sqlalchemy.orm import Session +from sqlalchemy import func + +from db.models import SplitRecord + +logger = logging.getLogger(__name__) + + +MAX_SPLIT_RECORDS = 20 + + +def save_split_record( + db: Session, + subject: Optional[str], + model_provider: str, + file_names: List[str], + questions: List[Dict], + user_id=None, +) -> SplitRecord: + """保存一次分割操作的完整结果,超过上限自动清理最旧记录""" + record = SplitRecord( + user_id=user_id, + subject=subject, + model_provider=model_provider, + file_names_json=json.dumps(file_names, ensure_ascii=False), + questions_json=json.dumps(questions, ensure_ascii=False), + question_count=len(questions), + ) + db.add(record) + try: + db.commit() + db.refresh(record) + _cleanup_old_split_records(db, user_id=user_id) + return record + except Exception as e: + db.rollback() + logger.error(f"保存分割记录失败: {e}") + raise + + +def _cleanup_old_split_records(db: Session, user_id=None): + """删除超出上限的最旧分割记录(按用户隔离)""" + count_query = db.query(func.count(SplitRecord.id)) + if user_id is not None: + count_query = count_query.filter(SplitRecord.user_id == user_id) + count = count_query.scalar() + if count <= MAX_SPLIT_RECORDS: + return + overflow = count - MAX_SPLIT_RECORDS + ids_query = db.query(SplitRecord.id) + if user_id is not None: + ids_query = ids_query.filter(SplitRecord.user_id == user_id) + old_ids = [ + row[0] for row in + ids_query.order_by(SplitRecord.created_at.asc()) + .limit(overflow) + .all() + ] + try: + db.query(SplitRecord).filter(SplitRecord.id.in_(old_ids)).delete(synchronize_session=False) + db.commit() + logger.info(f"已清理 {overflow} 条过期分割记录") + except Exception as e: + db.rollback() + logger.error(f"清理分割记录失败: {e}") + + +def get_recent_split_records(db, limit: int = 10, user_id=None): + """获取最近 N 条分割记录(不加载 questions_json 大字段)""" + from sqlalchemy.orm import defer + query = ( + db.query(SplitRecord) + .options(defer(SplitRecord.questions_json)) + ) + if user_id is not None: + query = query.filter(SplitRecord.user_id == user_id) + return query.order_by(SplitRecord.created_at.desc()).limit(limit).all() + + +def get_split_record_by_id(db: Session, record_id: int, user_id=None) -> Optional[SplitRecord]: + """按 ID 获取单条分割记录(含完整 questions_json)""" + query = db.query(SplitRecord).filter(SplitRecord.id == record_id) + if user_id is not None: + query = query.filter(SplitRecord.user_id == user_id) + return query.first() diff --git a/backend/db/crud/stats.py b/backend/db/crud/stats.py new file mode 100644 index 00000000..375b7734 --- /dev/null +++ b/backend/db/crud/stats.py @@ -0,0 +1,272 @@ +"""统计信息 CRUD""" + +import logging +from datetime import datetime +from typing import List, Dict, Any, Optional + +from sqlalchemy.orm import Session +from sqlalchemy import func + +from db.models import UploadBatch, Question, KnowledgeTag, QuestionTagMapping +from db.crud.questions import VALID_REVIEW_STATUSES + +logger = logging.getLogger(__name__) + + +def _get_filters(): + """延迟导入共享过滤函数,避免循环导入""" + from db.crud import _filter_by_subject, _filter_by_user + return _filter_by_subject, _filter_by_user + + +def get_statistics(db: Session, subject: Optional[str] = None, user_id=None) -> Dict[str, Any]: + """获取统计信息""" + _filter_by_subject, _filter_by_user = _get_filters() + + q_query = _filter_by_subject(db.query(func.count(Question.id)), subject) + if user_id is not None: + q_query = q_query.filter(Question.user_id == user_id) + total_questions = q_query.scalar() + + batch_query = db.query(func.count(UploadBatch.id)) + if user_id is not None: + batch_query = batch_query.filter(UploadBatch.user_id == user_id) + total_batches = batch_query.scalar() + + total_tags = db.query(func.count(KnowledgeTag.id)).scalar() + + # 按科目统计 + subject_q = db.query(UploadBatch.subject, func.count(Question.id)).join(Question) + if user_id is not None: + subject_q = subject_q.filter(Question.user_id == user_id) + subject_stats = subject_q.group_by(UploadBatch.subject).all() + + return { + "total_questions": total_questions or 0, + "total_batches": total_batches or 0, + "total_tags": total_tags or 0, + "by_subject": {s: c for s, c in subject_stats} + } + + +def get_knowledge_stats(db: Session, subject: Optional[str] = None, limit: Optional[int] = None, user_id=None) -> List[Dict]: + """ + 获取知识点统计信息 + + Args: + subject: 可选,按学科筛选 + limit: 可选,返回前 N 条 + user_id: 可选,按用户隔离 + + Returns: + [{"tag_name": "xxx", "count": 10}, ...] + """ + query = db.query( + KnowledgeTag.tag_name, + func.count(QuestionTagMapping.question_id).label("count") + ).join( + QuestionTagMapping, QuestionTagMapping.tag_id == KnowledgeTag.id + ) + + if user_id is not None: + query = query.join(Question, Question.id == QuestionTagMapping.question_id).filter( + Question.user_id == user_id + ) + + if subject: + query = query.filter(KnowledgeTag.subject == subject) + + query = query.group_by( + KnowledgeTag.id, KnowledgeTag.tag_name + ).order_by( + func.count(QuestionTagMapping.question_id).desc() + ) + + if limit: + query = query.limit(limit) + + stats = query.all() + + return [{"tag_name": tag_name, "count": count} for tag_name, count in stats] + + +def get_review_status_stats(db: Session, subject: Optional[str] = None, user_id=None) -> Dict[str, int]: + """按复习状态分组统计数量""" + _filter_by_subject, _filter_by_user = _get_filters() + + query = _filter_by_subject(db.query( + Question.review_status, + func.count(Question.id) + ), subject) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + + rows = query.group_by(Question.review_status).all() + + result = {s: 0 for s in VALID_REVIEW_STATUSES} + for status, count in rows: + key = status or '待复习' + if key in result: + result[key] += count + else: + result['待复习'] += count + return result + + +def get_today_mastered_count(db: Session, subject: Optional[str] = None, user_id=None) -> int: + """获取今日新掌握的题目数(updated_at 在今天且状态为已掌握)""" + _filter_by_subject, _filter_by_user = _get_filters() + + today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + query = _filter_by_subject(db.query(func.count(Question.id)).filter( + Question.updated_at >= today_start, + Question.review_status == '已掌握', + ), subject) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + return query.scalar() or 0 + + +def get_daily_counts(db: Session, days: int = 7, subject: Optional[str] = None, user_id=None) -> List[Dict[str, Any]]: + """获取最近 N 天每日新增题目数 + 每日新增已掌握数""" + from datetime import timedelta + _filter_by_subject, _filter_by_user = _get_filters() + + cutoff = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days - 1) + + # SQLite 兼容:使用 func.date() 提取日期字符串 + date_expr = func.date(Question.created_at) + + # 新增题目数 + query = _filter_by_subject(db.query( + date_expr.label('date'), + func.count(Question.id).label('count') + ).filter(Question.created_at >= cutoff), subject) + if user_id is not None: + query = query.filter(Question.user_id == user_id) + rows = query.group_by(date_expr).order_by(date_expr).all() + date_map = {str(row.date): row.count for row in rows} + + # 每日新增已掌握数(按 updated_at 统计) + mastered_date_expr = func.date(Question.updated_at) + mq = _filter_by_subject(db.query( + mastered_date_expr.label('date'), + func.count(Question.id).label('count') + ).filter( + Question.updated_at >= cutoff, + Question.review_status == '已掌握', + ), subject) + if user_id is not None: + mq = mq.filter(Question.user_id == user_id) + mastered_rows = mq.group_by(mastered_date_expr).order_by(mastered_date_expr).all() + mastered_map = {str(row.date): row.count for row in mastered_rows} + + # 填充缺失的日期 + result = [] + for i in range(days): + d = cutoff + timedelta(days=i) + date_key = d.strftime('%Y-%m-%d') + date_str = d.strftime('%m-%d') + result.append({ + 'date': date_str, + 'count': date_map.get(date_key, 0), + 'mastered': mastered_map.get(date_key, 0), + }) + + return result + + +def get_tag_status_stats(db: Session, subject: Optional[str] = None, limit: int = 10, user_id=None) -> List[Dict]: + """ + 知识点 × 掌握状态统计(堆叠柱状图用) + + Returns: + [{"tag_name": "函数", "待复习": 3, "复习中": 2, "已掌握": 5}, ...] + """ + query = db.query( + KnowledgeTag.tag_name, + Question.review_status, + func.count(Question.id).label('count') + ).join( + QuestionTagMapping, QuestionTagMapping.tag_id == KnowledgeTag.id + ).join( + Question, Question.id == QuestionTagMapping.question_id + ) + + if user_id is not None: + query = query.filter(Question.user_id == user_id) + if subject: + query = query.filter(KnowledgeTag.subject == subject) + + rows = query.group_by( + KnowledgeTag.tag_name, Question.review_status + ).all() + + # 按 tag 聚合 + tag_data = {} + tag_totals = {} + for tag_name, status, count in rows: + if tag_name not in tag_data: + tag_data[tag_name] = {'tag_name': tag_name, **{s: 0 for s in VALID_REVIEW_STATUSES}} + tag_totals[tag_name] = 0 + key = status or '待复习' + if key in VALID_REVIEW_STATUSES: + tag_data[tag_name][key] += count + else: + tag_data[tag_name]['待复习'] += count + tag_totals[tag_name] += count + + # 按总数降序,取 top N + sorted_tags = sorted(tag_data.keys(), key=lambda t: tag_totals[t], reverse=True)[:limit] + return [tag_data[t] for t in sorted_tags] + + +def get_tag_type_stats(db: Session, subject: Optional[str] = None, tag_limit: int = 8, user_id=None) -> Dict[str, Any]: + """ + 知识点 × 题型交叉统计(热力图用) + + Returns: + {"tags": ["函数", ...], "types": ["选择题", ...], "data": [[3, 1, ...], ...]} + """ + query = db.query( + KnowledgeTag.tag_name, + Question.question_type, + func.count(Question.id).label('count') + ).join( + QuestionTagMapping, QuestionTagMapping.tag_id == KnowledgeTag.id + ).join( + Question, Question.id == QuestionTagMapping.question_id + ).filter( + Question.question_type.isnot(None), + Question.question_type != '', + ) + + if user_id is not None: + query = query.filter(Question.user_id == user_id) + if subject: + query = query.filter(KnowledgeTag.subject == subject) + + rows = query.group_by( + KnowledgeTag.tag_name, Question.question_type + ).all() + + # 收集所有 tag 和 type + tag_totals = {} + type_set = set() + cross = {} + for tag_name, q_type, count in rows: + tag_totals[tag_name] = tag_totals.get(tag_name, 0) + count + type_set.add(q_type) + cross[(tag_name, q_type)] = count + + # 按总数取 top N tag + sorted_tags = sorted(tag_totals.keys(), key=lambda t: tag_totals[t], reverse=True)[:tag_limit] + sorted_types = sorted(type_set) + + # 构建矩阵 + data = [] + for tag in sorted_tags: + row = [cross.get((tag, t), 0) for t in sorted_types] + data.append(row) + + return {"tags": sorted_tags, "types": sorted_types, "data": data} diff --git a/backend/db/crud/tags.py b/backend/db/crud/tags.py new file mode 100644 index 00000000..dc16753d --- /dev/null +++ b/backend/db/crud/tags.py @@ -0,0 +1,47 @@ +"""知识点标签 CRUD""" + +import logging +from typing import List, Optional + +from sqlalchemy.orm import Session + +from db.models import KnowledgeTag + +logger = logging.getLogger(__name__) + + +def _parse_tag_list(knowledge_tag: str) -> List[str]: + """将逗号分隔的标签字符串拆分为去空白的非空列表""" + return [t.strip() for t in knowledge_tag.split(',') if t.strip()] + + +def get_or_create_tag(db: Session, tag_name: str, subject: str) -> KnowledgeTag: + """获取或创建知识点标签""" + tag = db.query(KnowledgeTag).filter_by( + tag_name=tag_name, + subject=subject + ).first() + + if not tag: + tag = KnowledgeTag(tag_name=tag_name, subject=subject) + db.add(tag) + db.flush() + + return tag + + +def get_existing_tag_names(db: Session, subject: Optional[str] = None) -> List[str]: + """获取数据库中已有的知识点标签名称列表(字符串)""" + query = db.query(KnowledgeTag.tag_name).distinct() + if subject: + query = query.filter(KnowledgeTag.subject == subject) + rows = query.order_by(KnowledgeTag.tag_name).all() + return [r[0] for r in rows] + + +def get_all_tags(db: Session, subject: Optional[str] = None) -> List[KnowledgeTag]: + """获取所有标签(可按科目筛选)""" + query = db.query(KnowledgeTag) + if subject: + query = query.filter(KnowledgeTag.subject == subject) + return query.order_by(KnowledgeTag.tag_name).all() diff --git a/backend/db/crud/users.py b/backend/db/crud/users.py new file mode 100644 index 00000000..5763677b --- /dev/null +++ b/backend/db/crud/users.py @@ -0,0 +1,54 @@ +"""用户认证 CRUD""" + +import logging +from sqlalchemy import func + +from db.models import User + +logger = logging.getLogger(__name__) + + +def create_user(db, email, password_hash, username, is_admin=False): + """创建用户""" + user = User(email=email, password_hash=password_hash, username=username, is_admin=is_admin) + db.add(user) + try: + db.commit() + db.refresh(user) + return user + except Exception as e: + db.rollback() + raise + + +def get_user_by_email(db, email): + """按邮箱查询用户""" + return db.query(User).filter(User.email == email).first() + + +def get_user_by_id(db, user_id): + """按 ID 查询用户""" + return db.query(User).filter(User.id == user_id).first() + + +def get_user_by_login(db, identifier): + """按邮箱或用户名查询用户(登录用,大小写不敏感邮箱)""" + identifier = identifier.strip() + return db.query(User).filter( + (func.lower(User.email) == identifier.lower()) | (User.username == identifier) + ).first() + + +def update_user_password(db, email: str, new_password_hash: str) -> bool: + """按邮箱更新用户密码,成功返回 True""" + user = db.query(User).filter(User.email == email).first() + if not user: + return False + user.password_hash = new_password_hash + user.session_version = (user.session_version or 0) + 1 + try: + db.commit() + return True + except Exception: + db.rollback() + raise diff --git a/backend/db/migrate.py b/backend/db/migrate.py index f613ea8c..80377d5f 100644 --- a/backend/db/migrate.py +++ b/backend/db/migrate.py @@ -12,16 +12,32 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from werkzeug.security import generate_password_hash -from config import settings +from core.config import settings from db import engine, SessionLocal from db.models import Base from db import crud +def _add_column_if_missing(conn, table: str, column: str, col_type: str, default=None): + """安全地给已有表添加列,若已存在则跳过""" + from sqlalchemy import text, inspect + inspector = inspect(conn) + existing = [c["name"] for c in inspector.get_columns(table)] + if column not in existing: + default_clause = f" DEFAULT {default}" if default is not None else "" + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}{default_clause}")) + print(f"[migrate] 已添加列: {table}.{column}") + + def migrate(): """增量迁移:仅创建缺失的表,并确保 Admin 用户存在。每次启动自动调用,安全幂等。""" Base.metadata.create_all(bind=engine) # checkfirst=True by default,已存在的表不动 + # 增量列迁移(新增字段时在此追加) + with engine.connect() as conn: + _add_column_if_missing(conn, "users", "session_version", "INTEGER", 0) + conn.commit() + default_users = [ {"email": "admin@admin.com", "username": "Admin", "password": "123456", "is_admin": True}, {"email": "admin2@admin.com", "username": "Admin2", "password": "123456", "is_admin": True}, diff --git a/backend/db/models.py b/backend/db/models.py index d9ace4ad..be8d6638 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -19,12 +19,14 @@ class User(Base): email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) is_admin = Column(Boolean, default=False) + session_version = Column(Integer, default=0, nullable=False) created_at = Column(DateTime, default=datetime.utcnow) questions = relationship("Question", back_populates="user") upload_batches = relationship("UploadBatch", back_populates="user") split_records = relationship("SplitRecord", back_populates="user") provider_configs = relationship("ProviderConfig", back_populates="user", cascade="all, delete-orphan") + notes = relationship("Note", back_populates="user") class ProviderConfig(Base): @@ -100,14 +102,17 @@ class Question(Base): class ChatSession(Base): - """教学辅导对话会话""" + """对话会话(可绑定题目,也可独立对话)""" __tablename__ = "chat_sessions" id = Column(Integer, primary_key=True) - question_id = Column(Integer, ForeignKey("questions.id"), nullable=False, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + question_id = Column(Integer, ForeignKey("questions.id"), nullable=True, index=True) + title = Column(String(255), default="新对话") created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + user = relationship("User") question = relationship("Question", back_populates="chat_sessions") messages = relationship("ChatMessage", back_populates="session", order_by="ChatMessage.id") @@ -164,3 +169,45 @@ class QuestionTagMapping(Base): question = relationship("Question", back_populates="tags") tag = relationship("KnowledgeTag") + + +class Note(Base): + """笔记表""" + __tablename__ = "notes" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + title = Column(String(255), nullable=False) # 笔记标题 + subject = Column(String(50)) # 科目 + content_markdown = Column(Text, default="") # LLM 整理后的 Markdown 内容 + source_images_json = Column(Text) # 原始上传图片路径列表 JSON + ocr_text = Column(Text) # OCR 识别的原始文本(保留用于重新整理) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + user = relationship("User", back_populates="notes") + tags = relationship("NoteTagMapping", back_populates="note", cascade="all, delete-orphan") + + +class NoteTagMapping(Base): + """笔记-标签关联表(与错题共享 KnowledgeTag)""" + __tablename__ = "note_tag_mapping" + + note_id = Column(Integer, ForeignKey("notes.id"), primary_key=True) + tag_id = Column(Integer, ForeignKey("knowledge_tags.id"), primary_key=True) + + note = relationship("Note", back_populates="tags") + tag = relationship("KnowledgeTag") + + +class EmailVerification(Base): + """注册邮箱验证码(仅存哈希,不存明文)""" + __tablename__ = "email_verifications" + + id = Column(Integer, primary_key=True) + email = Column(String(255), unique=True, nullable=False, index=True) + code_hash = Column(String(64), nullable=False) + expires_at = Column(DateTime, nullable=False) + last_sent_at = Column(DateTime, nullable=True) + attempts = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/models/blocks.py b/backend/models/blocks.py new file mode 100644 index 00000000..1684afcb --- /dev/null +++ b/backend/models/blocks.py @@ -0,0 +1,135 @@ +""" +基础网络模块:CBAM 注意力、编码/解码块,供 Generator 和 Discriminator 组合使用。 +""" +import torch +import torch.nn as nn +import torch.nn.functional as F + + +class CBAM(nn.Module): + """Convolutional Block Attention Module:通道注意力 + 空间注意力。 + + Args: + in_channels: 输入特征图通道数 + reduction: 通道注意力 MLP 的压缩比,越大参数越少但表达力越弱 + """ + def __init__(self, in_channels: int, reduction: int = 16): + super().__init__() + self.avg_pool = nn.AdaptiveAvgPool2d(1) + self.max_pool = nn.AdaptiveMaxPool2d(1) + self.mlp = nn.Sequential( + nn.Linear(in_channels, in_channels // reduction, bias=False), + nn.ReLU(inplace=True), + nn.Linear(in_channels // reduction, in_channels, bias=False), + ) + self.sigmoid = nn.Sigmoid() + # 空间注意力:7×7 大感受野,无 BN(标准 CBAM) + self.spatial = nn.Sequential( + nn.Conv2d(2, 1, kernel_size=7, padding=3, bias=False), + nn.Sigmoid(), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + b, c, h, w = x.shape + + # 通道注意力 + avg_out = self.mlp(self.avg_pool(x).view(b, c)).view(b, c, 1, 1) + max_out = self.mlp(self.max_pool(x).view(b, c)).view(b, c, 1, 1) + x = x * self.sigmoid(avg_out + max_out) + + # 空间注意力 + avg_s = torch.mean(x, dim=1, keepdim=True) + max_s, _ = torch.max(x, dim=1, keepdim=True) + x = x * self.spatial(torch.cat([avg_s, max_s], dim=1)) + + return x + + +class DilatedConvBlock(nn.Module): + """空洞卷积块,用于 RefineNet 解码器,扩大感受野而不损失分辨率。 + + Args: + dilation: 空洞率,默认 2;padding 自动计算以保持尺寸不变 + """ + def __init__(self, in_channels: int, out_channels: int, dilation: int = 2): + super().__init__() + padding = dilation * (3 - 1) // 2 + self.block = nn.Sequential( + nn.Conv2d(in_channels, out_channels, 3, + padding=padding, dilation=dilation, bias=False), + nn.BatchNorm2d(out_channels), + nn.ReLU(inplace=True), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.block(x) + + +class DownSample(nn.Module): + """U-Net 编码器下采样块:步长为 2 的卷积,分辨率减半。""" + def __init__(self, in_channels: int, out_channels: int): + super().__init__() + self.conv = nn.Sequential( + nn.Conv2d(in_channels, out_channels, 4, stride=2, padding=1, bias=False), + nn.BatchNorm2d(out_channels), + nn.LeakyReLU(0.2, inplace=True), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.conv(x) + + +class ResBlock(nn.Module): + """残差块,参照 EraseNet Residual:conv1(stride) → ReLU → conv2 + skip → BN → ReLU。 + + stride=1 时要求 in_channels == out_channels(无投影); + stride=2 时自动添加 1×1 skip 投影以匹配通道和尺寸。 + """ + def __init__(self, in_channels: int, out_channels: int, stride: int = 1): + super().__init__() + self.conv1 = nn.Conv2d(in_channels, in_channels, 3, stride=stride, padding=1) + self.conv2 = nn.Conv2d(in_channels, out_channels, 3, padding=1) + self.bn = nn.BatchNorm2d(out_channels) + self.skip = (nn.Conv2d(in_channels, out_channels, 1, stride=stride) + if (stride != 1 or in_channels != out_channels) else None) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + out = F.relu(self.conv1(x)) + out = self.conv2(out) + identity = self.skip(x) if self.skip is not None else x + return F.relu(self.bn(out + identity)) + + +class LateralConnection(nn.Module): + """跳跃连接特征精炼:1×1 → 3×3 → 3×3 → 1×1,参照 EraseNet lateral_connection。 + + 输入输出通道数相同,中间扩张为 2×channels。 + """ + def __init__(self, channels: int): + super().__init__() + inner = channels * 2 + self.net = nn.Sequential( + nn.Conv2d(channels, channels, 1), + nn.Conv2d(channels, inner, 3, padding=1), + nn.Conv2d(inner, inner, 3, padding=1), + nn.Conv2d(inner, channels, 1), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.net(x) + + +class UpSample(nn.Module): + """U-Net 解码器上采样块:转置卷积,分辨率翻倍,可选 CBAM 注意力。""" + def __init__(self, in_channels: int, out_channels: int, + use_cbam: bool = False, reduction: int = 16): + super().__init__() + self.conv = nn.Sequential( + nn.ConvTranspose2d(in_channels, out_channels, 4, stride=2, padding=1, bias=False), + nn.BatchNorm2d(out_channels), + nn.ReLU(inplace=True), + ) + self.cbam = CBAM(out_channels, reduction) if use_cbam else nn.Identity() + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.cbam(self.conv(x)) diff --git a/backend/models/config.py b/backend/models/config.py deleted file mode 100644 index 07cd972d..00000000 --- a/backend/models/config.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -import yaml -from pathlib import Path - - -class Config: - _instance = None - _config = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self): - if self._config is None: - self._load_config() - - def _load_config(self, config_path=None): - if config_path is None: - config_path = os.path.join( - os.path.dirname(__file__), - 'config.yaml' - ) - - if not os.path.exists(config_path): - raise FileNotFoundError(f"配置文件不存在: {config_path}") - - with open(config_path, 'r', encoding='utf-8') as f: - self._config = yaml.safe_load(f) - - @property - def train(self): - return self._config.get('train', {}) - - @property - def loss(self): - return self._config.get('loss', {}) - - @property - def inference(self): - return self._config.get('inference', {}) - - @property - def wandb(self): - return self._config.get('wandb', {}) - - def get(self, key, default=None): - keys = key.split('.') - value = self._config - for k in keys: - if isinstance(value, dict): - value = value.get(k, default) - else: - return default - return value - - -config = Config() diff --git a/backend/models/config.yaml b/backend/models/config.yaml deleted file mode 100644 index 164f9193..00000000 --- a/backend/models/config.yaml +++ /dev/null @@ -1,51 +0,0 @@ -# EnsExam 配置文件 - -# ====================== 训练参数 ====================== -train: - epochs: 100 # 训练轮数 - batch_size: 4 # 批次大小 - lr: 0.0001 # 学习率 - device: "cuda" # 训练设备 ("cuda" 或 "cpu") - num_workers: 4 # 数据加载线程数 - - # 路径配置 - data_root: "D:\\PythonProject1\\LLM\\adcj\\src\\adcj\\EnsExam\\SCUT-EnsExam\\SCUT-EnsExam" - save_dir: "./ensexam_checkpoints" - resume_path: "./ensexam_checkpoints/latest.pth" - -# ====================== 损失权重 ====================== -loss: - # LR Loss 多尺度权重 [Ic4, Ic2, Ic1, Ire] - lambda_n: [5, 6, 8, 10] - beta_n: [0.8, 0.8, 0.8, 2] - - # 各损失总权重 - lambda_lr: 1.0 # LR损失权重 - lambda_p: 0.05 # 感知损失权重 - lambda_style: 120 # 风格损失权重 - lambda_sn: 1 # SN损失权重 - lambda_b: 0.4 # 块损失权重 - -# ====================== 推理参数 ====================== -inference: - model_path: "D:\\PythonProject1\\LLM\\adcj\\src\\adcj\\EnsExam\\ensexam_checkpoints\\ensexam_epoch_5.pth" - img_size: 512 # 必须和训练时一致 - - # 单张图片推理 - input_image_path: "D:\\PythonProject1\\LLM\\adcj\\src\\adcj\\EnsExam\\SCUT-EnsExam\\SCUT-EnsExam\\test\\all_images\\15.jpg" - output_image_path: "./test_output.jpg" - - # 批量目录推理 - input_dir: "D:\\PythonProject1\\LLM\\adcj\\src\\adcj\\EnsExam\\SCUT-EnsExam\\SCUT-EnsExam\\test\\all_images" - output_dir: "./output" - patch_size: 512 # 滑动窗口尺寸 - overlap: 32 # 重叠像素 - -# ====================== WandB 配置 ====================== -wandb: - enabled: true # 是否启用 wandb - project: "ensexam" # 项目名称 - name: null # 实验名称 (null 则自动生成) - entity: null # 团队/用户名 - offline: true # 离线模式 (true 使用 offline wandb) - dir: "./wandb" # wandb 日志目录 diff --git a/backend/models/inference.py b/backend/models/inference.py index e06c848e..3ad48d14 100644 --- a/backend/models/inference.py +++ b/backend/models/inference.py @@ -12,8 +12,14 @@ import threading import numpy as np +import torch from PIL import Image +from models.model import Generator + +# cuDNN v8 frontend 与 A30+PyTorch2.1 不兼容 +torch.backends.cudnn.enabled = False + logger = logging.getLogger(__name__) _PATCH_SIZE = 512 @@ -50,23 +56,18 @@ def _load_model(self): if self._model is not None: return - import torch - torch.backends.cudnn.deterministic = True - torch.backends.cudnn.benchmark = False - from models.model import Generator - from config import settings + from core.config import settings model_path = settings.model_path if not model_path.exists(): raise FileNotFoundError( - f"模型权重不存在,请将 latest.pth 放到: {model_path}" + f"模型权重不存在,请将 best.pth 放到: {model_path}" ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") generator = Generator().to(device) ckpt = torch.load(model_path, map_location=device, weights_only=False) - # train.py 保存格式:{'G_state_dict': ..., 'D_state_dict': ..., ...} state_dict = ( ckpt.get("G_state_dict") or ckpt.get("generator_state_dict") @@ -85,7 +86,7 @@ def _load_model(self): @staticmethod def _preprocess(pil_img: Image.Image): - """PIL → float Tensor [-1, 1],并 padding 到 512 的整数倍 + """PIL → float ndarray [-1, 1],并 padding 到 512 的整数倍 Returns: arr_padded (np.ndarray): shape (H_pad, W_pad, 3),float32 in [-1,1] @@ -105,14 +106,12 @@ def _preprocess(pil_img: Image.Image): arr = np.array(img, dtype=np.float32) / 127.5 - 1.0 return arr, orig_w, orig_h - @staticmethod - def _to_tensor(arr: np.ndarray, device): + def _to_tensor(self, arr: np.ndarray) -> torch.Tensor: """(H, W, 3) ndarray → (1, 3, H, W) Tensor on device""" - import torch - return torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to(device) + return torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to(self._device) @staticmethod - def _to_numpy(tensor) -> np.ndarray: + def _to_numpy(tensor: torch.Tensor) -> np.ndarray: """(1, 3, H, W) Tensor → (H, W, 3) uint8 ndarray""" arr = tensor.squeeze(0).permute(1, 2, 0).cpu().numpy() return np.clip((arr + 1.0) * 127.5, 0, 255).astype(np.uint8) @@ -123,8 +122,6 @@ def _to_numpy(tensor) -> np.ndarray: def _infer_sliding_window(self, arr: np.ndarray) -> np.ndarray: """滑动窗口推理,patch 重叠区域做加权平均以消除接缝""" - import torch - h, w, _ = arr.shape result = np.zeros((h, w, 3), dtype=np.float32) weight = np.zeros((h, w, 1), dtype=np.float32) @@ -141,9 +138,8 @@ def _ticks(total): for y in _ticks(h): for x in _ticks(w): patch = arr[y:y + _PATCH_SIZE, x:x + _PATCH_SIZE] - tensor = self._to_tensor(patch, self._device) + tensor = self._to_tensor(patch) *_, Icomp = self._model(tensor) - # 直接从 tensor 提取 float32,避免 uint8 截断精度损失 out = (Icomp.squeeze(0).permute(1, 2, 0).cpu().numpy() + 1.0) * 127.5 result[y:y + _PATCH_SIZE, x:x + _PATCH_SIZE] += out weight[y:y + _PATCH_SIZE, x:x + _PATCH_SIZE] += 1.0 @@ -167,7 +163,6 @@ def run(self, image_bytes: bytes) -> Image.Image: pil_img = Image.open(io.BytesIO(image_bytes)) arr, orig_w, orig_h = self._preprocess(pil_img) - h, w, _ = arr.shape result_arr = self._infer_sliding_window(arr) diff --git a/backend/models/model.py b/backend/models/model.py index 436f19ad..cc931c52 100644 --- a/backend/models/model.py +++ b/backend/models/model.py @@ -1,473 +1,213 @@ -import math - -import cv2 -import numpy as np +""" +生成器网络:CoarseNet(粗擦除 + 掩码预测) + RefineNet(精细修复)。 + +结构对照 EraseNet (STRnet2),核心改动: + - 编码器:EraseNet 残差块风格(conv1/conva/convb + ResBlock×8) + - 跳跃连接:LateralConnection 特征精炼(替代简单 cat) + - 掩码分支:从 x_mask(瓶颈前 H/16 特征)出发,与 EraseNet 对齐 + - 保留:CBAM 注意力、双 sigmoid 掩码(Ms/Mb)、RefineNet 输入含 Ms + - RefineNet:EraseNet 风格多尺度空洞卷积,输入保持 cat([Iin, Ms, Ic1]) +""" import torch +import torch.nn as nn import torch.nn.functional as F -from torch import nn -from torchvision.models import vgg16 - - -class CBAM(nn.Module): - def __init__(self, in_channels, reduction=16): - super().__init__() - # 通道注意力(标准实现) - self.avg_pool = nn.AdaptiveAvgPool2d(1) - self.max_pool = nn.AdaptiveMaxPool2d(1) - self.mlp = nn.Sequential( - nn.Linear(in_channels, in_channels // reduction, bias=False), - nn.ReLU(inplace=True), - nn.Linear(in_channels // reduction, in_channels, bias=False) - ) - self.sigmoid = nn.Sigmoid() - - # 空间注意力(关键修正:7×7卷积 + 无BatchNorm) - self.spatial = nn.Sequential( - nn.Conv2d(2, 1, kernel_size=7, padding=3, bias=False), # ✅ 7×7 - nn.Sigmoid() # ✅ 无BatchNorm - ) - - def forward(self, x): - b, c, h, w = x.shape - - # ========== 通道注意力 ========== - avg_out = self.mlp(self.avg_pool(x).view(b, c)).view(b, c, 1, 1) - max_out = self.mlp(self.max_pool(x).view(b, c)).view(b, c, 1, 1) - channel_att = self.sigmoid(avg_out + max_out) - x = x * channel_att # 广播相乘 [B,C,H,W] * [B,C,1,1] - # ========== 空间注意力 ========== - avg_out = torch.mean(x, dim=1, keepdim=True) # [B,1,H,W] - max_out, _ = torch.max(x, dim=1, keepdim=True) # [B,1,H,W] - spatial_att = self.spatial(torch.cat([avg_out, max_out], dim=1)) # [B,1,H,W] - x = x * spatial_att # 广播相乘 [B,C,H,W] * [B,1,H,W] +from models.blocks import (DownSample, UpSample, DilatedConvBlock, + ResBlock, LateralConnection) - return x - - -# 空洞卷积块(Refine网络用) -class DilatedConvBlock(nn.Module): - def __init__(self, in_channels, out_channels, dilation=2): - super().__init__() - padding = dilation * (3 - 1) // 2 - self.block = nn.Sequential( - nn.Conv2d(in_channels, out_channels, 3, padding=padding, dilation=dilation, bias=False), - nn.BatchNorm2d(out_channels), - nn.ReLU(inplace=True) - ) - - def forward(self, x): - return self.block(x) - - -# U-Net下采样块(Coarse/Refine网络编码器) -class DownSample(nn.Module): - def __init__(self, in_channels, out_channels): - super().__init__() - self.conv = nn.Sequential( - nn.Conv2d(in_channels, out_channels, 4, 2, 1, bias=False), - nn.BatchNorm2d(out_channels), - nn.LeakyReLU(0.2, inplace=True) - ) - - def forward(self, x): - x = self.conv(x) - return x - - -# U-Net上采样块(Coarse网络解码器) -class UpSample(nn.Module): - def __init__(self, in_channels, out_channels, use_cbam=False): - super().__init__() - self.conv = nn.Sequential( - nn.ConvTranspose2d(in_channels, out_channels, 4, 2, 1, bias=False), - nn.BatchNorm2d(out_channels), - nn.ReLU(inplace=True) - ) - self.cbam = CBAM(out_channels) if use_cbam else nn.modules.Identity() - def forward(self, x): - x = self.conv(x) - x = self.cbam(x) - return x - - -# 带空洞卷积的上采样块(Refine网络解码器) -class DilatedUpSample(nn.Module): - def __init__(self, in_channels, out_channels, dilation=2): - super().__init__() - self.conv = nn.Sequential( - nn.ConvTranspose2d(in_channels, out_channels, 4, 2, 1, bias=False), - nn.BatchNorm2d(out_channels), - nn.ReLU(inplace=True) - ) - self.dilated = DilatedConvBlock(out_channels, out_channels, dilation) - - def forward(self, x): - x = self.conv(x) - x = self.dilated(x) - return x - - -# Coarse网络 class CoarseNet(nn.Module): - def __init__(self, in_channels=3): - super().__init__() - # 编码器:下采样+CBAM - self.down1 = DownSample(in_channels, 64) # 1/2 - self.down2 = DownSample(64, 128) # 1/4 - self.down3 = DownSample(128, 256) # 1/8 - self.down4 = DownSample(256, 512) # 1/16 - self.down5 = DownSample(512, 512) # 1/32 - # 解码器:上采样 - self.up1 = UpSample(512, 512, use_cbam=True) # 1/16 - self.up2 = UpSample(1024, 256, use_cbam=True) # 1/8 - self.up3 = UpSample(512, 128, use_cbam=True) # 1/4 → Ic4 - self.up4 = UpSample(256, 64, use_cbam=True) # 1/2 → Ic2 - self.up5 = UpSample(128, 64, use_cbam=True) # 1/1 → Ic1 - # 输出层:Ms(笔画掩码), Mb(文本块掩码), 多尺度Ic - self.up1_seg = UpSample(512, 512, use_cbam=True) - self.up2_seg = UpSample(1024, 256, use_cbam=True) - self.up3_seg = UpSample(512, 128, use_cbam=True) - self.up4_seg = UpSample(256, 64, use_cbam=True) - self.up5_seg = UpSample(128, 64, use_cbam=True) - - self.out_ms = nn.Conv2d(64, 1, 3, 1, 1, bias=True) - self.out_mb = nn.Conv2d(64, 1, 3, 1, 1, bias=True) - self.out_ic4 = nn.Conv2d(128, 3, 3, 1, 1, bias=False) - self.out_ic2 = nn.Conv2d(64, 3, 3, 1, 1, bias=False) - self.out_ic1 = nn.Conv2d(64, 3, 3, 1, 1, bias=False) - - def forward(self, x): - # 编码器(下采样) - d1 = self.down1(x) # H/2 - d2 = self.down2(d1) # H/4 - d3 = self.down3(d2) # H/8 - d4 = self.down4(d3) # H/16 - d5 = self.down5(d4) # H/32 - - # 解码器(上采样 + 跳跃连接) - u1 = self.up1(d5) - u1 = torch.cat([u1, d4], dim=1) # 拼接H/16特征 + """粗擦除网络:EraseNet 残差编码器 + 双解码器(修复 + 掩码)。 - u2 = self.up2(u1) - u2 = torch.cat([u2, d3], dim=1) # 拼接H/8特征 - - u3 = self.up3(u2) # H/4 → 输出 Ic4 - Ic4 = torch.tanh(self.out_ic4(u3)) - u3 = torch.cat([u3, d2], dim=1) # 拼接H/4特征 - - u4 = self.up4(u3) # H/2 → 输出 Ic2 - Ic2 = torch.tanh(self.out_ic2(u4)) - u4 = torch.cat([u4, d1], dim=1) # 拼接H/2特征 - - u5 = self.up5(u4) # H → 输出 Ic1, Mb, Ms - Ic1 = torch.tanh(self.out_ic1(u5)) - - u1_seg = self.up1_seg(d5) - u1_seg = torch.cat([u1_seg, d4], dim=1) - u2_seg = self.up2_seg(u1_seg) - u2_seg = torch.cat([u2_seg, d3], dim=1) - u3_seg = self.up3_seg(u2_seg) - u3_seg = torch.cat([u3_seg, d2], dim=1) - u4_seg = self.up4_seg(u3_seg) - u4_seg = torch.cat([u4_seg, d1], dim=1) - u5_seg = self.up5_seg(u4_seg) - Mb = torch.sigmoid(self.out_mb(u5_seg)) - Ms = torch.sigmoid(self.out_ms(u5_seg)) + Args: + in_channels: 输入通道数,RGB 固定为 3 + cbam_reduction: CBAM 通道压缩比 + """ + def __init__(self, in_channels: int = 3, cbam_reduction: int = 16): + super().__init__() + r = cbam_reduction + + # ── 编码器(EraseNet 风格) ────────────────────────────────────── + self.conv1 = nn.Sequential( + nn.Conv2d(in_channels, 32, 4, stride=2, padding=1, bias=False), + nn.BatchNorm2d(32), nn.LeakyReLU(0.2, inplace=True)) # H/2, 32 + self.conva = nn.Sequential( + nn.Conv2d(32, 32, 3, padding=1, bias=False), + nn.BatchNorm2d(32), nn.LeakyReLU(0.2, inplace=True)) # H/2, 32 + self.convb = nn.Sequential( + nn.Conv2d(32, 64, 4, stride=2, padding=1, bias=False), + nn.BatchNorm2d(64), nn.LeakyReLU(0.2, inplace=True)) # H/4, 64 + self.res1 = ResBlock(64, 64) + self.res2 = ResBlock(64, 64) + self.res3 = ResBlock(64, 128, stride=2) # H/8, 128 + self.res4 = ResBlock(128, 128) + self.res5 = ResBlock(128, 256, stride=2) # H/16, 256 + self.res6 = ResBlock(256, 256) + self.res7 = ResBlock(256, 512, stride=2) # H/32, 512 + self.res8 = ResBlock(512, 512) + self.conv2 = nn.Conv2d(512, 512, 1) # H/32, 512(瓶颈) + + # ── 修复解码器(LateralConnection + CBAM UpSample) ───────────── + self.lat1 = LateralConnection(256) + self.lat2 = LateralConnection(128) + self.lat3 = LateralConnection(64) + self.lat4 = LateralConnection(32) + self.up1 = UpSample(512, 256, use_cbam=True, reduction=r) + self.up2 = UpSample(512, 128, use_cbam=True, reduction=r) + self.up3 = UpSample(256, 64, use_cbam=True, reduction=r) + self.up4 = UpSample(128, 32, use_cbam=True, reduction=r) + self.up5 = UpSample(64, 32, use_cbam=True, reduction=r) + self.out_ic4 = nn.Conv2d(64, 3, 1) + self.out_ic2 = nn.Conv2d(32, 3, 1) + self.out_ic1 = nn.Conv2d(32, 3, 3, padding=1) + + # ── 掩码解码器(从瓶颈 d5 出发,保留全局上下文,双 sigmoid 头) ── + self.mask_up_0 = UpSample(512, 256, use_cbam=True, reduction=r) # H/32→H/16 + self.mask_up_a = UpSample(512, 256, use_cbam=True, reduction=r) + self.mask_conv_a = nn.Sequential( + nn.Conv2d(256, 128, 3, padding=1, bias=False), + nn.BatchNorm2d(128), nn.ReLU(inplace=True)) + self.mask_up_b = UpSample(256, 128, use_cbam=True, reduction=r) + self.mask_conv_b = nn.Sequential( + nn.Conv2d(128, 64, 3, padding=1, bias=False), + nn.BatchNorm2d(64), nn.ReLU(inplace=True)) + self.mask_up_c = UpSample(128, 64, use_cbam=True, reduction=r) + self.mask_conv_c = nn.Sequential( + nn.Conv2d(64, 32, 3, padding=1, bias=False), + nn.BatchNorm2d(32), nn.ReLU(inplace=True)) + self.mask_up_d = UpSample(64, 32, use_cbam=True, reduction=r) + self.out_ms = nn.Conv2d(32, 1, 1) # 软笔画掩码 + self.out_mb = nn.Conv2d(32, 1, 1) # 文本块掩码 + + def forward(self, x: torch.Tensor): + # 编码 + x = self.conv1(x) + x = self.conva(x) + con_x1 = x # H/2, 32 + x = self.convb(x) + x = self.res1(x) + con_x2 = x # H/4, 64 + x = self.res2(x) + x = self.res3(x) + con_x3 = x # H/8, 128 + x = self.res4(x) + x = self.res5(x) + con_x4 = x # H/16, 256 + x = self.res6(x) + x = self.res7(x) + x = self.res8(x) + d5 = self.conv2(x) # H/32, 512(瓶颈) + + # 修复解码 + u1 = torch.cat([self.lat1(con_x4), self.up1(d5)], dim=1) # H/16, 512 + u2 = torch.cat([self.lat2(con_x3), self.up2(u1)], dim=1) # H/8, 256 + xo1 = self.up3(u2) # H/4, 64 + Ic4 = torch.tanh(self.out_ic4(xo1)) + u3 = torch.cat([self.lat3(con_x2), xo1], dim=1) # H/4, 128 + xo2 = self.up4(u3) # H/2, 32 + Ic2 = torch.tanh(self.out_ic2(xo2)) + u4 = torch.cat([self.lat4(con_x1), xo2], dim=1) # H/2, 64 + Ic1 = torch.tanh(self.out_ic1(self.up5(u4))) # H, 3 + + # 掩码解码(从瓶颈 d5 出发,保留全局上下文) + mm = self.mask_up_a(torch.cat([self.mask_up_0(d5), con_x4], dim=1)) # H/8, 256 + mm = self.mask_conv_a(mm) # H/8, 128 + mm = self.mask_up_b(torch.cat([mm, con_x3], dim=1)) # H/4, 128 + mm = self.mask_conv_b(mm) # H/4, 64 + mm = self.mask_up_c(torch.cat([mm, con_x2], dim=1)) # H/2, 64 + mm = self.mask_conv_c(mm) # H/2, 32 + mm = self.mask_up_d(torch.cat([mm, con_x1], dim=1)) # H, 32 + Ms = torch.sigmoid(self.out_ms(mm)) + Mb = torch.sigmoid(self.out_mb(mm)) return Ms, Mb, Ic4, Ic2, Ic1 -# Refine网络 class RefineNet(nn.Module): - def __init__(self, in_channels=7): # 3(Iin)+1(Ms)+3(Ic1) =7 - super().__init__() - # 编码器:下采样 - self.down1 = DownSample(in_channels, 64) # 1/2 - self.down2 = DownSample(64, 128) # 1/4 - self.down3 = DownSample(128, 256) # 1/8 - self.down4 = DownSample(256, 512) # 1/16 - # 解码器:带空洞卷积的上采样 - self.up1 = DilatedUpSample(512, 256) # 1/8 - self.up2 = DilatedUpSample(512, 128) # 1/4 - self.up3 = DilatedUpSample(256, 64) # 1/2 - self.up4 = DilatedUpSample(128, 64) # 1/1 - # 输出层:精细化擦除结果Ire - self.out_ire = nn.Conv2d(64, 3, 3, 1, 1) + """精细修复网络:EraseNet 风格多尺度空洞卷积,感受野更大。 - def forward(self, x): - # 编码器 - d1 = self.down1(x) - d2 = self.down2(d1) - d3 = self.down3(d2) - d4 = self.down4(d3) - # 解码器 - u1 = self.up1(d4) - u1 = torch.cat([u1, d3], dim=1) - - u2 = self.up2(u1) - u2 = torch.cat([u2, d2], dim=1) - - u3 = self.up3(u2) - u3 = torch.cat([u3, d1], dim=1) - - u4 = self.up4(u3) - # 输出 - Ire = torch.tanh(self.out_ire(u4)) - return Ire + Args: + in_channels: 输入通道数 = Iin(3) + Ms(1) + Ic1(3) = 7 + """ + def __init__(self, in_channels: int = 7): + super().__init__() + cnum = 32 + + # 编码 + self.conva = nn.Sequential( + nn.Conv2d(in_channels, cnum, 5, padding=2, bias=False), + nn.BatchNorm2d(cnum), nn.ReLU(inplace=True)) + self.down1 = DownSample(cnum, cnum * 2) # H/2, 64 + self.convc = nn.Sequential( + nn.Conv2d(cnum * 2, cnum * 2, 3, padding=1, bias=False), + nn.BatchNorm2d(cnum * 2), nn.ReLU(inplace=True)) + self.down2 = DownSample(cnum * 2, cnum * 4) # H/4, 128 + self.conve = nn.Sequential( + nn.Conv2d(cnum * 4, cnum * 4, 3, padding=1, bias=False), + nn.BatchNorm2d(cnum * 4), nn.ReLU(inplace=True)) + self.convf = nn.Sequential( + nn.Conv2d(cnum * 4, cnum * 4, 3, padding=1, bias=False), + nn.BatchNorm2d(cnum * 4), nn.ReLU(inplace=True)) + + # 多尺度空洞卷积(参照 EraseNet astrous_net) + self.astrous = nn.Sequential( + DilatedConvBlock(cnum * 4, cnum * 4, dilation=2), + DilatedConvBlock(cnum * 4, cnum * 4, dilation=4), + DilatedConvBlock(cnum * 4, cnum * 4, dilation=8), + DilatedConvBlock(cnum * 4, cnum * 4, dilation=16), + ) + self.convk = nn.Sequential( + nn.Conv2d(cnum * 4, cnum * 4, 3, padding=1, bias=False), + nn.BatchNorm2d(cnum * 4), nn.ReLU(inplace=True)) + self.convl = nn.Sequential( + nn.Conv2d(cnum * 4, cnum * 4, 3, padding=1, bias=False), + nn.BatchNorm2d(cnum * 4), nn.ReLU(inplace=True)) + + # 解码(cat 自身跳跃连接) + self.up1 = UpSample(cnum * 8, cnum * 2) # cat([x,x_c2]) H/4→H/2 + self.convm = nn.Sequential( + nn.Conv2d(cnum * 2, cnum * 2, 3, padding=1, bias=False), + nn.BatchNorm2d(cnum * 2), nn.ReLU(inplace=True)) + self.up2 = UpSample(cnum * 4, cnum) # cat([x,x_c1]) H/2→H + self.convn = nn.Sequential( + nn.Conv2d(cnum, cnum // 2, 3, padding=1, bias=False), + nn.BatchNorm2d(cnum // 2), nn.ReLU(inplace=True), + nn.Conv2d(cnum // 2, 3, 3, padding=1)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.conva(x) + x = self.down1(x) + x = self.convc(x) + x_c1 = x # H/2, 64 + x = self.down2(x) + x = self.conve(x) + x = self.convf(x) + x_c2 = x # H/4, 128 + x = self.astrous(x) + x = self.convk(x) + x = self.convl(x) + x = self.up1(torch.cat([x, x_c2], dim=1)) # H/2, 64 + x = self.convm(x) + x = self.up2(torch.cat([x, x_c1], dim=1)) # H, 32 + return torch.tanh(self.convn(x)) -# 生成器整体 class Generator(nn.Module): - def __init__(self): + """完整生成器:CoarseNet → RefineNet → 融合输出。 + + Args: + cfg: model 子配置字典,含 coarse_in_channels / refine_in_channels / cbam_reduction + """ + def __init__(self, cfg: dict = None): super().__init__() - self.coarse = CoarseNet() - self.refine = RefineNet() + if cfg is None: + cfg = {'coarse_in_channels': 3, 'refine_in_channels': 7, 'cbam_reduction': 16} + self.coarse = CoarseNet(in_channels=cfg['coarse_in_channels'], + cbam_reduction=cfg['cbam_reduction']) + self.refine = RefineNet(in_channels=cfg['refine_in_channels']) - def forward(self, Iin): - # Coarse网络输出 + def forward(self, Iin: torch.Tensor): Ms, Mb, Ic4, Ic2, Ic1 = self.coarse(Iin) - # 拼接输入:Iin + Ms + Ic1(通道维度) - refine_in = torch.cat([Iin, Ms, Ic1], dim=1) - # Refine网络输出 - Ire = self.refine(refine_in) - # 融合得到最终结果Icomp + Ire = self.refine(torch.cat([Iin, Ms, Ic1], dim=1)) Icomp = Ire * Mb + Iin * (1 - Mb) return Ms, Mb, Ic4, Ic2, Ic1, Ire, Icomp - - -# 基础判别器块 -class DiscBlock(nn.Module): - def __init__(self, in_channels, out_channels, stride=2): - super().__init__() - self.block = nn.Sequential( - nn.Conv2d(in_channels, out_channels, 4, stride, 1, bias=False), - nn.BatchNorm2d(out_channels), - nn.LeakyReLU(0.2, inplace=True) - ) - - def forward(self, x): - return self.block(x) - - -# Local-Global判别器 -class Discriminator(nn.Module): - def __init__(self, in_channels=3): - super().__init__() - # 全局判别器:输出标量(适配任意尺寸) - self.global_disc = nn.Sequential( - DiscBlock(in_channels, 64), - DiscBlock(64, 128), - DiscBlock(128, 256), - DiscBlock(256, 512), - nn.Conv2d(512, 1, 4, 1, 0, bias=False) # [B,1,1,1] - ) - # 局部判别器:输出特征图(用于掩码加权) - self.local_disc = nn.Sequential( - DiscBlock(in_channels, 64), - DiscBlock(64, 128), - DiscBlock(128, 256), - DiscBlock(256, 512), - nn.Conv2d(512, 1, 1, 1, 0, bias=False) # [B,1,H',W'] - ) - - def forward(self, x, local_mask=None): - """ - :param x: [B,3,H,W] 归一化到[-1,1]的图像 - :param local_mask: [B,1,H,W] 真实文本掩码(训练时必需) - :return: - global_score: [B,1,1,1] 全局判别logits - local_score: [B,1,H',W'] 掩码加权后的局部判别logits - """ - global_score = self.global_disc(x) - local_score = 0 - - if local_mask is not None: - # 1. 计算局部判别特征图 - local_feat = self.local_disc(x) # [B,1,H',W'] - # 2. 将local_mask下采样到local_feat的尺寸 - _, _, h_feat, w_feat = local_feat.shape - local_mask_scaled = F.interpolate(local_mask, size=(h_feat, w_feat), mode='nearest') - # 3. 关键修复:用掩码加权局部特征(仅关注文本区域) - local_score = local_feat * local_mask_scaled - - return global_score, local_score - - -# 加载预训练VGG16(用于感知/风格损失) -class VGG16Feature(nn.Module): - def __init__(self): - super().__init__() - vgg = vgg16(pretrained=True).features - self.feat1 = nn.Sequential(*vgg[:5]) # 第1个池化层前 - self.feat2 = nn.Sequential(*vgg[5:10]) # 第2个池化层前 - self.feat3 = nn.Sequential(*vgg[10:17]) # 第3个池化层前 - # 冻结参数 - for param in self.parameters(): - param.requires_grad = False - - # 【关键修正】ImageNet归一化参数 - # self.mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1).cuda() - # self.std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1).cuda() - self.register_buffer('mean', torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)) - self.register_buffer('std', torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)) - - def forward(self, x): - # x: [B,3,H,W] 归一化到ImageNet标准 - # x: [-1,1] → [0,1] → ImageNet归一化 - x = (x + 1) / 2.0 # [-1,1] → [0,1] - x = F.interpolate(x, size=(224, 224), mode='bilinear', align_corners=False) - x = (x - self.mean) / self.std - f1 = self.feat1(x) - f2 = self.feat2(f1) - f3 = self.feat3(f2) - return [f1, f2, f3] - - -# Gram矩阵计算 -def gram_matrix(x): - b, c, h, w = x.shape - x = x.view(b, c, h * w) - return torch.bmm(x, x.transpose(1, 2)) / (c * h * w) - - -# 全量Loss计算类 -class EnsExamLoss(nn.Module): - def __init__(self, loss_config=None): - super().__init__() - if loss_config is None: - loss_config = {} - self.vgg_feat = VGG16Feature() - self.lambda_n = loss_config.get('lambda_n', [5, 6, 8, 10]) - self.beta_n = loss_config.get('beta_n', [0.8, 0.8, 0.8, 2]) - self.lambda_lr = loss_config.get('lambda_lr', 1.0) - self.lambda_p = loss_config.get('lambda_p', 0.05) - self.lambda_style = loss_config.get('lambda_style', 120) - self.lambda_sn = loss_config.get('lambda_sn', 1) - self.lambda_b = loss_config.get('lambda_b', 0.4) - - def sn_loss(self, Ms, Ms_gt): - """SN损失""" - Ms = Ms.float() - Ms_gt = Ms_gt.float() - - # 1. 计算每个样本的 GT 字迹总面积 (Batch 维度) - # shape: [B] - sum_gt = torch.sum(Ms_gt, dim=tuple(range(1, Ms.dim()))) - - # 2. 创建掩码:只选择 GT 中有字迹的样本 (阈值设为 1,避免浮点误差) - # 如果 sum_gt > 0,则 valid_mask 为 1,否则为 0 - valid_mask = (sum_gt > 1.0).float() - - # 如果整个 Batch 都没有字迹,直接返回 0,避免除零或无效计算 - if valid_mask.sum() == 0: - # 返回与输入无关的零梯度,但保持可训练 - return Ms.sum() * 0 # 利用输入创建,保持计算图连接 - - # 3. 计算 L1 误差总和 [B] - l1_sum = torch.sum(torch.abs(Ms - Ms_gt), dim=tuple(range(1, Ms.dim()))) - - # 4. 计算归一化分母 [B] - sum_pred = torch.sum(Ms, dim=tuple(range(1, Ms.dim()))) - normalization = torch.min(sum_pred, sum_gt) - - # 5. 计算单样本 Loss - # 注意:这里 epsilon 可以改回 1e-6,因为 valid_mask 已经保证了 sum_gt 不为 0 - loss_batch = l1_sum / (normalization + 1e-6) - - # 6. 只对有字迹的样本求平均 - # 乘以 valid_mask 将无字样本 loss 置零,除以 valid_mask.sum() 保证梯度尺度稳定 - L_sn = (loss_batch * valid_mask).sum() / (valid_mask.sum() + 1e-6) - - return L_sn - - def block_loss(self, Mb, Mb_gt): - # 逐样本计算 - intersection = (Mb * Mb_gt).sum(dim=[1, 2, 3]) # [B] - mb_sq = (Mb ** 2).sum(dim=[1, 2, 3]) # [B] - mbgt_sq = (Mb_gt ** 2).sum(dim=[1, 2, 3]) # [B] - - dice = (2 * intersection) / (mb_sq + mbgt_sq + 1e-8) # [B] - return (1 - dice).mean() # 平均所有样本 - - def lr_loss(self, Iouts, Igt_list, Mb_gt): - """LR损失(修复多尺度掩码维度匹配问题)""" - lr_loss = 0.0 - for i, (Iout, Igt) in enumerate(zip(Iouts, Igt_list)): - # 关键修复:将Mb_gt下采样到当前Iout/Igt的尺度 - # 获取当前Iout的高/宽 - _, _, h, w = Iout.shape - # 下采样Mb_gt到(h, w),保持通道数不变 - Mb_gt_scaled = F.interpolate(Mb_gt, size=(h, w), mode='nearest') - - # 文本区域损失(使用下采样后的掩码) - loss_text = F.l1_loss(Iout * Mb_gt_scaled, Igt * Mb_gt_scaled, reduction='mean') - # 非文本区域损失(使用下采样后的掩码) - loss_nontext = F.l1_loss(Iout * (1 - Mb_gt_scaled), Igt * (1 - Mb_gt_scaled), reduction='mean') - # 加权求和 - lr_loss += self.lambda_n[i] * loss_text + self.beta_n[i] * loss_nontext - return lr_loss - - def perceptual_loss(self, I_list, Igt): - """感知损失""" - per_loss = 0.0 - gt_feats = self.vgg_feat(Igt) - for I in I_list: - I_feats = self.vgg_feat(I) - for f1, f2 in zip(I_feats, gt_feats): - per_loss += F.l1_loss(f1, f2, reduction='mean') - return per_loss - - def style_loss(self, I_list, Igt): - style_loss = 0.0 - gt_feats = self.vgg_feat(Igt) - for I in I_list: - I_feats = self.vgg_feat(I) - for f1, f2 in zip(I_feats, gt_feats): - b, c, h, w = f1.shape - gram1 = gram_matrix(f1) # [B, C, C] - gram2 = gram_matrix(f2) # [B, C, C] - # 关键:除以 H*W*C 归一化 - norm = h * w * c - style_loss += F.l1_loss(gram1, gram2, reduction='mean') / norm - return style_loss - - # 判别器损失(训练D时用) - @staticmethod - def hinge_loss_D(real_score, fake_score): - loss_real = torch.mean(F.relu(1.0 - real_score)) - loss_fake = torch.mean(F.relu(1.0 + fake_score)) - return loss_real + loss_fake - - # 生成器损失(训练G时用) - @staticmethod - def hinge_loss_G(fake_score): - return -torch.mean(F.relu(1.0 + fake_score)) # 注意:ReLU(1+score) - - def forward(self, gen_out, gt, disc_score): - """ - 总损失计算 - :param gen_out: 生成器输出 (Ms, Mb, Ic4, Ic2, Ic1, Ire, Icomp) - :param gt: 标签 (Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt) - :param disc_score: 判别器分数 (global_score, local_score) - :return: 总损失,各分项损失 - """ - Ms, Mb, Ic4, Ic2, Ic1, Ire, Icomp = gen_out - Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt = gt - global_score, local_score = disc_score - - # 各分项损失 - L_sn = self.sn_loss(Ms, Ms_gt) * self.lambda_sn - L_block = self.block_loss(Mb, Mb_gt) * self.lambda_b - L_lr = self.lr_loss([Ic4, Ic2, Ic1, Ire], [Igt4, Igt2, Igt1, Igt], Mb_gt) * self.lambda_lr - L_per = self.perceptual_loss([Ire, Icomp], Igt) * self.lambda_p - L_style = self.style_loss([Ire, Icomp], Igt) * self.lambda_style - - L_adv_global = self.hinge_loss_G(global_score) - L_adv_local = self.hinge_loss_G(local_score) - L_adv = (L_adv_global + L_adv_local) / 2 - - # 总损失 - L_total = L_adv + L_lr + L_per + L_style + L_sn + L_block - return L_total, [L_adv, L_lr, L_per, L_style, L_sn, L_block] \ No newline at end of file diff --git a/backend/models/test_erase.py b/backend/models/test_erase.py index 59c13aa4..e0eee131 100644 --- a/backend/models/test_erase.py +++ b/backend/models/test_erase.py @@ -9,8 +9,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import torch -torch.backends.cudnn.deterministic = True -torch.backends.cudnn.benchmark = False +torch.backends.cudnn.enabled = False # cuDNN v8 frontend 与 A30+PyTorch2.1 不兼容 from pathlib import Path from PIL import Image import numpy as np @@ -27,7 +26,7 @@ sys.exit(1) # ── 路径配置 ────────────────────────────────────────── -MODEL_PATH = Path(__file__).parent.parent / "runtime_data" / "models" / "latest.pth" +MODEL_PATH = Path(__file__).parent / "weight" / "best.pth" OUTPUT_DIR = Path(__file__).parent.parent / "runtime_data" / "erased" OUTPUT_DIR.mkdir(parents=True, exist_ok=True) diff --git a/backend/models/train.py b/backend/models/train.py deleted file mode 100644 index 9baa621a..00000000 --- a/backend/models/train.py +++ /dev/null @@ -1,556 +0,0 @@ -# 设备 -import math -import os -import argparse - -import numpy as np -import torch -from torch import optim -from torch.utils.data import Dataset, DataLoader -from torchvision.transforms import transforms -import torch.nn.functional as F -from tqdm import tqdm - -from src.adcj.EnsExam.model import Generator, Discriminator, EnsExamLoss -from src.adcj.EnsExam.config import config - -import os -import torch -import numpy as np -import cv2 -from torch.utils.data import Dataset -from torchvision import transforms -import torch.nn.functional as F - - -def generate_mask_from_pair(Iin, Igt, threshold=20, debug=False): - """ - 单块(512x512)软笔画掩码生成 - 修正版本 - :param Iin: 单块图像 (H,W,3) RGB 0-255 - :param Igt: 单块GT图 (H,W,3) RGB 0-255 - :param threshold: 差异阈值,默认20 - :param debug: 是否返回中间结果用于调试 - :return: Ms_gt(软掩码), Mb_gt(基础掩码), [debug_dict] - """ - H, W = Iin.shape[:2] - - # ========== 1. 生成粗笔画掩码 ========== - # 使用int16避免溢出,计算RGB三通道的平均差异 - diff = np.abs(Iin.astype(np.int16) - Igt.astype(np.int16)).mean(axis=-1) - # print(f"Diff统计: min={diff.min()}, max={diff.max()}, 90%={np.percentile(diff, 90)}") - coarse_mask = (diff > threshold).astype(np.uint8) - - # ========== 2. 形态学去噪 ========== - kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) - # 开运算去除散点噪声 + 闭运算连接断裂笔画 - denoised_mask = cv2.morphologyEx(coarse_mask, cv2.MORPH_OPEN, kernel) - denoised_mask = cv2.morphologyEx(denoised_mask, cv2.MORPH_CLOSE, kernel) - - # ========== 3. 生成 Mb_gt(基础文本块掩码) ========== - # 对去噪后的掩码适度膨胀,作为文本区域指导 - Mb_gt = cv2.dilate(denoised_mask, kernel, iterations=2).astype(np.float32) - - # ========== 4. 生成软笔画掩码 Ms_gt ========== - # 4.1 骨架:笔画收缩1像素(论文明确要求) - skeleton = cv2.erode(denoised_mask, kernel, iterations=1) - - # 4.2 外边界区域:原始笔画扩张5像素 - kernel_large = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) - outer_region = cv2.dilate(denoised_mask, kernel_large, iterations=5) - - # 4.3 距离变换:计算outer_region内每个像素到边缘的最短距离 - # 输入要求:uint8, 0=背景, >0=前景;输出:float32,单位像素 - dist_map = cv2.distanceTransform(outer_region, cv2.DIST_L2, cv2.DIST_MASK_PRECISE) - - # 4.4 SAF 参数 (论文明确值) - alpha = 3.0 - L = 5.0 - exp_neg_alpha = np.exp(-alpha) - C = (1 + exp_neg_alpha) / (1 - exp_neg_alpha + 1e-8) # +eps防除零 - - # 4.5 向量化计算软掩码(避免循环,提速100x) - Ms_gt = np.zeros((H, W), dtype=np.float32) - - # 区域划分mask - mask_skeleton = skeleton > 0 # 骨架区域 → 值=1 - mask_outer = outer_region > 0 # 扩张区域(含骨架) - mask_middle = mask_outer & (~mask_skeleton) # 中间环形区域 → 用SAF - - # (a) 骨架区域强制=1 - Ms_gt[mask_skeleton] = 1.0 - - # (b) 中间区域应用SAF衰减 - if np.any(mask_middle): - D = np.clip(dist_map[mask_middle], 0, L) # 距离截断到[0, L] - # SAF公式: C * (2/(1+exp(-α*D/L)) - 1) - exp_term = np.exp(-alpha * D / L) - saf_vals = C * (2.0 / (1.0 + exp_term + 1e-8) - 1.0) - Ms_gt[mask_middle] = np.clip(saf_vals, 0.0, 1.0) - - # (c) 外边界及以外区域保持=0(已初始化为0) - - # ========== 5. 调试信息(可选) ========== - if debug: - debug_info = { - 'coarse_mask': coarse_mask, - 'denoised_mask': denoised_mask, - 'skeleton': skeleton, - 'outer_region': outer_region, - 'dist_map': dist_map, - 'mask_middle': mask_middle.astype(np.uint8) * 255 - } - return Ms_gt, Mb_gt, debug_info - - return Ms_gt, Mb_gt - - -# -------------------------- 真实数据集加载类(核心修复:统一图像尺寸) -------------------------- -# class EnsExamRealDataset(Dataset): -# """ -# 适配“仅含原始图+擦除GT图”的数据集加载类 -# ✅ 修复:所有图像读取后先resize到img_size,保证批量尺寸一致 -# """ -# -# def __init__(self, data_root, img_size=512, is_train=True): -# self.data_root = data_root -# self.img_size = img_size # 统一目标尺寸(512×512) -# self.is_train = is_train -# -# # 1. 定义图像预处理:归一化到[-1,1](匹配GAN训练) -# self.img_transform = transforms.Compose([ -# transforms.ToTensor(), # HWC→CHW,0-255→0-1 -# transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) # 0-1→[-1,1] -# ]) -# -# # 2. 加载文件列表(保证images和gt目录下文件同名) -# split = "train" if is_train else "test" -# self.img_dir = os.path.join(data_root, split, "all_images") -# self.gt_dir = os.path.join(data_root, split, "all_labels") -# -# # 过滤有效文件(仅保留png/jpg/jpeg) -# self.file_names = [ -# f for f in os.listdir(self.img_dir) -# if f.endswith((".png", ".jpg", ".jpeg")) and os.path.exists(os.path.join(self.gt_dir, f)) -# ] -# assert len(self.file_names) > 0, f"未找到匹配的图像文件!检查{self.img_dir}和{self.gt_dir}目录" -# -# def __len__(self): -# return len(self.file_names) -# -# def __getitem__(self, idx): -# # 1. 加载原始图和擦除GT图 -# file_name = self.file_names[idx] -# img_path = os.path.join(self.img_dir, file_name) -# gt_path = os.path.join(self.gt_dir, file_name) -# -# # 读取图像(BGR→RGB,避免OpenCV默认BGR格式问题) -# Iin = cv2.imread(img_path)[:, :, ::-1] # (H,W,3),0-255,RGB -# Igt = cv2.imread(gt_path)[:, :, ::-1] # (H,W,3),0-255,RGB -# -# # ✅ 核心修复1:先统一resize到img_size×img_size(所有样本尺寸一致) -# Iin = cv2.resize(Iin, (self.img_size, self.img_size), interpolation=cv2.INTER_LINEAR) -# Igt = cv2.resize(Igt, (self.img_size, self.img_size), interpolation=cv2.INTER_LINEAR) -# -# # 2. 自动生成Ms_gt(软笔画掩码)、Mb_gt(文本块掩码) -# # ✅ 核心修复2:传入已resize的图像,函数内不再重复resize -# Ms_gt_np, Mb_gt_np = generate_mask_from_pair(Iin, Igt, threshold=20) -# -# # 3. 图像预处理(归一化到[-1,1]) -# Iin = self.img_transform(Iin) # (3,512,512),[-1,1] -# Igt = self.img_transform(Igt) # (3,512,512),[-1,1] -# -# # 4. 掩码预处理(归一化到[0,1],扩展通道维度) -# Ms_gt = torch.from_numpy(Ms_gt_np).unsqueeze(0).float() # (1,512,512),[0,1] -# Mb_gt = torch.from_numpy(Mb_gt_np).unsqueeze(0).float() # (1,512,512),[0,1] -# -# # 5. 生成多尺度擦除GT(1/4, 1/2, 1/1) -# # Igt4:512→128,Igt2:512→256,Igt1:原尺寸 -# Igt4 = F.interpolate(Igt.unsqueeze(0), size=(self.img_size // 4, self.img_size // 4), mode='bilinear', -# align_corners=False).squeeze(0) -# Igt2 = F.interpolate(Igt.unsqueeze(0), size=(self.img_size // 2, self.img_size // 2), mode='bilinear', -# align_corners=False).squeeze(0) -# Igt1 = Igt # 1:1尺度 -# -# # 返回顺序:Iin, Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt -# return Iin, Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt - -class EnsExamRealDataset(Dataset): - """ - 适配“仅含原始图 + 擦除 GT 图”的数据集加载类 - ✅ 修改:不再 resize 原图,而是将大图裁剪为多个 512x512 的样本块 - """ - - def __init__(self, data_root, img_size=512, is_train=True, overlap=0): - """ - :param data_root: 数据集根目录 - :param img_size: 裁剪块尺寸 (默认 512) - :param is_train: 是否训练模式 - :param overlap: 裁剪重叠像素 (默认 0,即不重叠;训练时可设为 128 增加数据量) - """ - self.data_root = data_root - self.img_size = img_size - self.is_train = is_train - self.overlap = overlap - - # 1. 定义图像预处理:归一化到 [-1,1] - self.img_transform = transforms.Compose([ - transforms.ToTensor(), # HWC→CHW, 0-255→0-1 - transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) - ]) - - # 2. 获取文件路径 - split = "train" if is_train else "test" - self.img_dir = os.path.join(data_root, split, "all_images") - self.gt_dir = os.path.join(data_root, split, "all_labels") - - # 3. ✅ 核心修改:构建“样本索引列表” - # 不再是一对一 (1 图=1 样本),而是一对多 (1 图=N 个样本块) - self.patch_index_map = [] - - valid_extensions = (".png", ".jpg", ".jpeg") - all_files = [f for f in os.listdir(self.img_dir) if f.endswith(valid_extensions)] - - print(f"🔍 正在扫描数据集并构建裁剪索引 (Overlap={overlap})...") - for fname in all_files: - if not os.path.exists(os.path.join(self.gt_dir, fname)): - continue - - img_path = os.path.join(self.img_dir, fname) - gt_path = os.path.join(self.gt_dir, fname) - - # 读取尺寸 (仅读取头信息,不加载像素,速度快) - # 如果 cv2.imread 太慢,可用 imageio 或 PIL 获取 size,这里为了兼容直接用 cv2 - # 注意:这里必须加载一次获取 H,W,或者用 cv2.imread + IMREAD_UNCHANGED - img_temp = cv2.imread(img_path) - if img_temp is None: continue - H, W = img_temp.shape[:2] - del img_temp # 释放内存 - - # 计算步长 - step = self.img_size - self.overlap - if step <= 0: step = 1 # 防止 overlap 过大导致步长为 0 - - # 计算该图能切多少块 (覆盖全图,边缘不足补黑) - # 使用 math.ceil 确保覆盖整张图 - num_h = math.ceil((H - self.overlap) / step) if H > self.img_size else 1 - num_w = math.ceil((W - self.overlap) / step) if W > self.img_size else 1 - - # 如果原图小于 512,也作为一个样本 (后续会 padding) - if H <= self.img_size and W <= self.img_size: - num_h, num_w = 1, 1 - - for i in range(num_h): - for j in range(num_w): - # 计算裁剪坐标 - y1 = i * step - x1 = j * step - - # 如果是最后一块,确保覆盖到边缘 - y2 = min(y1 + self.img_size, H) - x2 = min(x1 + self.img_size, W) - - # 如果是第一块且图很小,直接取全图 - if H <= self.img_size: y1, y2 = 0, H - if W <= self.img_size: x1, x2 = 0, W - - # 记录样本信息:(图路径,GT 路径,裁剪坐标,是否需要 padding) - need_pad_h = (y2 - y1) < self.img_size - need_pad_w = (x2 - x1) < self.img_size - - self.patch_index_map.append({ - 'img_path': img_path, - 'gt_path': gt_path, - 'y1': y1, 'y2': y2, - 'x1': x1, 'x2': x2, - 'pad_h': need_pad_h, - 'pad_w': need_pad_w - }) - - print(f"✅ 索引构建完成:共 {len(self.patch_index_map)} 个样本块 (来自 {len(all_files)} 张图)") - assert len(self.patch_index_map) > 0, "未找到有效样本!" - - def __len__(self): - return len(self.patch_index_map) - - def __getitem__(self, idx): - # 1. 获取当前样本的裁剪信息 - info = self.patch_index_map[idx] - - # print(f"🔎 Loading: {info['img_path']}") - # print(f" Exists: {os.path.exists(info['img_path'])}") - - # 2. 加载完整原图 (RGB) - Iin_full = cv2.imread(info['img_path'])[:, :, ::-1] - Igt_full = cv2.imread(info['gt_path'])[:, :, ::-1] - - # 3. ✅ 核心修改:根据坐标裁剪 - Iin = Iin_full[info['y1']:info['y2'], info['x1']:info['x2']] - Igt = Igt_full[info['y1']:info['y2'], info['x1']:info['x2']] - - Iin = np.ascontiguousarray(Iin) - Igt = np.ascontiguousarray(Igt) - - # 4. ✅ 边缘填充 (确保输入模型的都是 512x512) - if info['pad_h'] or info['pad_w']: - pad_h = self.img_size - Iin.shape[0] - pad_w = self.img_size - Iin.shape[1] - # 使用 REPLICATE 填充比 CONSTANT(黑边) 更好,减少边界伪影 - Iin = cv2.copyMakeBorder(Iin, 0, pad_h, 0, pad_w, cv2.BORDER_REPLICATE) - Igt = cv2.copyMakeBorder(Igt, 0, pad_h, 0, pad_w, cv2.BORDER_REPLICATE) - - # 5. 生成掩码 (输入已经是 512x512) - # 注意:这里调用的是单块生成函数,不是滑动拼接函数 - Ms_gt_np, Mb_gt_np = generate_mask_from_pair(Iin, Igt, threshold=20) - - # 6. 图像预处理 (归一化) - Iin = self.img_transform(Iin) # (3, 512, 512) - Igt = self.img_transform(Igt) # (3, 512, 512) - - # 7. 掩码转 Tensor - Ms_gt = torch.from_numpy(Ms_gt_np).unsqueeze(0).float() - Mb_gt = torch.from_numpy(Mb_gt_np).unsqueeze(0).float() - - # 8. 生成多尺度 GT (1/4, 1/2, 1/1) - Igt_unsqueeze = Igt.unsqueeze(0) - Igt4 = F.interpolate(Igt_unsqueeze, size=(self.img_size // 4, self.img_size // 4), - mode='bilinear', align_corners=False).squeeze(0) - Igt2 = F.interpolate(Igt_unsqueeze, size=(self.img_size // 2, self.img_size // 2), - mode='bilinear', align_corners=False).squeeze(0) - Igt1 = Igt - - # 返回:Iin, Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt - return Iin, Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt - - -# ====================== 3. 核心训练函数 ====================== -def train_ensexam( - epochs=None, - batch_size=None, - lr=None, - data_root=None, - img_size=512, - save_dir=None, - resume_path=None, - resume=False, - num_workers=None, - device=None, - use_wandb=None): - train_cfg = config.train - loss_cfg = config.loss - wandb_cfg = config.wandb - - epochs = epochs if epochs is not None else train_cfg.get('epochs', 100) - batch_size = batch_size if batch_size is not None else train_cfg.get('batch_size', 4) - lr = lr if lr is not None else train_cfg.get('lr', 0.0001) - data_root = data_root if data_root is not None else train_cfg.get('data_root', './data') - save_dir = save_dir if save_dir is not None else train_cfg.get('save_dir', './ensexam_checkpoints') - resume_path = resume_path if resume_path is not None else train_cfg.get('resume_path', - './ensexam_checkpoints/latest.pth') - num_workers = num_workers if num_workers is not None else train_cfg.get('num_workers', 4) - device = device if device is not None else train_cfg.get('device', 'cuda' if torch.cuda.is_available() else 'cpu') - use_wandb = use_wandb if use_wandb is not None else wandb_cfg.get('enabled', True) - # 0. 初始化目录 - os.makedirs(save_dir, exist_ok=True) - - wandb_run = None - if use_wandb: - try: - import wandb - wandb_cfg = config.wandb - if wandb_cfg.get('offline', True): - os.environ['WANDB_MODE'] = 'offline' - wandb_dir = wandb_cfg.get('dir', './wandb') - os.makedirs(wandb_dir, exist_ok=True) - - wandb_run = wandb.init( - project=wandb_cfg.get('project', 'ensexam'), - name=wandb_cfg.get('name'), - entity=wandb_cfg.get('entity'), - dir=wandb_cfg.get('dir', './wandb'), - config={ - 'train': train_cfg, - 'loss': loss_cfg - } - ) - print(f"WandB 初始化成功 (离线模式: {wandb_cfg.get('offline', True)})") - except ImportError: - print("Warning: wandb 未安装,跳过 wandb 记录") - except Exception as e: - print(f"Warning: wandb 初始化失败: {e}") - - # 1. 数据加载 - train_dataset = EnsExamRealDataset(data_root=data_root) - train_loader = DataLoader( - train_dataset, - batch_size=batch_size, - shuffle=True, - num_workers=0, # Windows下建议设为0,避免多进程问题 - drop_last=True - ) - - # 2. 模型初始化 - G = Generator().to(device) - D = Discriminator().to(device) - criterion = EnsExamLoss(loss_cfg).to(device) - - # 3. 优化器 - optimizer_G = optim.Adam(G.parameters(), lr=lr, betas=(0.5, 0.9)) - optimizer_D = optim.Adam(D.parameters(), lr=lr, betas=(0.5, 0.9)) - - # 4. 断点续训 - start_epoch = 0 - if resume and os.path.exists(resume_path): - checkpoint = torch.load(resume_path, map_location=device) - G.load_state_dict(checkpoint['G_state_dict']) - D.load_state_dict(checkpoint['D_state_dict']) - optimizer_G.load_state_dict(checkpoint['optimizer_G']) - optimizer_D.load_state_dict(checkpoint['optimizer_D']) - start_epoch = checkpoint['epoch'] - print(f"断点续训:从第{start_epoch}Epoch恢复") - - # 5. 训练循环 - G.train() - D.train() - for epoch in range(start_epoch, epochs): - epoch_loss_G = 0.0 # 生成器总损失 - epoch_loss_D = 0.0 # 判别器总损失 - epoch_loss_parts = { - 'adv': 0.0, - 'lr': 0.0, - 'per': 0.0, - 'style': 0.0, - 'sn': 0.0, - 'block': 0.0 - } - pbar = tqdm(train_loader, desc=f"Epoch {epoch + 1}/{epochs}") - - for batch_idx, (Iin, Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt) in enumerate(pbar): - # 数据移到设备 - Iin = Iin.to(device) - Ms_gt = Ms_gt.to(device) - Mb_gt = Mb_gt.to(device) - Igt4 = Igt4.to(device) - Igt2 = Igt2.to(device) - Igt1 = Igt1.to(device) - Igt = Igt.to(device) - gt = (Ms_gt, Mb_gt, Igt4, Igt2, Igt1, Igt) - - # ---------------------- 步骤1:训练判别器D ---------------------- - optimizer_D.zero_grad() - - # 1.1 真实图像判别 - real_global, real_local = D(Igt, Mb_gt) - # 1.2 生成图像判别 - gen_out = G(Iin) - Icomp = gen_out[-1] # 取最后一个输出Icomp - fake_global, fake_local = D(Icomp.detach(), Mb_gt) - # 1.3 计算D损失(使用Model中的hinge_loss_D) - loss_D_global = EnsExamLoss.hinge_loss_D(real_global, fake_global) - loss_D_local = EnsExamLoss.hinge_loss_D(real_local, fake_local) - loss_D = (loss_D_global + loss_D_local) / 2 - - loss_D.backward() - optimizer_D.step() - - # ---------------------- 步骤2:训练生成器G ---------------------- - optimizer_G.zero_grad() - - # 2.1 重新计算生成图像的判别分数 - fake_global, fake_local = D(Icomp, Mb_gt) - disc_score = (fake_global, fake_local) - # 2.2 计算G的全量损失(Model中的EnsExamLoss.forward) - loss_G, loss_parts = criterion(gen_out, gt, disc_score) - - loss_G.backward() - optimizer_G.step() - - # ---------------------- 步骤3:日志记录 ---------------------- - epoch_loss_G += loss_G.item() - epoch_loss_D += loss_D.item() - epoch_loss_parts['adv'] += loss_parts[0].item() - epoch_loss_parts['lr'] += loss_parts[1].item() - epoch_loss_parts['per'] += loss_parts[2].item() - epoch_loss_parts['style'] += loss_parts[3].item() - epoch_loss_parts['sn'] += loss_parts[4].item() - epoch_loss_parts['block'] += loss_parts[5].item() - - # 进度条显示 - pbar.set_postfix({ - 'Loss_D': f"{loss_D.item():.4f}", - 'Loss_G': f"{loss_G.item():.4f}", - 'Loss_G_adv': f"{loss_parts[0].item():.4f}", - 'Loss_G_lr': f"{loss_parts[1].item():.4f}" - }) - if wandb_run is not None: - wandb.log({ - 'batch_loss_G': loss_G.item(), - 'batch_loss_D': loss_D.item(), - 'batch_loss_G_adv': loss_parts[0].item(), - 'batch_loss_G_lr': loss_parts[1].item(), - 'batch_loss_G_per': loss_parts[2].item(), - 'batch_loss_G_style': loss_parts[3].item(), - 'batch_loss_G_sn': loss_parts[4].item(), - 'batch_loss_G_block': loss_parts[5].item() - }) - - # ---------------------- 步骤4:Epoch结束处理 ---------------------- - # 计算Epoch平均损失 - avg_loss_G = epoch_loss_G / len(train_loader) - avg_loss_D = epoch_loss_D / len(train_loader) - avg_loss_parts = {k: v / len(train_loader) for k, v in epoch_loss_parts.items()} - print(f"Epoch {epoch + 1} 平均损失:Loss_G={avg_loss_G:.4f}, Loss_D={avg_loss_D:.4f}") - if wandb_run is not None: - wandb.log({ - 'epoch': epoch + 1, - 'avg_loss_G': avg_loss_G, - 'avg_loss_D': avg_loss_D - }) - - # 保存模型(每5个Epoch保存一次,同时保存最新模型) - if (epoch + 1) % 5 == 0 or epoch == epochs - 1: - checkpoint = { - 'epoch': epoch + 1, - 'G_state_dict': G.state_dict(), - 'D_state_dict': D.state_dict(), - 'optimizer_G': optimizer_G.state_dict(), - 'optimizer_D': optimizer_D.state_dict(), - 'avg_loss_G': avg_loss_G, - 'avg_loss_D': avg_loss_D - } - # 保存最新模型(用于断点续训) - torch.save(checkpoint, os.path.join(save_dir, 'latest.pth')) - # 保存Epoch模型 - torch.save(checkpoint, os.path.join(save_dir, f'ensexam_epoch_{epoch + 1}.pth')) - print(f"模型已保存:{os.path.join(save_dir, f'ensexam_epoch_{epoch + 1}.pth')}") - - if wandb_run is not None: - wandb.finish() - print("训练完成!") - - -# ====================== 4. 启动训练 ====================== -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='EnsExam 训练') - parser.add_argument('--epochs', type=int, default=None, help='训练轮数') - parser.add_argument('--batch_size', type=int, default=None, help='批次大小') - parser.add_argument('--lr', type=float, default=None, help='学习率') - parser.add_argument('--device', type=str, default=None, help='设备 (cuda/cpu)') - parser.add_argument('--data_root', type=str, default=None, help='数据集路径') - parser.add_argument('--save_dir', type=str, default=None, help='模型保存目录') - parser.add_argument('--resume', action='store_true', help='断点续训') - parser.add_argument('--no_wandb', action='store_true', help='禁用 wandb') - args = parser.parse_args() - - use_wandb = not args.no_wandb - device = args.device - - train_ensexam( - epochs=args.epochs, - batch_size=args.batch_size, - lr=args.lr, - device=device, - data_root=args.data_root, - save_dir=args.save_dir, - resume=args.resume, - use_wandb=use_wandb - ) \ No newline at end of file diff --git a/backend/models/weight/best.pth b/backend/models/weight/best.pth new file mode 100644 index 00000000..61723552 --- /dev/null +++ b/backend/models/weight/best.pth @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ce399f0447fdc016bbe3957559264735a5b9e5de30725ca2ed4d66699fd3f47 +size 362841171 diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 00000000..dfb18f11 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 00000000..1a9f7b33 --- /dev/null +++ b/backend/routes/__init__.py @@ -0,0 +1,22 @@ +""" +路由注册模块 — 将所有 Blueprint 注册到 Flask app +""" + +from .auth import bp as auth_bp +from .upload import bp as upload_bp +from .questions import bp as questions_bp +from .chat import bp as chat_bp +from .stats import bp as stats_bp +from .settings import bp as settings_bp +from .notes import bp as notes_bp + + +def register_routes(app): + """注册所有 Blueprint 到 Flask app""" + app.register_blueprint(auth_bp, url_prefix='/api/auth') + app.register_blueprint(upload_bp, url_prefix='/api') + app.register_blueprint(questions_bp, url_prefix='/api') + app.register_blueprint(chat_bp, url_prefix='/api') + app.register_blueprint(stats_bp, url_prefix='/api') + app.register_blueprint(settings_bp, url_prefix='/api') + app.register_blueprint(notes_bp, url_prefix='/api/notes') diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 00000000..01efeeb1 --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,284 @@ +import hmac +import hashlib +import logging +import secrets +from datetime import datetime, timedelta + +from flask import Blueprint, request, jsonify, session, current_app +from werkzeug.security import generate_password_hash, check_password_hash + +from core.config import settings +from core.mail import send_smtp_email +from db import SessionLocal +from db import crud + +logger = logging.getLogger(__name__) + +bp = Blueprint("auth", __name__) + + +def _hash_registration_code(email: str, code: str, pepper: str) -> str: + raw = f"{email.lower().strip()}:{code}".encode("utf-8") + return hmac.new(pepper.encode("utf-8"), raw, hashlib.sha256).hexdigest() + + +def _generate_six_digit_code() -> str: + return f"{secrets.randbelow(900000) + 100000}" + + +@bp.route("/register", methods=["POST"]) +def auth_register(): + """用户注册(需先调用 /send-code?type=register 并填写正确验证码)。""" + data = request.get_json() or {} + email = (data.get("email") or "").strip().lower() + username = (data.get("username") or "").strip() + password = data.get("password") or "" + code = (data.get("code") or "").strip() + + if not email or "@" not in email: + return jsonify({"success": False, "error": "邮箱格式不正确"}), 400 + if not username: + return jsonify({"success": False, "error": "用户名不能为空"}), 400 + if len(password) < 6: + return jsonify({"success": False, "error": "密码至少 6 位"}), 400 + if not code or not code.isdigit() or len(code) != 6: + return jsonify({"success": False, "error": "请输入 6 位邮箱验证码"}), 400 + + pepper = (current_app.secret_key or "dev-secret-change-in-production")[:64] + submitted_hash = _hash_registration_code(email, code, pepper) + now = datetime.utcnow() + + user_id = None + username_out = None + email_out = None + is_admin_out = None + + with SessionLocal() as db: + if crud.get_user_by_email(db, email): + return jsonify({"success": False, "error": "该邮箱已注册"}), 409 + + row = crud.get_verification_by_email(db, email) + if not row: + return jsonify({"success": False, "error": "请先获取邮箱验证码"}), 400 + if row.expires_at < now: + crud.delete_verification_by_email(db, email) + return jsonify({"success": False, "error": "验证码已过期,请重新获取"}), 400 + if (row.attempts or 0) >= settings.registration_max_attempts: + crud.delete_verification_by_email(db, email) + return jsonify({"success": False, "error": "验证失败次数过多,请重新获取验证码"}), 400 + + if not hmac.compare_digest(row.code_hash, submitted_hash): + crud.increment_verification_attempts(db, email) + return jsonify({"success": False, "error": "验证码错误"}), 400 + + pwd_hash = generate_password_hash(password) + user = crud.create_user(db, email=email, password_hash=pwd_hash, username=username) + crud.delete_verification_by_email(db, email) + # 将需要的字段提前取出为纯 Python 变量,避免脱离 Session 后访问 ORM 对象触发 DetachedInstanceError + user_id = user.id + username_out = user.username + email_out = user.email + is_admin_out = user.is_admin + session_version_out = user.session_version or 0 + + session["user_id"] = user_id + session["username"] = username_out + session["is_admin"] = bool(is_admin_out) + session["session_version"] = session_version_out + return jsonify( + { + "success": True, + "user": { + "id": user_id, + "email": email_out, + "username": username_out, + "is_admin": is_admin_out, + }, + } + ), 201 + + +@bp.route("/send-code", methods=["POST"]) +def send_code(): + """发送验证码(支持注册和找回密码两种场景)。 + + 请求体:{ email, type: 'register' | 'reset' } + """ + data = request.get_json() or {} + email = (data.get("email") or "").strip().lower() + code_type = (data.get("type") or "register").strip().lower() + + if not email or "@" not in email: + return jsonify({"success": False, "error": "邮箱格式不正确"}), 400 + + now = datetime.utcnow() + # 提前生成验证码,保证频率检查与写入在同一个 DB 事务中(消除 TOCTOU 竞态) + code = _generate_six_digit_code() + pepper = (current_app.secret_key or "dev-secret-change-in-production")[:64] + code_hash = _hash_registration_code(email, code, pepper) + expires_at = now + timedelta(minutes=settings.registration_code_ttl_minutes) + + with SessionLocal() as db: + # 频率限制与写入在同一 session,防止并发请求绕过限制 + row = crud.get_verification_by_email(db, email) + if row and row.last_sent_at: + delta = (now - row.last_sent_at).total_seconds() + if delta < settings.registration_send_interval_seconds: + wait = int(settings.registration_send_interval_seconds - delta) + return jsonify( + {"success": False, "error": f"发送过于频繁,请 {wait} 秒后再试"} + ), 429 + + user = crud.get_user_by_email(db, email) + # 防邮箱枚举:注册时邮箱已存在、重置时邮箱不存在,均返回相同成功响应但不实际发送 + # 同时写入记录以保证频率限制行为一致,防止通过 429 差异探测邮箱 + if (code_type == "register" and user) or (code_type == "reset" and not user): + crud.upsert_registration_code(db, email, "anti-enum-dummy", now + timedelta(minutes=1), now) + return jsonify({"success": True, "message": "验证码已发送"}) + + crud.upsert_registration_code(db, email, code_hash, expires_at, now) + + subject_text = "找回密码" if code_type == "reset" else "注册" + smtp_ok = bool(settings.smtp_host and settings.smtp_from) + if smtp_ok: + try: + send_smtp_email( + host=settings.smtp_host, + port=settings.smtp_port, + user=settings.smtp_user, + password=settings.smtp_password, + mail_from=settings.smtp_from, + use_tls=settings.smtp_use_tls, + to_addr=email, + subject=f"【智卷系统】{subject_text}验证码", + body=f"您的{subject_text}验证码为:{code},{settings.registration_code_ttl_minutes} 分钟内有效。请勿泄露给他人。", + async_send=True, + ) + except Exception: + logger.exception("发送验证码邮件失败") + return jsonify({"success": False, "error": "邮件发送失败,请稍后重试"}), 502 + else: + if current_app.debug: + logger.warning( + "[开发模式] 未配置 SMTP,验证码(仅调试用) email=%s code=%s", + email, + code, + ) + else: + return jsonify( + {"success": False, "error": "服务器未配置发信,无法发送验证码"} + ), 503 + + return jsonify({"success": True, "message": "验证码已发送"}) + + +@bp.route("/reset-password", methods=["POST"]) +def reset_password(): + """通过邮箱验证码重置密码。 + + 请求体:{ email, code, password } + """ + data = request.get_json() or {} + email = (data.get("email") or "").strip().lower() + code = (data.get("code") or "").strip() + password = data.get("password") or "" + + if not email or "@" not in email: + return jsonify({"success": False, "error": "邮箱格式不正确"}), 400 + if not code or not code.isdigit() or len(code) != 6: + return jsonify({"success": False, "error": "请输入 6 位验证码"}), 400 + if len(password) < 6: + return jsonify({"success": False, "error": "密码至少 6 位"}), 400 + + pepper = (current_app.secret_key or "dev-secret-change-in-production")[:64] + submitted_hash = _hash_registration_code(email, code, pepper) + now = datetime.utcnow() + + with SessionLocal() as db: + user = crud.get_user_by_email(db, email) + if not user: + # 防枚举:与验证码错误返回相同提示 + return jsonify({"success": False, "error": "验证码错误"}), 400 + + row = crud.get_verification_by_email(db, email) + if not row: + return jsonify({"success": False, "error": "请先获取验证码"}), 400 + if row.expires_at < now: + crud.delete_verification_by_email(db, email) + return jsonify({"success": False, "error": "验证码已过期,请重新获取"}), 400 + if (row.attempts or 0) >= settings.registration_max_attempts: + crud.delete_verification_by_email(db, email) + return jsonify({"success": False, "error": "验证失败次数过多,请重新获取验证码"}), 400 + + if not hmac.compare_digest(row.code_hash, submitted_hash): + crud.increment_verification_attempts(db, email) + return jsonify({"success": False, "error": "验证码错误"}), 400 + + if check_password_hash(user.password_hash, password): + return jsonify({"success": False, "error": "新密码不能与原密码相同"}), 400 + + new_hash = generate_password_hash(password) + crud.update_user_password(db, email, new_hash) + crud.delete_verification_by_email(db, email) + + return jsonify({"success": True, "message": "密码重置成功"}) + + +@bp.route("/login", methods=["POST"]) +def auth_login(): + """用户登录(支持邮箱或用户名)""" + data = request.get_json() or {} + identifier = (data.get("email") or data.get("identifier") or "").strip() + password = data.get("password") or "" + + if not identifier: + return jsonify({"error": "请输入邮箱或用户名"}), 400 + + with SessionLocal() as db: + user = crud.get_user_by_login(db, identifier) + if not user or not check_password_hash(user.password_hash, password): + return jsonify({"error": "账号或密码错误"}), 401 + session["user_id"] = user.id + session["username"] = user.username + session["is_admin"] = bool(user.is_admin) + session["session_version"] = user.session_version or 0 + return jsonify( + { + "user": { + "id": user.id, + "email": user.email, + "username": user.username, + "is_admin": user.is_admin, + } + } + ) + + +@bp.route("/logout", methods=["POST"]) +def auth_logout(): + """退出登录""" + session.clear() + return jsonify({"ok": True}) + + +@bp.route("/me", methods=["GET"]) +def auth_me(): + """获取当前登录用户""" + user_id = session.get("user_id") + if not user_id: + return jsonify({"error": "未登录", "code": "UNAUTHORIZED"}), 401 + with SessionLocal() as db: + user = crud.get_user_by_id(db, user_id) + if not user: + session.clear() + return jsonify({"error": "用户不存在", "code": "UNAUTHORIZED"}), 401 + return jsonify( + { + "user": { + "id": user.id, + "email": user.email, + "username": user.username, + "is_admin": user.is_admin, + } + } + ) diff --git a/backend/routes/chat.py b/backend/routes/chat.py new file mode 100644 index 00000000..d7b636c7 --- /dev/null +++ b/backend/routes/chat.py @@ -0,0 +1,303 @@ +"""Chat-related routes (AI conversation with questions).""" + +import json +import logging + +from flask import Blueprint, request, jsonify, session, Response + +from db import SessionLocal +from db import crud +from db.models import Question, ChatSession as ChatSessionModel, QuestionTagMapping +from core.config import settings + +logger = logging.getLogger(__name__) + +bp = Blueprint('chat', __name__) + + +def _effective_user_id(): + """管理员返回 None(不过滤),普通用户返回 user_id""" + if session.get('is_admin'): + return None + return session.get('user_id') + + +def _serialize_chat_session(s) -> dict: + """将 ChatSession ORM 对象序列化为前端 JSON""" + return { + 'id': s.id, + 'title': s.title or '新对话', + 'question_id': s.question_id, + 'created_at': s.created_at.isoformat() if s.created_at else None, + 'updated_at': s.updated_at.isoformat() if s.updated_at else None, + } + + +def _serialize_question(q: Question) -> dict: + """将 Question ORM 对象序列化为前端 JSON 格式""" + subject = None + if q.batch: + subject = q.batch.subject + knowledge_tags = [] + if q.tags: + for mapping in q.tags: + if mapping.tag: + knowledge_tags.append(mapping.tag.tag_name) + + return { + 'id': q.id, + 'question_type': q.question_type, + 'content_json': json.loads(q.content_json) if q.content_json else [], + 'options_json': json.loads(q.options_json) if q.options_json else None, + 'has_formula': q.has_formula, + 'has_image': q.has_image, + 'needs_correction': q.needs_correction, + 'answer': q.answer, + 'subject': subject, + 'knowledge_tags': knowledge_tags, + 'created_at': q.created_at.isoformat() if q.created_at else None, + } + + +@bp.route('/question//chats', methods=['GET']) +def get_question_chats(question_id): + """获取某道题目的所有对话会话""" + try: + with SessionLocal() as db: + sessions = crud.get_chat_sessions_by_question(db, question_id, user_id=_effective_user_id()) + return jsonify({ + 'success': True, + 'sessions': [_serialize_chat_session(s) for s in sessions], + }) + except Exception as e: + logger.exception("获取对话列表失败") + return jsonify({'success': False, 'error': '获取对话列表失败'}), 500 + + +@bp.route('/chat', methods=['POST']) +def create_chat(): + """创建新对话(支持绑定题目或独立对话)""" + try: + data = request.get_json(silent=True) or {} + question_id = data.get('question_id') # 可选 + title = data.get('title', '新对话') + user_id = session.get('user_id') + + if question_id: + with SessionLocal() as db: + uid = _effective_user_id() + q_query = db.query(Question).filter(Question.id == question_id) + if uid is not None: + q_query = q_query.filter(Question.user_id == uid) + question = q_query.first() + if not question: + return jsonify({'success': False, 'error': '题目不存在'}), 404 + chat_session = crud.create_chat_session(db, question_id=question_id, user_id=user_id, title=title) + return jsonify({'success': True, 'session': _serialize_chat_session(chat_session)}) + else: + # 独立对话 + with SessionLocal() as db: + chat_session = crud.create_chat_session(db, user_id=user_id, title=title) + return jsonify({'success': True, 'session': _serialize_chat_session(chat_session)}) + + except Exception as e: + logger.exception("创建对话失败") + return jsonify({'success': False, 'error': '创建对话失败'}), 500 + + +@bp.route('/chat/my-sessions', methods=['GET']) +def get_my_chat_sessions(): + """获取当前用户的独立对话列表""" + try: + user_id = session.get('user_id') + page = max(1, request.args.get('page', 1, type=int)) + page_size = min(50, max(1, request.args.get('limit', 20, type=int))) + + with SessionLocal() as db: + sessions_list, total = crud.get_user_chat_sessions(db, user_id, page=page, page_size=page_size) + return jsonify({ + 'success': True, + 'sessions': [_serialize_chat_session(s) for s in sessions_list], + 'total': total, + }) + except Exception as e: + logger.exception("获取对话列表失败") + return jsonify({'success': False, 'error': '获取对话列表失败'}), 500 + + +@bp.route('/chat/', methods=['PATCH']) +def update_chat_title(session_id): + """更新对话标题""" + try: + data = request.get_json(silent=True) or {} + title = data.get('title', '').strip() + if not title: + return jsonify({'success': False, 'error': '标题不能为空'}), 400 + + with SessionLocal() as db: + s = crud.update_chat_session_title(db, session_id, title, user_id=_effective_user_id()) + if not s: + return jsonify({'success': False, 'error': '对话不存在'}), 404 + return jsonify({'success': True, 'session': _serialize_chat_session(s)}) + except Exception as e: + logger.exception("更新对话标题失败") + return jsonify({'success': False, 'error': '更新失败'}), 500 + + +@bp.route('/chat/', methods=['DELETE']) +def delete_chat(session_id): + """删除对话""" + try: + with SessionLocal() as db: + if not crud.delete_chat_session(db, session_id, user_id=_effective_user_id()): + return jsonify({'success': False, 'error': '对话不存在'}), 404 + return jsonify({'success': True}) + except Exception as e: + logger.exception("删除对话失败") + return jsonify({'success': False, 'error': '删除失败'}), 500 + + +@bp.route('/chat/sessions', methods=['GET']) +def get_chat_sessions(): + """分页获取所有对话会话""" + try: + page = max(1, request.args.get('page', 1, type=int)) + page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) + + with SessionLocal() as db: + sessions, total = crud.get_all_chat_sessions(db, page=page, page_size=page_size, user_id=_effective_user_id()) + total_pages = (total + page_size - 1) // page_size + + return jsonify({ + 'success': True, + 'sessions': [_serialize_chat_session(s) for s in sessions], + 'total': total, + 'page': page, + 'total_pages': total_pages, + }) + + except Exception as e: + logger.exception("获取对话会话列表失败") + return jsonify({'success': False, 'error': '获取对话会话列表失败'}), 500 + + +@bp.route('/chat//messages', methods=['GET']) +def get_chat_messages(session_id): + """游标分页获取对话消息""" + try: + limit = min(100, max(1, request.args.get('limit', 30, type=int))) + before_id = request.args.get('before_id', type=int) + + with SessionLocal() as db: + result = crud.get_chat_messages(db, session_id, limit=limit, before_id=before_id, user_id=_effective_user_id()) + return jsonify({ + 'success': True, + 'messages': result['messages'], + 'hasMore': result['hasMore'], + }) + + except Exception as e: + logger.exception("获取对话消息失败") + return jsonify({'success': False, 'error': '获取对话消息失败'}), 500 + + +@bp.route('/chat//stream', methods=['POST']) +def stream_chat(session_id): + """SSE 流式对话""" + from agents.teach import stream_teach + from sqlalchemy.orm import selectinload + + try: + data = request.get_json(silent=True) or {} + message = data.get('message', '').strip() + model_provider = data.get('model_provider', 'openai') + model_name = data.get('model_name') or None + deep_think = data.get('deep_think', False) + + # 深度思考模式:切换到 reasoner 模型 + if deep_think and model_provider == 'openai': + model_name = 'deepseek-reasoner' + + if not message: + return jsonify({'success': False, 'error': '消息不能为空'}), 400 + + if not settings.is_valid_provider(model_provider): + return jsonify({'success': False, 'error': f'不支持的模型供应商: {model_provider}'}), 400 + + # 从数据库加载用户的 LLM 凭据 + user_id = session.get('user_id') + if user_id: + settings.load_providers_from_db(user_id) + + with SessionLocal() as db: + uid = _effective_user_id() + cs_query = db.query(ChatSessionModel).options( + selectinload(ChatSessionModel.question) + .selectinload(Question.batch), + selectinload(ChatSessionModel.question) + .selectinload(Question.tags) + .selectinload(QuestionTagMapping.tag), + ).filter(ChatSessionModel.id == session_id) + if uid is not None: + cs_query = cs_query.filter(ChatSessionModel.user_id == uid) + chat_session = cs_query.first() + if not chat_session: + return jsonify({'success': False, 'error': '对话不存在'}), 404 + + # 独立对话或题目绑定对话 + question = chat_session.question + q_data = _serialize_question(question) if question else None + + # 加载历史消息(最近 20 条) + history_result = crud.get_chat_messages(db, session_id, limit=20) + history = [{"role": m["role"], "content": m["content"]} for m in history_result["messages"]] + + # 追加用户消息 + history.append({"role": "user", "content": message}) + crud.add_chat_message(db, session_id, "user", message) + + def generate(): + full_response = [] + full_reasoning = [] + try: + for chunk in stream_teach( + question=q_data, + messages=history, + provider=model_provider, + model_name=model_name, + ): + # stream_teach 现在 yield dict: {"type": "reasoning"|"content", "content": "..."} + if isinstance(chunk, dict): + if chunk["type"] == "reasoning": + full_reasoning.append(chunk["content"]) + yield f"data: {json.dumps({'reasoning': chunk['content']}, ensure_ascii=False)}\n\n" + else: + full_response.append(chunk["content"]) + yield f"data: {json.dumps({'token': chunk['content']}, ensure_ascii=False)}\n\n" + else: + # 兼容旧格式(纯字符串) + full_response.append(chunk) + yield f"data: {json.dumps({'token': chunk}, ensure_ascii=False)}\n\n" + except Exception as e: + logger.exception("流式对话错误") + yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n" + + # 保存完整的 assistant 回复(思考过程不保存到消息历史) + assistant_content = "".join(full_response) + if assistant_content: + try: + with SessionLocal() as db: + crud.add_chat_message(db, session_id, "assistant", assistant_content) + except Exception as e: + logger.error(f"保存 assistant 回复失败: {e}") + + yield f"data: {json.dumps({'done': True})}\n\n" + + resp = Response(generate(), mimetype='text/event-stream') + resp.headers['Cache-Control'] = 'no-cache' + resp.headers['X-Accel-Buffering'] = 'no' + return resp + + except Exception as e: + logger.exception("流式对话失败") + return jsonify({'success': False, 'error': '对话失败,请稍后重试'}), 500 diff --git a/backend/routes/notes.py b/backend/routes/notes.py new file mode 100644 index 00000000..cb16d88d --- /dev/null +++ b/backend/routes/notes.py @@ -0,0 +1,250 @@ +""" +笔记模块 API 路由 + +流程:上传笔记图片 → PaddleOCR 识别 → LLM 整理 → 保存到数据库 +""" + +import os +import uuid +import json +import logging +from pathlib import PurePath + +from flask import Blueprint, request, jsonify, session + +from core.config import settings +from db import SessionLocal +from db import crud + +logger = logging.getLogger(__name__) + +bp = Blueprint('notes', __name__) + + +def _effective_user_id(): + """管理员返回 None(不过滤),普通用户返回 user_id""" + if session.get('is_admin'): + return None + return session.get('user_id') + + +def _allowed_image(filename): + """检查是否为允许的图片格式""" + return PurePath(filename).suffix.lower().lstrip('.') in {'png', 'jpg', 'jpeg', 'bmp', 'tiff', 'webp'} + + +def _serialize_note(note) -> dict: + """将 Note ORM 对象序列化为前端 JSON""" + knowledge_tags = [] + if note.tags: + for mapping in note.tags: + if mapping.tag: + knowledge_tags.append(mapping.tag.tag_name) + + return { + 'id': note.id, + 'title': note.title, + 'subject': note.subject, + 'content_markdown': note.content_markdown, + 'source_images': json.loads(note.source_images_json) if note.source_images_json else [], + 'knowledge_tags': knowledge_tags, + 'created_at': note.created_at.isoformat() if note.created_at else None, + 'updated_at': note.updated_at.isoformat() if note.updated_at else None, + } + + +@bp.route('/', methods=['POST']) +def create_note(): + """上传笔记图片 → OCR → LLM 整理 → 保存 + + 请求:multipart/form-data + - files: 一张或多张图片 + - model_provider: 模型供应商(可选,默认 openai) + - model_name: 模型名称(可选) + """ + files = request.files.getlist('files') + if not files or all(f.filename == '' for f in files): + return jsonify({'success': False, 'error': '请上传笔记图片'}), 400 + + # 校验文件格式 + for f in files: + if f.filename and not _allowed_image(f.filename): + return jsonify({'success': False, 'error': f'不支持的图片格式: {f.filename}'}), 400 + + user_id = session.get('user_id') + model_provider = request.form.get('model_provider', 'openai') + model_name = request.form.get('model_name') or None + + try: + # 1. 保存上传图片 + saved_paths = [] + for f in files: + if not f.filename: + continue + ext = os.path.splitext(f.filename)[1].lower() + filename = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(str(settings.upload_dir), filename) + f.save(filepath) + saved_paths.append(filepath) + + if not saved_paths: + return jsonify({'success': False, 'error': '没有有效的图片文件'}), 400 + + # 2. OCR 识别 + from src.paddleocr_client import PaddleOCRClient + from src.utils import simplify_ocr_results, run_async + + # 从数据库加载用户 OCR 凭据 + ocr_kwargs = {} + if user_id: + with SessionLocal() as db: + ocr_provider = crud.get_active_provider(db, user_id, 'paddleocr') + if ocr_provider: + ocr_kwargs = { + "api_url": ocr_provider.base_url, + "token": ocr_provider.api_key, + "model": ocr_provider.model_name, + } + + client = PaddleOCRClient(**ocr_kwargs) + raw_results = run_async(client.parse_images_async(saved_paths)) + simplified = simplify_ocr_results(raw_results) + + # 拼接 OCR 文本 + 图片引用 + ocr_text_parts = [] + image_refs = [] + for page in simplified: + for block in page.get('blocks', []): + label = block.get('block_label', '') + content = block.get('block_content', '').strip() + if label in ('image', 'chart') and content: + # 图片块:记录路径,在文本中插入占位符 + image_refs.append(content) + ocr_text_parts.append(f'[图片: {content}]') + elif content: + ocr_text_parts.append(content) + ocr_text = '\n'.join(ocr_text_parts) + + if not ocr_text.strip(): + return jsonify({'success': False, 'error': 'OCR 未识别到任何文字,请检查图片'}), 400 + + # 3. LLM 整理 + if user_id: + settings.load_providers_from_db(user_id) + + from agents.note import invoke_note_organize + result = invoke_note_organize(ocr_text, provider=model_provider, model_name=model_name) + + # 4. 保存到数据库 + with SessionLocal() as db: + note = crud.save_note( + db, + title=result.title, + subject=result.subject, + content_markdown=result.content_markdown, + source_images=saved_paths, + ocr_text=ocr_text, + knowledge_tags=result.knowledge_tags, + user_id=user_id, + ) + return jsonify({ + 'success': True, + 'note': _serialize_note(note), + }), 201 + + except Exception as e: + logger.exception("笔记创建失败") + return jsonify({'success': False, 'error': f'笔记创建失败: {str(e)}'}), 500 + + +@bp.route('/', methods=['GET']) +def list_notes(): + """分页查询笔记列表 + + 查询参数:page, limit, subject, knowledge_tag, keyword + """ + try: + page = max(1, request.args.get('page', 1, type=int)) + limit = min(100, max(1, request.args.get('limit', 20, type=int))) + subject = request.args.get('subject') or None + knowledge_tag = request.args.get('knowledge_tag') or None + keyword = request.args.get('keyword') or None + user_id = session.get('user_id') + + with SessionLocal() as db: + notes, total = crud.get_notes( + db, + user_id=user_id, + subject=subject, + knowledge_tag=knowledge_tag, + keyword=keyword, + page=page, + page_size=limit, + ) + return jsonify({ + 'success': True, + 'items': [_serialize_note(n) for n in notes], + 'total': total, + 'page': page, + 'total_pages': (total + limit - 1) // limit, + }) + + except Exception as e: + logger.exception("获取笔记列表失败") + return jsonify({'success': False, 'error': '获取笔记列表失败'}), 500 + + +@bp.route('/', methods=['GET']) +def get_note(note_id): + """获取单条笔记详情""" + try: + with SessionLocal() as db: + note = crud.get_note_by_id(db, note_id, user_id=_effective_user_id()) + if not note: + return jsonify({'success': False, 'error': '笔记不存在'}), 404 + return jsonify({ + 'success': True, + 'note': _serialize_note(note), + }) + except Exception as e: + logger.exception("获取笔记详情失败") + return jsonify({'success': False, 'error': '获取笔记详情失败'}), 500 + + +@bp.route('/', methods=['PATCH']) +def update_note_route(note_id): + """更新笔记(标题、内容、科目、标签)""" + try: + data = request.get_json(silent=True) or {} + with SessionLocal() as db: + note = crud.update_note( + db, + note_id=note_id, + title=data.get('title'), + content_markdown=data.get('content_markdown'), + subject=data.get('subject'), + knowledge_tags=data.get('knowledge_tags'), + user_id=_effective_user_id(), + ) + if not note: + return jsonify({'success': False, 'error': '笔记不存在'}), 404 + return jsonify({ + 'success': True, + 'note': _serialize_note(note), + }) + except Exception as e: + logger.exception("更新笔记失败") + return jsonify({'success': False, 'error': '更新笔记失败'}), 500 + + +@bp.route('/', methods=['DELETE']) +def delete_note_route(note_id): + """删除笔记""" + try: + with SessionLocal() as db: + if not crud.delete_note(db, note_id, user_id=_effective_user_id()): + return jsonify({'success': False, 'error': '笔记不存在'}), 404 + return jsonify({'success': True}) + except Exception as e: + logger.exception("删除笔记失败") + return jsonify({'success': False, 'error': '删除笔记失败'}), 500 diff --git a/backend/routes/questions.py b/backend/routes/questions.py new file mode 100644 index 00000000..2f0e8ece --- /dev/null +++ b/backend/routes/questions.py @@ -0,0 +1,660 @@ +import os +import json +import logging +from datetime import datetime + +from flask import Blueprint, request, jsonify, session + +from db import SessionLocal +from db import crud +from db.models import Question, UploadBatch, KnowledgeTag +from core.config import settings +from src.utils import export_wrongbook as export_wrongbook_md + +logger = logging.getLogger(__name__) + +bp = Blueprint('questions', __name__) + + +def _effective_user_id(): + """管理员返回 None(不过滤),普通用户返回 user_id""" + if session.get('is_admin'): + return None + return session.get('user_id') + + +def _serialize_question(q: Question) -> dict: + """将 Question ORM 对象序列化为前端 JSON 格式""" + subject = None + if q.batch: + subject = q.batch.subject + knowledge_tags = [] + if q.tags: + for mapping in q.tags: + if mapping.tag: + knowledge_tags.append(mapping.tag.tag_name) + + return { + 'id': q.id, + 'question_type': q.question_type, + 'content_json': json.loads(q.content_json) if q.content_json else [], + 'options_json': json.loads(q.options_json) if q.options_json else None, + 'has_formula': q.has_formula, + 'has_image': q.has_image, + 'needs_correction': q.needs_correction, + 'answer': q.answer, + 'subject': subject, + 'knowledge_tags': knowledge_tags, + 'created_at': q.created_at.isoformat() if q.created_at else None, + } + + +def _serialize_question_detail(q: Question) -> dict: + """将 Question ORM 对象序列化为详情 JSON(含科目、标签、答案等)""" + base = _serialize_question(q) + # subject / knowledge_tags already set by _serialize_question + base['original_filename'] = q.batch.original_filename if q.batch else None + base['user_answer'] = q.user_answer + base['updated_at'] = q.updated_at.isoformat() if q.updated_at else None + base['review_status'] = q.review_status or '待复习' + base['image_refs_json'] = json.loads(q.image_refs_json) if q.image_refs_json else None + return base + + +def _read_split_subject(): + """从 split_metadata.json 读取学科信息""" + meta_path = os.path.join(str(settings.results_dir), "split_metadata.json") + if os.path.exists(meta_path): + with open(meta_path, 'r', encoding='utf-8') as f: + return json.load(f).get("subject") + return None + + +@bp.route('/questions', methods=['GET']) +def get_questions(): + """ + 获取已分割的题目列表 + + Returns: + JSON响应,包含题目列表 + """ + try: + results_dir = settings.results_dir + questions_file = os.path.join(results_dir, "questions.json") + + if not os.path.exists(questions_file): + return jsonify({ + 'success': True, + 'questions': [], + 'message': '暂无题目数据' + }) + + with open(questions_file, 'r', encoding='utf-8') as f: + questions = json.load(f) + + return jsonify({ + 'success': True, + 'questions': questions + }) + + except json.JSONDecodeError: + return jsonify({ + 'success': False, + 'error': '题目数据文件格式错误,请重新分割题目' + }), 500 + except Exception as e: + logger.exception("获取题目列表失败") + return jsonify({ + 'success': False, + 'error': '获取题目列表失败,请稍后重试' + }), 500 + + +@bp.route('/history', methods=['GET']) +def get_history(): + """ + 分页查询历史题目(全部题目,非仅错题) + + Query参数: + page: 页码(默认1) + page_size: 每页数量(默认20) + start_date: 开始日期(可选,格式:YYYY-MM-DD) + end_date: 结束日期(可选,格式:YYYY-MM-DD) + """ + try: + page = max(1, request.args.get('page', 1, type=int)) + page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + + # 解析日期 + start_date = None + end_date = None + if start_date_str: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + if end_date_str: + end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + + with SessionLocal() as db: + questions, total = crud.get_history_questions( + db, + start_date=start_date, + end_date=end_date, + page=page, + page_size=page_size, + user_id=_effective_user_id(), + ) + + total_pages = (total + page_size - 1) // page_size + items = [_serialize_question(q) for q in questions] + + return jsonify({ + 'success': True, + 'items': items, + 'total': total, + 'page': page, + 'page_size': page_size, + 'total_pages': total_pages + }) + + except ValueError: + return jsonify({'success': False, 'error': '日期格式错误,请使用 YYYY-MM-DD 格式'}), 400 + except Exception as e: + logger.exception("查询历史题目失败") + return jsonify({'success': False, 'error': '查询失败,请稍后重试'}), 500 + + +@bp.route('/search', methods=['GET']) +def search_questions(): + """ + 搜索题目(知识点/题型/关键字) + + Query参数: + keyword: 关键字搜索(匹配题目内容) + knowledge_tag: 知识点标签筛选 + question_type: 题型筛选 + page: 页码(默认1) + page_size: 每页数量(默认20) + """ + try: + page = max(1, request.args.get('page', 1, type=int)) + page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) + keyword = request.args.get('keyword', type=str) + knowledge_tag = request.args.get('knowledge_tag', type=str) + question_type = request.args.get('question_type', type=str) + + # 至少需要一个搜索条件 + if not any([keyword, knowledge_tag, question_type]): + return jsonify({ + 'success': False, + 'error': '至少需要提供一个搜索条件(keyword/knowledge_tag/question_type)' + }), 400 + + with SessionLocal() as db: + questions, total = crud.search_questions( + db, + keyword=keyword if keyword else None, + knowledge_tag=knowledge_tag if knowledge_tag else None, + question_type=question_type if question_type else None, + page=page, + page_size=page_size, + user_id=_effective_user_id(), + ) + + total_pages = (total + page_size - 1) // page_size + items = [_serialize_question(q) for q in questions] + + return jsonify({ + 'success': True, + 'items': items, + 'total': total, + 'page': page, + 'page_size': page_size, + 'total_pages': total_pages + }) + + except Exception as e: + logger.exception("搜索题目失败") + return jsonify({'success': False, 'error': '搜索失败,请稍后重试'}), 500 + + +@bp.route('/question/', methods=['DELETE']) +def delete_question(question_id): + """ + 删除题目 + + Path参数: + question_id: 题目ID + """ + try: + with SessionLocal() as db: + success = crud.delete_question(db, question_id, user_id=_effective_user_id()) + if success: + return jsonify({ + 'success': True, + 'message': '题目已删除' + }) + else: + return jsonify({ + 'success': False, + 'error': '题目不存在' + }), 404 + + except Exception as e: + logger.exception("删除题目失败") + return jsonify({'success': False, 'error': '删除失败,请稍后重试'}), 500 + + +@bp.route('/error-bank', methods=['GET']) +def get_error_bank(): + """ + 错题库统一查询 + + Query参数: + subject: 科目筛选 + knowledge_tag: 知识点标签筛选 + question_type: 题型筛选 + keyword: 关键字搜索 + start_date: 开始日期(YYYY-MM-DD) + end_date: 结束日期(YYYY-MM-DD) + page: 页码(默认1) + page_size: 每页数量(默认20) + """ + try: + page = max(1, request.args.get('page', 1, type=int)) + page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) + subject = request.args.get('subject', type=str) or None + knowledge_tag = request.args.get('knowledge_tag', type=str) or None + question_type = request.args.get('question_type', type=str) or None + keyword = request.args.get('keyword', type=str) or None + review_status = request.args.get('review_status', type=str) or None + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + + start_date = None + end_date = None + if start_date_str: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + if end_date_str: + end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + + with SessionLocal() as db: + questions, total, grand_total = crud.query_questions( + db, + user_id=_effective_user_id(), + subject=subject, + knowledge_tag=knowledge_tag, + question_type=question_type, + keyword=keyword, + start_date=start_date, + end_date=end_date, + review_status=review_status, + page=page, + page_size=page_size, + ) + + total_pages = (total + page_size - 1) // page_size + items = [_serialize_question_detail(q) for q in questions] + + return jsonify({ + 'success': True, + 'items': items, + 'total': total, + 'grand_total': grand_total, + 'page': page, + 'page_size': page_size, + 'total_pages': total_pages, + }) + + except ValueError: + return jsonify({'success': False, 'error': '日期格式错误,请使用 YYYY-MM-DD 格式'}), 400 + except Exception as e: + logger.exception("查询错题库失败") + return jsonify({'success': False, 'error': '查询失败,请稍后重试'}), 500 + + +@bp.route('/subjects', methods=['GET']) +def get_subjects(): + """获取所有科目列表""" + try: + with SessionLocal() as db: + subjects = crud.get_existing_subjects(db, user_id=_effective_user_id()) + return jsonify({'success': True, 'subjects': subjects}) + except Exception as e: + logger.exception("获取科目列表失败") + return jsonify({'success': False, 'error': '获取科目列表失败'}), 500 + + +@bp.route('/question-types', methods=['GET']) +def get_question_types(): + """获取所有题型列表""" + try: + with SessionLocal() as db: + types = crud.get_existing_question_types(db, user_id=_effective_user_id()) + return jsonify({'success': True, 'question_types': types}) + except Exception as e: + logger.exception("获取题型列表失败") + return jsonify({'success': False, 'error': '获取题型列表失败'}), 500 + + +@bp.route('/question/', methods=['PATCH']) +def update_question(question_id): + """编辑题目内容和/或答案""" + try: + data = request.get_json(silent=True) or {} + with SessionLocal() as db: + question = db.get(Question, question_id) + if not question: + return jsonify({'success': False, 'error': '题目不存在'}), 404 + uid = _effective_user_id() + if uid is not None and question.user_id != uid: + return jsonify({'success': False, 'error': '题目不存在'}), 404 + try: + if 'content' in data: + text = str(data['content'])[:20000] + blocks = [{'block_type': 'text', 'content': text}] + question.content_json = json.dumps(blocks, ensure_ascii=False) + question.content_hash = crud.compute_content_hash(blocks) + if 'answer' in data: + question.answer = str(data['answer'])[:10000] if data['answer'] else None + question.updated_at = datetime.utcnow() + db.commit() + return jsonify({'success': True}) + except Exception: + db.rollback() + raise + except Exception: + logger.exception("编辑题目失败") + return jsonify({'success': False, 'error': '保存失败,请稍后重试'}), 500 + + +@bp.route('/question//answer', methods=['PATCH']) +def update_question_answer(question_id): + """保存用户答案""" + try: + data = request.get_json(silent=True) or {} + user_answer = data.get('user_answer') + if user_answer is None: + return jsonify({'success': False, 'error': '缺少 user_answer 字段'}), 400 + if len(user_answer) > 10000: + return jsonify({'success': False, 'error': '答案内容过长(最多 10000 字符)'}), 400 + + with SessionLocal() as db: + question = crud.update_user_answer(db, question_id, user_answer, user_id=_effective_user_id()) + if not question: + return jsonify({'success': False, 'error': '题目不存在'}), 404 + + return jsonify({ + 'success': True, + 'message': '答案已保存', + 'user_answer': question.user_answer, + 'updated_at': question.updated_at.isoformat() if question.updated_at else None, + }) + + except Exception as e: + logger.exception("保存答案失败") + return jsonify({'success': False, 'error': '保存答案失败,请稍后重试'}), 500 + + +@bp.route('/question//review-status', methods=['PATCH']) +def update_question_review_status(question_id): + """更新题目复习状态""" + try: + data = request.get_json(silent=True) or {} + review_status = data.get('review_status') + if not review_status: + return jsonify({'success': False, 'error': '缺少 review_status 字段'}), 400 + + with SessionLocal() as db: + question = crud.update_review_status(db, question_id, review_status, user_id=_effective_user_id()) + if not question: + return jsonify({'success': False, 'error': '题目不存在'}), 404 + + return jsonify({ + 'success': True, + 'message': '复习状态已更新', + 'review_status': question.review_status, + 'updated_at': question.updated_at.isoformat() if question.updated_at else None, + }) + + except ValueError: + return jsonify({'success': False, 'error': f'无效的复习状态,可选值: {", ".join(crud.VALID_REVIEW_STATUSES)}'}), 400 + except Exception as e: + logger.exception("更新复习状态失败") + return jsonify({'success': False, 'error': '更新复习状态失败,请稍后重试'}), 500 + + +@bp.route('/save-to-db', methods=['POST']) +def save_to_db(): + """ + 将分割好的题目导入错题库(仅入库,不导出 Markdown) + + Request Body: + { + "selected_ids": ["q_0", "q_1", ...] # 选中的题目 ID 列表 + } + """ + try: + data = request.get_json(silent=True) or {} + selected_uids = data.get('selected_ids', []) + + if not isinstance(selected_uids, list) or not selected_uids: + return jsonify({'success': False, 'error': '请选择至少一道题目'}), 400 + + results_dir = settings.results_dir + questions_file = os.path.join(results_dir, "questions.json") + if not os.path.exists(questions_file): + return jsonify({'success': False, 'error': '请先分割题目'}), 400 + + with open(questions_file, 'r', encoding='utf-8') as f: + questions = json.load(f) + + uid_set = set(selected_uids) + selected_questions = [q for q in questions if q.get('uid') in uid_set] + if not selected_questions: + return jsonify({'success': False, 'error': '未找到选中的题目'}), 400 + + # 合并前端传来的 answer/user_answer(按 uid 匹配) + answers_map = {a['uid']: a for a in data.get('answers', []) if 'uid' in a} + for sq in selected_questions: + uid = sq.get('uid') + if uid and uid in answers_map: + if 'answer' in answers_map[uid]: + sq['answer'] = answers_map[uid]['answer'] + if 'user_answer' in answers_map[uid]: + sq['user_answer'] = answers_map[uid]['user_answer'] + + # 读取科目信息 + subject = _read_split_subject() + + from core.state import session_lock, get_user_session + uid = session.get('user_id') + with session_lock: + us = get_user_session(uid) + batch_info = { + "original_filename": ", ".join( + us["session_files"].get(k, {}).get("filename", "未知") + for k in us["session_file_order"] + ), + "subject": subject, + "file_path": "", + } + + with SessionLocal() as db: + result = crud.save_questions_to_db(db, selected_questions, batch_info, user_id=session.get('user_id')) + + return jsonify({ + 'success': True, + 'message': f'已导入 {result["created"]} 道题目(跳过 {result["duplicates"]} 道重复)', + 'created': result['created'], + 'duplicates': result['duplicates'], + }) + + except Exception as e: + logger.exception("导入错题库失败") + return jsonify({'success': False, 'error': '导入错题库失败,请稍后重试'}), 500 + + +@bp.route('/ai-analysis', methods=['POST']) +def ai_analysis(): + """ + AI 错题分析(骨架路由) + + 接收一组题目 ID,调用 AI 对这些错题进行综合分析,返回: + - 薄弱知识点诊断 + - 错因归纳 + - 针对性复习建议 + + Request Body: + { + "question_ids": [1, 2, 3] # 必填,至少 1 道,最多 20 道 + } + + Response: + { + "success": true, + "analysis": { + "summary": "综合诊断摘要", + "weak_points": ["知识点A", "知识点B"], + "error_patterns": [ + {"pattern": "错因类型", "description": "详细描述", "question_ids": [1, 2]} + ], + "suggestions": ["建议1", "建议2"], + "per_question": [ + {"question_id": 1, "diagnosis": "该题错因分析", "hint": "解题提示"} + ] + } + } + + TODO(后端开发者): + 1. 从数据库查询题目详情(content_json, options_json, user_answer, knowledge_tags) + 2. 构建 prompt,将题目内容 + 用户答案 + 知识点标签传给 LLM + 3. 调用 LLM(建议使用 llm.py 中的 init_model,支持 openai/anthropic) + 4. 解析 LLM 返回的结构化结果,填充 analysis 字段 + 5. 可选:将分析结果缓存到数据库,避免重复分析 + """ + try: + data = request.get_json(silent=True) or {} + question_ids = data.get('question_ids', []) + + if not isinstance(question_ids, list) or not question_ids: + return jsonify({'success': False, 'error': '请选择至少一道题目'}), 400 + + if len(question_ids) > 20: + return jsonify({'success': False, 'error': '单次最多分析 20 道题目'}), 400 + + with SessionLocal() as db: + questions = crud.get_questions_by_ids(db, question_ids, user_id=_effective_user_id()) + if not questions: + return jsonify({'success': False, 'error': '未找到对应题目'}), 404 + + # ── TODO: 替换为真实 AI 分析逻辑 ────────────────── + # 当前返回占位数据,供前端联调使用 + knowledge_tags = set() + per_question = [] + for q in questions: + tags = [m.tag.tag_name for m in (q.tags or []) if m.tag] + knowledge_tags.update(tags) + per_question.append({ + 'question_id': q.id, + 'diagnosis': f'题目 #{q.id} 的错因分析(待 AI 生成)', + 'hint': f'题目 #{q.id} 的解题提示(待 AI 生成)', + }) + + analysis = { + 'summary': f'已选择 {len(questions)} 道题目进行分析(AI 分析功能待接入)', + 'weak_points': list(knowledge_tags) or ['暂无知识点数据'], + 'error_patterns': [ + { + 'pattern': '待 AI 识别', + 'description': '错因模式将由 AI 自动归纳', + 'question_ids': [q.id for q in questions], + } + ], + 'suggestions': [ + '建议回顾相关知识点章节', + '建议针对薄弱环节进行专项练习', + ], + 'per_question': per_question, + } + # ── END TODO ────────────────────────────────────── + + return jsonify({ + 'success': True, + 'analysis': analysis, + }) + + except Exception as e: + logger.exception("AI 分析失败") + return jsonify({'success': False, 'error': 'AI 分析失败,请稍后重试'}), 500 + + +@bp.route('/export-from-db', methods=['POST']) +def export_from_db(): + """从数据库按 ID 列表导出 Markdown 错题本""" + try: + data = request.get_json(silent=True) or {} + selected_ids = data.get('selected_ids', []) + + if not isinstance(selected_ids, list) or not selected_ids: + return jsonify({'success': False, 'error': '请选择至少一道题目'}), 400 + + with SessionLocal() as db: + questions_orm = crud.get_questions_by_ids(db, selected_ids, user_id=_effective_user_id()) + if not questions_orm: + return jsonify({'success': False, 'error': '未找到对应题目'}), 404 + + # 转换为 export_wrongbook_md 所需的 dict 格式 + questions = [] + for q in questions_orm: + questions.append({ + 'question_id': str(q.id), + 'question_type': q.question_type, + 'content_blocks': json.loads(q.content_json) if q.content_json else [], + 'options': json.loads(q.options_json) if q.options_json else None, + 'has_formula': q.has_formula, + 'has_image': q.has_image, + 'knowledge_tags': [m.tag.tag_name for m in (q.tags or []) if m.tag], + }) + + # DB 导出的题目已按 selected_ids 过滤完毕,赋顺序 uid 后全部导出 + for i, q in enumerate(questions): + q['uid'] = str(i) + output_path = export_wrongbook_md(questions, [str(i) for i in range(len(questions))]) + + return jsonify({ + 'success': True, + 'message': '错题本导出成功', + 'output_path': output_path, + }) + + except Exception as e: + logger.exception("从数据库导出失败") + return jsonify({'success': False, 'error': '导出失败,请稍后重试'}), 500 + + +@bp.route('/question//answer', methods=['PUT']) +def save_question_answer(question_id): + """保存题目答案(Markdown 格式,用于 AI 辅导)""" + try: + data = request.get_json(silent=True) or {} + answer = data.get('answer') + if answer is None: + return jsonify({'success': False, 'error': '缺少 answer 字段'}), 400 + if len(answer) > 50000: + return jsonify({'success': False, 'error': '答案内容过长(最多 50000 字符)'}), 400 + + with SessionLocal() as db: + question = crud.update_question_answer(db, question_id, answer, user_id=_effective_user_id()) + if not question: + return jsonify({'success': False, 'error': '题目不存在'}), 404 + + return jsonify({ + 'success': True, + 'message': '答案已保存', + 'answer': question.answer, + }) + + except Exception as e: + logger.exception("保存答案失败") + return jsonify({'success': False, 'error': '保存答案失败,请稍后重试'}), 500 diff --git a/backend/routes/settings.py b/backend/routes/settings.py new file mode 100644 index 00000000..163a9efd --- /dev/null +++ b/backend/routes/settings.py @@ -0,0 +1,310 @@ +import os +import json +import uuid +import logging + +import requests as http_requests +from flask import Blueprint, request, jsonify, session + +from core.config import settings +from db import SessionLocal +from db import crud +from db.models import ProviderConfig + +logger = logging.getLogger(__name__) + +bp = Blueprint('settings', __name__) + + +@bp.route('/models/erase', methods=['POST']) +def erase_text(): + """文字擦除接口 + + 使用 GAN 模型将试卷图片中的手写笔迹擦除,还原干净的题目底图。 + + Request: + multipart/form-data,字段 file:图片文件(JPEG/PNG/BMP 等) + + Response: + {"success": true, "result_url": "/erased/"} + """ + if 'file' not in request.files: + return jsonify({'success': False, 'error': '缺少 file 字段'}), 400 + + file = request.files['file'] + if not file or not file.filename: + return jsonify({'success': False, 'error': '文件为空'}), 400 + + ext = os.path.splitext(file.filename)[1].lower() or '.png' + allowed = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'} + if ext not in allowed: + return jsonify({'success': False, 'error': f'不支持的图片格式: {ext}'}), 400 + + try: + image_bytes = file.read() + except Exception: + return jsonify({'success': False, 'error': '读取文件失败'}), 400 + + try: + from models.inference import InferenceEngine + + result_img = InferenceEngine().run(image_bytes) + + filename = uuid.uuid4().hex + '.png' + save_path = settings.erased_dir / filename + result_img.save(save_path, format='PNG') + + return jsonify({'success': True, 'result_url': f'/erased/{filename}'}) + + except FileNotFoundError as e: + logger.error("模型权重缺失: %s", e) + return jsonify({'success': False, 'error': str(e)}), 503 + except Exception: + logger.exception("文字擦除失败") + return jsonify({'success': False, 'error': '擦除处理失败,请稍后重试'}), 500 + + +@bp.route('/status', methods=['GET']) +def get_status(): + """ + 获取系统状态 + + Returns: + JSON响应,包含系统配置和状态 + """ + try: + user_id = session.get('user_id') + + # 检查 EnsExam 模型权重是否存在 + ensexam_configured = settings.model_path.exists() + + if user_id: + # 已登录:从数据库读取用户配置 + with SessionLocal() as db: + paddle_provider = crud.get_active_provider(db, user_id, 'paddleocr') + paddleocr_configured = bool(paddle_provider and paddle_provider.api_key and paddle_provider.base_url) + + # 构建用户级可用模型列表 + available_models = [] + for category, label in [('openai', 'OpenAI'), ('anthropic', 'Anthropic')]: + provider = crud.get_active_provider(db, user_id, category) + if provider and provider.api_key: + available_models.append({ + 'value': category, + 'label': provider.name or label, + 'configured': True, + 'status': '配置成功', + 'default_model': provider.model_name or '', + 'models': [provider.model_name] if provider.model_name else [], + }) + else: + available_models.append({ + 'value': category, + 'label': label, + 'configured': False, + 'status': '未配置', + 'default_model': '', + 'models': [], + }) + else: + # 未登录:返回空状态 + paddleocr_configured = False + available_models = [] + + status = { + 'paddleocr_configured': paddleocr_configured, + 'ensexam_configured': ensexam_configured, + 'available_models': available_models, + 'langsmith_enabled': os.getenv('LANGSMITH_TRACING', 'false').lower() == 'true', + 'output_dirs': { + 'pages': str(settings.pages_dir), + 'struct': str(settings.struct_dir), + 'results': str(settings.results_dir), + } + } + + return jsonify({ + 'success': True, + 'status': status + }) + + except Exception as e: + logger.exception("获取系统状态失败") + return jsonify({ + 'success': False, + 'error': '获取系统状态失败,请稍后重试' + }), 500 + + +@bp.route('/config', methods=['GET']) +def get_config(): + """获取当前用户的 provider 配置""" + try: + user_id = session.get('user_id') + if not user_id: + return jsonify({'success': False, 'error': '请先登录'}), 401 + with SessionLocal() as db: + config = crud.get_user_providers(db, user_id) + return jsonify({'success': True, 'config': config}) + except Exception as e: + logger.exception("获取配置失败") + return jsonify({'success': False, 'error': '获取配置失败'}), 500 + + +@bp.route('/config', methods=['PUT']) +def update_config(): + """保存当前用户的 provider 配置到数据库""" + try: + data = request.get_json(force=True) or {} + user_id = session.get('user_id') + + if not user_id: + return jsonify({'success': False, 'error': '请先登录'}), 401 + + with SessionLocal() as db: + try: + crud.save_user_providers(db, user_id, data) + except Exception: + db.rollback() + raise + return jsonify({'success': True}) + + except Exception as e: + logger.exception("更新配置失败") + return jsonify({'success': False, 'error': '更新配置失败,请检查日志'}), 500 + + +@bp.route('/models/list', methods=['POST']) +def list_models(): + """代理请求目标 API 的模型列表,绕过浏览器 CORS 限制。 + + 请求体: + type: 'openai' | 'anthropic' + api_key: API Key(可选,留空则使用系统已配置的 key) + base_url: Base URL(可选) + provider_id: 已有 provider 的 id(可选,用于回退读取已存 key) + """ + try: + data = request.get_json(force=True) or {} + provider_type = data.get('type', 'openai') + api_key = data.get('api_key') or '' + base_url = data.get('base_url') or '' + + # 如果前端没传 key,从数据库读取已保存的配置 + if not api_key: + user_id = session.get('user_id') + provider_id = data.get('provider_id') + if user_id: + with SessionLocal() as db: + if provider_id: + provider = db.query(ProviderConfig).filter_by( + id=provider_id, user_id=user_id + ).first() + else: + provider = crud.get_active_provider(db, user_id, provider_type) + if provider: + if not api_key: + api_key = provider.api_key or '' + if not base_url: + base_url = provider.base_url or '' + + if not api_key: + return jsonify({'error': '未提供 API Key,请先在设置中配置'}), 400 + + if provider_type == 'openai': + url = (base_url.rstrip('/') if base_url else 'https://api.openai.com') + '/v1/models' + headers = {'Authorization': f'Bearer {api_key}'} + resp = http_requests.get(url, headers=headers, timeout=15) + + # 部分 OpenAI 兼容 API 的路径可能不带 /v1 + if resp.status_code == 404: + url_alt = (base_url.rstrip('/') if base_url else 'https://api.openai.com') + '/models' + resp = http_requests.get(url_alt, headers=headers, timeout=15) + + if resp.status_code != 200: + return jsonify({'error': f'API 返回 {resp.status_code}: {resp.text[:200]}'}), 502 + + body = resp.json() + models = sorted([m['id'] for m in body.get('data', [])]) if 'data' in body else [] + return jsonify({'models': models}) + + elif provider_type == 'anthropic': + # Anthropic 没有官方 list models 接口,返回常用模型列表 + models = [ + 'claude-opus-4-20250514', + 'claude-sonnet-4-20250514', + 'claude-haiku-4-20250506', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229', + ] + return jsonify({'models': models}) + + else: + return jsonify({'error': f'不支持的 provider 类型: {provider_type}'}), 400 + + except http_requests.Timeout: + return jsonify({'error': '请求超时,请检查 Base URL 是否正确'}), 504 + except http_requests.ConnectionError: + return jsonify({'error': '无法连接目标 API,请检查 Base URL'}), 502 + except Exception as e: + logger.exception("获取模型列表失败") + return jsonify({'error': str(e)}), 500 + + +@bp.route('/paddleocr/test', methods=['POST']) +def test_paddleocr(): + """测试 PaddleOCR API Token 是否可用。 + + 请求体: + api_token: API Token(可选,留空则从数据库读取) + api_url: API URL(可选,留空则从数据库读取) + provider_id: 已有 provider 的 id(可选,用于回退读取已存 token) + """ + try: + data = request.get_json(force=True) or {} + api_token = data.get('api_token') or '' + api_url = data.get('api_url') or '' + + # 前端未提供凭据时,从数据库读取已保存的配置 + if not api_token or not api_url: + user_id = session.get('user_id') + provider_id = data.get('provider_id') + if user_id: + with SessionLocal() as db: + if provider_id: + provider = db.query(ProviderConfig).filter_by( + id=provider_id, user_id=user_id + ).first() + else: + provider = crud.get_active_provider(db, user_id, 'paddleocr') + if provider: + if not api_token: + api_token = provider.api_key or '' + if not api_url: + api_url = provider.base_url or '' + + if not api_token or not api_url: + return jsonify({'error': '未提供 API Token 或 API URL'}), 400 + + # 用 GET 请求 API URL,带 token 验证认证是否有效 + headers = {'Authorization': f'bearer {api_token}'} + resp = http_requests.get(api_url, headers=headers, timeout=10) + + # 401/403 表示 token 无效 + if resp.status_code in (401, 403): + return jsonify({'success': False, 'error': '认证失败,请检查 API Token 是否正确'}) + + # 其他非 5xx 状态码都认为 token 有效(包括 404/405 等,因为 GET 可能不被支持但认证通过了) + if resp.status_code >= 500: + return jsonify({'success': False, 'error': f'服务端错误 ({resp.status_code}),请检查 API URL'}) + + return jsonify({'success': True, 'message': 'API Token 验证通过'}) + + except http_requests.Timeout: + return jsonify({'success': False, 'error': '请求超时,请检查 API URL 是否正确'}), 504 + except http_requests.ConnectionError: + return jsonify({'success': False, 'error': '无法连接,请检查 API URL'}), 502 + except Exception as e: + logger.exception("测试 PaddleOCR 连接失败") + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/backend/routes/stats.py b/backend/routes/stats.py new file mode 100644 index 00000000..b74f5f7c --- /dev/null +++ b/backend/routes/stats.py @@ -0,0 +1,79 @@ +import logging + +from flask import Blueprint, request, jsonify, session + +from db import SessionLocal +from db import crud + +logger = logging.getLogger(__name__) + +bp = Blueprint('stats', __name__) + + +@bp.route('/stats', methods=['GET']) +def get_stats(): + """ + 获取知识点统计信息 + """ + try: + subject = request.args.get('subject', type=str) or None + with SessionLocal() as db: + stats = crud.get_knowledge_stats(db, subject=subject, user_id=session.get('user_id')) + return jsonify({ + 'success': True, + 'stats': stats, + 'total_tags': len(stats) + }) + + except Exception as e: + logger.exception("获取统计失败") + return jsonify({'success': False, 'error': '获取统计失败,请稍后重试'}), 500 + + +@bp.route('/dashboard-stats', methods=['GET']) +def get_dashboard_stats(): + """获取 Dashboard 所需的完整统计数据,支持 ?subject= 学科筛选""" + try: + subject = request.args.get('subject') or None + uid = session.get('user_id') + with SessionLocal() as db: + # 可用学科列表(按当前用户隔离) + subjects = crud.get_existing_subjects(db, user_id=uid) + + # 复习状态统计 + review_stats = crud.get_review_status_stats(db, subject=subject, user_id=uid) + + # 总体统计 + statistics = crud.get_statistics(db, subject=subject, user_id=uid) + + # 知识点标签统计 top 10(横向条形图) + tag_stats = crud.get_knowledge_stats(db, subject=subject, limit=10, user_id=uid) + + # 知识点 × 掌握状态(堆叠柱状图) + tag_status_stats = crud.get_tag_status_stats(db, subject=subject, limit=10, user_id=uid) + + # 知识点 × 题型(热力图) + tag_type_stats = crud.get_tag_type_stats(db, subject=subject, tag_limit=8, user_id=uid) + + # 今日掌握数 + today_mastered = crud.get_today_mastered_count(db, subject=subject, user_id=uid) + + # 最近30天每日新增 + 已掌握趋势 + daily_counts = crud.get_daily_counts(db, days=30, subject=subject, user_id=uid) + + return jsonify({ + 'success': True, + 'subjects': subjects, + 'review_stats': review_stats, + 'today_mastered': today_mastered, + 'total_questions': statistics['total_questions'], + 'by_subject': statistics['by_subject'], + 'tag_stats': tag_stats, + 'tag_status_stats': tag_status_stats, + 'tag_type_stats': tag_type_stats, + 'daily_counts': daily_counts, + }) + + except Exception as e: + logger.exception("获取 Dashboard 统计失败") + return jsonify({'success': False, 'error': '获取统计失败,请稍后重试'}), 500 diff --git a/backend/routes/upload.py b/backend/routes/upload.py new file mode 100644 index 00000000..357a94d5 --- /dev/null +++ b/backend/routes/upload.py @@ -0,0 +1,648 @@ +import os +import json +import uuid +import logging +from pathlib import PurePath +from typing import Optional + +from flask import Blueprint, request, jsonify, session + +import core.state as state +from core.state import ( + workflow_graph, + session_lock, + get_user_session, +) +from db import SessionLocal +from db import crud +from core.config import settings +from src.utils import export_wrongbook as export_wrongbook_md + +logger = logging.getLogger(__name__) + +bp = Blueprint('upload', __name__) + + +# --------------------------------------------------------------------------- +# Helper functions +# --------------------------------------------------------------------------- + +def allowed_file(filename): + """检查文件扩展名是否允许""" + return PurePath(filename).suffix.lower().lstrip('.') in settings.allowed_extensions + + +def _read_split_subject() -> Optional[str]: + """从 split_metadata.json 读取学科信息""" + meta_path = os.path.join(str(settings.results_dir), "split_metadata.json") + if os.path.exists(meta_path): + with open(meta_path, 'r', encoding='utf-8') as f: + return json.load(f).get("subject") + return None + + +def _serialize_split_record(r) -> dict: + """将 SplitRecord ORM 对象序列化为前端 JSON 格式""" + return { + "id": r.id, + "subject": r.subject, + "model_provider": r.model_provider, + "file_names": json.loads(r.file_names_json) if r.file_names_json else [], + "question_count": r.question_count, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@bp.route('/upload', methods=['POST']) +def upload_file(): + """处理文件上传(支持多文件)。 + + 这里只负责把原始文件保存到 uploads 并登记到会话中; + 标准化(PDF/图片 → 图片列表) + OCR + 分割,会在 /api/split 里由用户点击"开始分割"后统一触发。 + + Returns: + JSON响应,包含上传结果 + """ + # 支持多文件:前端用 'files' 字段发送,兼容单文件 'file' 字段 + files = request.files.getlist('files') + if not files: + files = request.files.getlist('file') + if not files or all(f.filename == '' for f in files): + return jsonify({'error': '没有上传文件'}), 400 + + # 校验每个文件 + for file in files: + if file.filename == '': + continue + + if not allowed_file(file.filename): + return jsonify({ + 'error': f'不支持的文件格式: {file.filename}。支持: {", ".join(settings.allowed_extensions)}' + }), 400 + + file.seek(0, 2) + file_size = file.tell() + file.seek(0) + file_size_mb = file_size / (1024 * 1024) + + if file_size_mb > settings.max_file_size_mb: + return jsonify({ + 'error': f'{file.filename} 大小为 {file_size_mb:.1f}MB,超出最大限制 {settings.max_file_size_mb}MB' + }), 400 + + if file_size == 0: + return jsonify({ + 'error': f'{file.filename} 为空文件,请重新选择' + }), 400 + + try: + file_keys = request.form.getlist('file_key') + if not file_keys: + file_keys = request.form.getlist('file_keys') + + prepared = [] + for i, file in enumerate(files): + if file.filename == '': + continue + fk = file_keys[i] if i < len(file_keys) and file_keys[i] else None + prepared.append((fk, file)) + + if not prepared: + return jsonify({'error': '没有上传文件'}), 400 + + results_out = [] + uid = session.get('user_id') + for fk, file in prepared: + file_key = fk or f"{uuid.uuid4().hex}" + + original_ext = os.path.splitext(file.filename)[1].lower().lstrip('.') + filename = f"{uuid.uuid4().hex}.{original_ext}" + filepath = os.path.join(str(settings.upload_dir), filename) + file.save(filepath) + + with session_lock: + us = get_user_session(uid) + cancelled = file_key in us["cancelled_file_keys"] + if cancelled: + us["cancelled_file_keys"].discard(file_key) + + if cancelled: + try: + os.remove(filepath) + except FileNotFoundError: + pass + continue + + with session_lock: + us = get_user_session(uid) + if us["current_thread_id"] is not None: + us["current_thread_id"] = None + us["session_files"].clear() + us["session_file_order"].clear() + + us["session_files"][file_key] = { + "filename": file.filename, + "filepath": filepath, + } + if file_key not in us["session_file_order"]: + us["session_file_order"].append(file_key) + + results_out.append({ + "file_key": file_key, + "filename": file.filename, + }) + + return jsonify({ + 'success': True, + 'message': '上传成功', + 'result': { + 'file_count': len(results_out), + 'files': results_out, + } + }) + + except FileNotFoundError: + return jsonify({ + 'success': False, + 'error': '文件保存失败,请重新上传' + }), 500 + except Exception as e: + logger.exception("文件处理失败") + return jsonify({ + 'success': False, + 'error': '文件处理失败,请稍后重试' + }), 500 + + +@bp.route('/cancel_file', methods=['POST']) +def cancel_file(): + try: + data = request.get_json(silent=True) or {} + file_key = data.get('file_key') + if not file_key: + return jsonify({ + 'success': False, + 'error': '缺少 file_key' + }), 400 + + uid = session.get('user_id') + with session_lock: + us = get_user_session(uid) + if us["current_thread_id"] is not None: + return jsonify({ + 'success': False, + 'error': '已开始分割,无法撤销单个文件;请重置后重新上传' + }), 400 + + filepath = None + existed = False + with session_lock: + us = get_user_session(uid) + us["cancelled_file_keys"].add(file_key) + + existed = file_key in us["session_files"] + v = us["session_files"].pop(file_key, None) or {} + filepath = v.get('filepath') + if file_key in us["session_file_order"]: + us["session_file_order"].remove(file_key) + + us["cancelled_file_keys"].discard(file_key) + + if filepath: + try: + os.remove(filepath) + except FileNotFoundError: + pass + + return jsonify({ + 'success': True, + 'message': '已撤销该文件' if existed else '已标记撤销该文件', + }) + + except Exception as e: + logger.exception("撤销文件失败") + return jsonify({ + 'success': False, + 'error': '撤销失败,请稍后重试' + }), 500 + + +@bp.route('/erase', methods=['POST']) +def run_erase(): + """擦除手写笔迹,返回擦除后的图片 URL。 + + 擦除后的文件路径保存在会话状态中,供后续 /api/ocr 使用。 + """ + try: + uid = session.get('user_id') + with session_lock: + us = get_user_session(uid) + keys = list(us["session_file_order"]) + file_paths = [] + for k in keys: + v = us["session_files"].get(k) or {} + fp = v.get('filepath') + if fp: + file_paths.append(fp) + + if not file_paths: + return jsonify({'success': False, 'error': '请先上传文件'}), 400 + + ensexam_configured = settings.model_path.exists() + if not ensexam_configured: + return jsonify({'success': False, 'error': 'EnsExam 模型未配置'}), 400 + + from models.inference import InferenceEngine + engine = InferenceEngine() + erased_paths = [] + for fp in file_paths: + with open(fp, 'rb') as f: + img_bytes = f.read() + result_img = engine.run(img_bytes) + erased_name = f"auto_{uuid.uuid4().hex[:8]}_{os.path.splitext(os.path.basename(fp))[0]}.png" + erased_path = settings.erased_dir / erased_name + result_img.save(str(erased_path), format='PNG') + erased_paths.append(str(erased_path)) + + # 保存擦除后的路径到会话,供 /api/ocr 复用 + with session_lock: + us = get_user_session(uid) + us["erased_file_paths"] = erased_paths + + preview = [] + for i, (orig, erased) in enumerate(zip(file_paths, erased_paths)): + preview.append({ + "index": i, + "original_url": f"/api/image/{os.path.basename(orig)}", + "erased_url": f"/api/image/{os.path.basename(erased)}", + }) + + return jsonify({ + 'success': True, + 'message': f'擦除完成,共 {len(erased_paths)} 张图片', + 'images': preview, + }) + + except Exception: + logger.exception("擦除手写笔迹失败") + return jsonify({'success': False, 'error': '擦除失败,请稍后重试'}), 500 + + +@bp.route('/ocr', methods=['POST']) +def run_ocr(): + """只执行 OCR,返回识别结果供用户预览。 + + 流程:标准化输入 → OCR 解析 → 返回结构化文本 + 不触发 Agent 分割。 + 如果之前调用过 /api/erase,则使用擦除后的图片。 + """ + try: + # 加载 OCR 凭据 + user_id = session.get('user_id') + ocr_credentials = {} + if user_id: + with SessionLocal() as db: + ocr_provider = crud.get_active_provider(db, user_id, 'paddleocr') + if ocr_provider: + ocr_credentials = { + "api_url": ocr_provider.base_url, + "token": ocr_provider.api_key, + "model": ocr_provider.model_name, + "use_doc_orientation": ocr_provider.use_doc_orientation, + "use_doc_unwarping": ocr_provider.use_doc_unwarping, + "use_chart_recognition": ocr_provider.use_chart_recognition, + } + + # 优先使用擦除后的路径 + file_paths = None + with session_lock: + us = get_user_session(user_id) + if us["erased_file_paths"]: + file_paths = list(us["erased_file_paths"]) + us["erased_file_paths"] = None # 用完即清 + + if not file_paths: + with session_lock: + us = get_user_session(user_id) + keys = list(us["session_file_order"]) + file_paths = [] + for k in keys: + v = us["session_files"].get(k) or {} + fp = v.get('filepath') + if fp: + file_paths.append(fp) + + if not file_paths: + return jsonify({'success': False, 'error': '请先上传文件'}), 400 + + # 标准化输入(PDF → 图片) + from src.utils import prepare_input + all_image_paths = [] + for fp in file_paths: + all_image_paths.extend(prepare_input(fp)) + + # 执行 OCR(获取原始结果用于预览 bbox,简化结果用于后续分割) + from src.paddleocr_client import PaddleOCRClient + from src.utils import simplify_ocr_results + from src.workflow import run_async + + creds = ocr_credentials or {} + client = PaddleOCRClient( + api_url=creds.get("api_url"), + token=creds.get("token"), + model=creds.get("model"), + use_doc_orientation=creds.get("use_doc_orientation"), + use_doc_unwarping=creds.get("use_doc_unwarping"), + use_chart_recognition=creds.get("use_chart_recognition"), + ) + + # 按文件类型分组 + pdf_paths = [p for p in all_image_paths if p.lower().endswith(".pdf")] + image_only = [p for p in all_image_paths if not p.lower().endswith(".pdf")] + + raw_ocr_results = [] + for pdf_path in pdf_paths: + try: + raw_ocr_results.append(client.parse_pdf(pdf_path, save_output=True)) + except Exception: + logger.exception(f"OCR PDF 失败: {pdf_path}") + if image_only: + try: + img_results = run_async(client.parse_images_async(image_only, save_output=True)) + if img_results: + raw_ocr_results.extend(img_results) + except Exception: + logger.exception("OCR 图片失败") + + if not raw_ocr_results: + return jsonify({'success': False, 'error': 'OCR 解析失败,请检查 PaddleOCR 配置'}), 500 + + # 简化结果用于后续分割 + ocr_data = simplify_ocr_results(raw_ocr_results) + + # 保存 OCR 结果到会话,供后续 /api/split 复用 + import json + results_dir = str(settings.results_dir) + os.makedirs(results_dir, exist_ok=True) + ocr_cache_path = os.path.join(results_dir, "ocr_cache.json") + with open(ocr_cache_path, 'w', encoding='utf-8') as f: + json.dump(ocr_data, f, ensure_ascii=False, indent=2) + + # 构建前端预览数据:每页的图片 + bbox 标注 + preview_pages = [] + page_idx = 0 + for result in raw_ocr_results: + for layout_page in result.get("layoutParsingResults", []): + pruned = layout_page.get("prunedResult", {}) + page_w = pruned.get("width", 1) + page_h = pruned.get("height", 1) + parsing_list = pruned.get("parsing_res_list", []) + + blocks = [] + for b in parsing_list: + bbox = b.get("block_bbox") + if not bbox: + continue + blocks.append({ + "label": b.get("block_label", ""), + "content": (b.get("block_content", "") or "")[:80], + "bbox": bbox, + }) + + image_path = all_image_paths[page_idx] if page_idx < len(all_image_paths) else None + image_url = f"/api/image/{os.path.basename(image_path)}" if image_path else None + + preview_pages.append({ + "page_index": page_idx, + "image_url": image_url, + "page_width": page_w, + "page_height": page_h, + "blocks": blocks, + }) + page_idx += 1 + + return jsonify({ + 'success': True, + 'message': f'OCR 完成,共 {len(preview_pages)} 页', + 'pages': preview_pages, + 'total_blocks': sum(len(p["blocks"]) for p in preview_pages), + }) + + except Exception: + logger.exception("OCR 执行失败") + return jsonify({'success': False, 'error': 'OCR 执行失败,请稍后重试'}), 500 + + +@bp.route('/split', methods=['POST']) +def split_questions(): + """开始执行标准化 + OCR + 分割。 + + 用户点击前端"开始分割题目"后调用该接口: + - 标准化输入(PDF/图片 → 图片列表) + - 触发 Agent/OCR 并分割题目 + + Returns: + JSON响应,包含分割后的题目 + """ + try: + # 读取请求体参数(模型供应商 + 模型名称) + data = request.get_json(silent=True) or {} + model_provider = data.get("model_provider", "openai") + model_name = data.get("model_name") # 可选,None 时使用 provider 默认模型 + if not settings.is_valid_provider(model_provider): + return jsonify({ + 'success': False, + 'error': f'不支持的模型供应商: {model_provider}' + }), 400 + + # 从数据库加载用户的 LLM + OCR 凭据 + user_id = session.get('user_id') + ocr_credentials = {} + if user_id: + settings.load_providers_from_db(user_id) + with SessionLocal() as db: + ocr_provider = crud.get_active_provider(db, user_id, 'paddleocr') + if ocr_provider: + ocr_credentials = { + "api_url": ocr_provider.base_url, + "token": ocr_provider.api_key, + "model": ocr_provider.model_name, + "use_doc_orientation": ocr_provider.use_doc_orientation, + "use_doc_unwarping": ocr_provider.use_doc_unwarping, + "use_chart_recognition": ocr_provider.use_chart_recognition, + } + + with session_lock: + us = get_user_session(user_id) + keys = list(us["session_file_order"]) + file_paths = [] + for k in keys: + v = us["session_files"].get(k) or {} + fp = v.get('filepath') + if fp: + file_paths.append(fp) + + if not file_paths: + return jsonify({ + 'success': False, + 'error': '请先上传文件' + }), 400 + + with session_lock: + us = get_user_session(user_id) + us["current_thread_id"] = str(uuid.uuid4()) + thread_id = us["current_thread_id"] + config = {"configurable": {"thread_id": thread_id}} + + initial_state = { + "file_paths": file_paths, + "model_provider": model_provider, + "model_name": model_name, + "ocr_credentials": ocr_credentials, + } + workflow_graph.invoke(initial_state, config=config) + result_state = workflow_graph.invoke(None, config=config) + + questions = result_state.get('questions', []) + warnings = result_state.get('warnings', []) + + # 自动保存分割记录 + try: + subject = _read_split_subject() + + with session_lock: + us = get_user_session(user_id) + file_names = [ + us["session_files"].get(k, {}).get("filename", "未知") + for k in us["session_file_order"] + ] + + with SessionLocal() as db: + crud.save_split_record(db, subject, model_provider, file_names, questions, user_id=user_id) + except Exception: + logger.warning("保存分割记录失败,不影响主流程", exc_info=True) + + return jsonify({ + 'success': True, + 'message': f'成功分割 {len(questions)} 道题目', + 'questions': questions, + 'warnings': warnings, + }) + + except Exception as e: + logger.exception("题目分割失败") + return jsonify({ + 'success': False, + 'error': '题目分割失败,请稍后重试' + }), 500 + + +@bp.route('/split-records', methods=['GET']) +def get_split_records(): + """获取最近 N 次分割历史记录,limit 由前端通过查询参数指定""" + try: + limit = request.args.get('limit', 10, type=int) + limit = max(1, min(limit, crud.MAX_SPLIT_RECORDS)) # 上限与保留条数一致 + + with SessionLocal() as db: + records = crud.get_recent_split_records(db, limit, user_id=session.get('user_id')) + result = [_serialize_split_record(r) for r in records] + + return jsonify({"success": True, "records": result}) + + except Exception as e: + logger.exception("获取分割记录失败") + return jsonify({"success": False, "error": "获取分割记录失败"}), 500 + + +@bp.route('/split-records/', methods=['GET']) +def get_split_record_detail(record_id): + """获取单条分割记录的完整数据(含 questions)""" + try: + with SessionLocal() as db: + uid = None if session.get('is_admin') else session.get('user_id') + record = crud.get_split_record_by_id(db, record_id, user_id=uid) + if not record: + return jsonify({"success": False, "error": "记录不存在"}), 404 + + result = _serialize_split_record(record) + result["questions"] = json.loads(record.questions_json) if record.questions_json else [] + + return jsonify({"success": True, "record": result}) + + except Exception as e: + logger.exception("获取分割记录详情失败") + return jsonify({"success": False, "error": "获取分割记录详情失败"}), 500 + + +@bp.route('/export', methods=['POST']) +def export_wrongbook(): + """ + 注入选中题目 ID 并恢复图执行导出 + + 图执行: export → END + + Returns: + JSON响应,包含导出文件路径 + """ + try: + data = request.get_json(silent=True) or {} + selected_uids = data.get('selected_ids', []) + + if not isinstance(selected_uids, list): + return jsonify({ + 'success': False, + 'error': 'selected_ids 必须为列表' + }), 400 + + if not selected_uids: + return jsonify({ + 'success': False, + 'error': '未选择任何题目' + }), 400 + + # 检查是否已分割(通过 questions.json 存在性判断,不依赖内存中的 current_thread_id) + results_dir = settings.results_dir + questions_file = os.path.join(results_dir, "questions.json") + if not os.path.exists(questions_file): + return jsonify({ + 'success': False, + 'error': '请先分割题目' + }), 400 + + with open(questions_file, 'r', encoding='utf-8') as f: + questions = json.load(f) + + output_path = export_wrongbook_md( + questions, + selected_uids, + ) + + return jsonify({ + 'success': True, + 'message': '错题本导出成功', + 'output_path': output_path + }) + + except Exception as e: + logger.exception("导出失败") + return jsonify({ + 'success': False, + 'error': '导出失败,请稍后重试' + }), 500 + + +@bp.route('/image/') +def serve_image(filename): + """提供上传/处理后的图片文件访问(OCR 预览用)""" + from flask import send_from_directory + # 在多个目录中查找 + for d in [settings.upload_dir, settings.erased_dir, settings.results_dir, settings.pages_dir]: + path = os.path.join(str(d), filename) + if os.path.exists(path): + return send_from_directory(str(d), filename) + return jsonify({'error': 'File not found'}), 404 diff --git a/backend/src/paddleocr_client.py b/backend/src/paddleocr_client.py index af16130f..79b13db3 100644 --- a/backend/src/paddleocr_client.py +++ b/backend/src/paddleocr_client.py @@ -120,7 +120,7 @@ def _parse_file( ) -> Dict[str, Any]: """解析文件并返回结构化结果(同步,任务轮询模式)""" if output_dir is None: - from config import settings + from core.config import settings output_dir = settings.struct_dir console.print(f"[cyan]正在解析: {file_path}[/cyan]") @@ -270,7 +270,7 @@ async def async_parse_image( ) -> Dict[str, Any]: """异步解析单张图片(提交任务 → 轮询 → 下载结果)""" if output_dir is None: - from config import settings + from core.config import settings output_dir = settings.struct_dir console.print(f"[cyan]正在解析图片: {image_path}[/cyan]") diff --git a/backend/src/utils.py b/backend/src/utils.py index 6d5f07bd..db8ad164 100644 --- a/backend/src/utils.py +++ b/backend/src/utils.py @@ -4,6 +4,7 @@ """ import os +import re import shutil import asyncio from concurrent.futures import ThreadPoolExecutor @@ -12,9 +13,9 @@ from dotenv import load_dotenv from rich.console import Console -from config import settings +from core.config import settings -load_dotenv() +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) console = Console() @@ -62,7 +63,7 @@ def prepare_input(file_path: str) -> List[str]: def export_wrongbook( questions: List[Dict[str, Any]], - selected_ids: List[str], + selected_uids: List[str], output_path: str = None, batch_info: Dict[str, Any] = None ) -> str: @@ -73,7 +74,7 @@ def export_wrongbook( Args: questions: 题目列表 - selected_ids: 用户选择的题目ID列表 + selected_uids: 用户选择的题目 uid 列表 output_path: 输出Markdown文件路径(可选) batch_info: 批次信息(用于入库),包含 original_filename, subject 等 @@ -84,59 +85,107 @@ def export_wrongbook( os.makedirs(settings.results_dir, exist_ok=True) output_path = os.path.join(settings.results_dir, "wrongbook.md") - # 过滤选中的题目 - selected_questions = [q for q in questions if q.get('question_id') in selected_ids] + # 用 uid 过滤选中的题目,保持原始顺序 + uid_set = set(selected_uids) + selected_questions = [q for q in questions if q.get('uid') in uid_set] # 构建Markdown内容 md_content = "# 错题本\n\n" md_content += f"> 共收录 {len(selected_questions)} 道题目\n\n" md_content += "---\n\n" - for i, q in enumerate(selected_questions, start=1): - md_content += f"## {i}. 题目 {q.get('question_id', '')} ({q.get('question_type', '未知')})\n\n" + rel_struct_dir = os.path.relpath(settings.struct_dir, settings.results_dir) + _img_src_re = re.compile(r'src="((?:/images/|imgs/)[^"]+)"') + + def _resolve_image_path(p: str) -> str: + """将 Agent 输出的图片路径统一转为 Markdown 可用的相对路径。 + + Agent 有时输出 '/images/xxx.jpg'(Flask 路由),有时输出 'imgs/xxx.jpg' + (LLM 对路径的自由变形),统一映射到 struct_dir/imgs/ 下的相对路径。 + """ + p = p.strip() + if p.startswith("/images/"): + return f"{rel_struct_dir}/imgs/{p[len('/images/'):]}" + if p.startswith("imgs/"): + return f"{rel_struct_dir}/imgs/{p[len('imgs/'):]}" + return p + + def _fix_html_image_src(html: str) -> str: + """修正 HTML 内 中的图片路径,与独立 image block 保持一致。""" + return _img_src_re.sub( + lambda m: f'src="{_resolve_image_path(m.group(1))}"', html + ) + + _INIT = object() # 初始哨兵,确保第一个有 section_title 的题一定触发标题输出 + current_section = _INIT + unsorted_started = False + serial = 0 # 全局序号 + + for q in selected_questions: + section = q.get('section_title') + + if section: + # 有 section_title:正常输出大题分组标题 + if section != current_section: + current_section = section + md_content += f"## {section}\n\n" + else: + # section=None:首次遇到时输出"(未分类)"节标题 + if not unsorted_started: + unsorted_started = True + md_content += "## (未分类)\n\n" + + serial += 1 + md_content += f"### {serial}. 题目 {q.get('question_id', '')} ({q.get('question_type', '未知')})\n\n" # 获取图片引用列表,用于填充空的 image block image_refs = q.get('image_refs') or [] - rendered_images = set() # 记录已渲染的图片路径 + rendered_images = set() # 记录已渲染的图片路径(原始路径,供 image_refs 兜底比对) + + # 预扫描:收集已内嵌在 HTML 文本块(如 table)中的图片路径,避免重复渲染 + html_embedded = set() + for block in q.get('content_blocks', []): + if block.get('block_type') == 'text': + for m in _img_src_re.finditer(block.get('content', '')): + html_embedded.add(m.group(1).strip()) # 添加内容块(只有 text 和 image,公式以 LaTeX 标记嵌入 text 中) for block in q.get('content_blocks', []): if block['block_type'] == 'text': - md_content += f"{block['content']}\n\n" + content = block['content'].replace('\n', ' \n') + # 修正 HTML 内嵌图片路径(table 中的 ) + content = _fix_html_image_src(content) + md_content += f"{content}\n\n" elif block['block_type'] == 'image': image_path = block.get('content', '').strip() - if image_path: - rendered_images.add(image_path) - - # 将 Flask 路由路径转为 Markdown 相对路径 - if image_path.startswith("/images/"): - rel_struct_dir = os.path.relpath(settings.struct_dir, settings.results_dir) - image_path = f"{rel_struct_dir}/imgs/{image_path[len('/images/') :]}" - - if image_path: - md_content += f"![图片]({image_path})\n\n" + if not image_path or image_path in html_embedded: + # 已在 HTML 表格中渲染过,跳过独立 image block 避免重复 + continue + rendered_images.add(image_path) + resolved = _resolve_image_path(image_path) + if resolved: + md_content += f"![图片]({resolved})\n\n" # 添加选项 if q.get('options'): for option in q['options']: - md_content += f"{option}\n\n" + md_content += f"{option} \n" + md_content += "\n" - # 兜底:渲染 image_refs 中未被 content_blocks 覆盖的图片 - remaining_images = [p for p in image_refs if p not in rendered_images] + # 兜底:渲染 image_refs 中未被 content_blocks 覆盖、也未在 HTML 中出现的图片 + remaining_images = [p for p in image_refs if p not in rendered_images and p not in html_embedded] if remaining_images: for image_path in remaining_images: - if image_path.startswith("/images/"): - rel_struct_dir = os.path.relpath(settings.struct_dir, settings.results_dir) - image_path = f"{rel_struct_dir}/imgs/{image_path[len('/images/') :]}" - md_content += f"![图片]({image_path})\n\n" + md_content += f"![图片]({_resolve_image_path(image_path)})\n\n" - md_content += "### 我的答案\n\n" + answer_prefix = "####" if section else "###" + md_content += f"{answer_prefix} 我的答案\n\n" md_content += "_(请在此处填写你的答案)_\n\n" - md_content += "### 正确答案\n\n" + md_content += f"{answer_prefix} 正确答案\n\n" md_content += "_(请在此处填写正确答案)_\n\n" - md_content += "### 解析\n\n" + md_content += f"{answer_prefix} 解析\n\n" md_content += "_(请在此处填写解题思路和知识点)_\n\n" md_content += "---\n\n" @@ -185,6 +234,9 @@ def simplify_ocr_results(ocr_results: list) -> List[Dict[str, Any]]: if bbox: prefix = "img_in_chart_box" if label == "chart" else "img_in_image_box" content = f"/images/{prefix}_{int(bbox[0])}_{int(bbox[1])}_{int(bbox[2])}_{int(bbox[3])}.jpg" + # 统一 OCR 原始输出中的图片路径:imgs/ → /images/ + if 'src="imgs/' in content: + content = content.replace('src="imgs/', 'src="/images/') slim_blocks.append({ "block_label": label, "block_content": content, diff --git a/backend/src/workflow.py b/backend/src/workflow.py index ce0c8f7d..4ed8c1e7 100644 --- a/backend/src/workflow.py +++ b/backend/src/workflow.py @@ -4,16 +4,21 @@ """ import os +import re as _re import json import logging import time +import traceback +import difflib as _difflib from typing import List, Dict, Any, TypedDict from concurrent.futures import ThreadPoolExecutor, as_completed from rich.console import Console from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.memory import MemorySaver -from config import settings +from core.config import settings from .utils import prepare_input, export_wrongbook, simplify_ocr_results, run_async + +_HTML_TAG_RE = _re.compile(r"<[^>]+>") console = Console() # 配置日志 @@ -106,14 +111,15 @@ def _run_ocr_and_simplify(file_paths: List[str], ocr_credentials: Dict[str, Any] break except Exception as e: last_error = e + tb = traceback.format_exc() if attempt < max_retries: delay = retry_delays[attempt - 1] - logger.warning(f"OCR PDF 第 {attempt} 次失败 ({e}),{delay}s 后重试...") - console.print(f"[yellow]OCR PDF 第 {attempt} 次失败,{delay}s 后重试...[/yellow]") + logger.warning(f"OCR PDF 第 {attempt} 次失败 ({type(e).__name__}: {e}),{delay}s 后重试...\n{tb}") + console.print(f"[yellow]OCR PDF 第 {attempt} 次失败 ({type(e).__name__}: {e}),{delay}s 后重试...[/yellow]") time.sleep(delay) else: - logger.error(f"OCR PDF 全部 {max_retries} 次重试失败: {last_error}") - console.print(f"[red]OCR PDF 解析失败(已重试 {max_retries} 次): {last_error}[/red]") + logger.error(f"OCR PDF 全部 {max_retries} 次重试失败 ({type(last_error).__name__}: {last_error})\n{tb}") + console.print(f"[red]OCR PDF 解析失败(已重试 {max_retries} 次): {type(last_error).__name__}: {last_error}[/red]") if result is not None: ocr_results.append(result) @@ -128,14 +134,15 @@ def _run_ocr_and_simplify(file_paths: List[str], ocr_credentials: Dict[str, Any] break except Exception as e: last_error = e + tb = traceback.format_exc() if attempt < max_retries: delay = retry_delays[attempt - 1] - logger.warning(f"OCR 图片第 {attempt} 次失败 ({e}),{delay}s 后重试...") - console.print(f"[yellow]OCR 图片第 {attempt} 次失败,{delay}s 后重试...[/yellow]") + logger.warning(f"OCR 图片第 {attempt} 次失败 ({type(e).__name__}: {e}),{delay}s 后重试...\n{tb}") + console.print(f"[yellow]OCR 图片第 {attempt} 次失败 ({type(e).__name__}: {e}),{delay}s 后重试...[/yellow]") time.sleep(delay) else: - logger.error(f"OCR 图片全部 {max_retries} 次重试失败: {last_error}") - console.print(f"[red]OCR 图片解析失败(已重试 {max_retries} 次): {last_error}[/red]") + logger.error(f"OCR 图片全部 {max_retries} 次重试失败 ({type(last_error).__name__}: {last_error})\n{tb}") + console.print(f"[red]OCR 图片解析失败(已重试 {max_retries} 次): {type(last_error).__name__}: {last_error}[/red]") if img_results: ocr_results.extend(img_results) @@ -150,28 +157,40 @@ def _build_overlapping_batches( batch_size: int = 2, overlap: int = 1, ) -> List[List[Dict[str, Any]]]: - """构建重叠批次 + """构建重叠批次,并为每页打上 is_primary 标记。 每批 batch_size 页,相邻批次重叠 overlap 页。 例如 5 页, batch_size=2, overlap=1: - 批次0: [page0, page1] - 批次1: [page1, page2] - 批次2: [page2, page3] - 批次3: [page3, page4] + 批次0: [page0(primary), page1(context)] + 批次1: [page1(primary), page2(context)] + 批次2: [page2(primary), page3(context)] + 批次3: [page3(primary), page4(primary←最后一批)] + + is_primary=True → Agent 必须提取该页开始的所有题目 + is_primary=False → 仅作跨页上下文,Agent 不提取该页独立开始的题目 + + 最后一批次的所有页均为 primary(最后一页没有后续批次来处理它)。 """ if not ocr_data: return [] n_pages = len(ocr_data) if n_pages <= batch_size: - return [ocr_data] + # 只有一批:全部页都是 primary + return [[dict(page, is_primary=True) for page in ocr_data]] step = batch_size - overlap batches = [] for start in range(0, n_pages, step): end = min(start + batch_size, n_pages) - batches.append(ocr_data[start:end]) - if end >= n_pages: + is_last = (end >= n_pages) + batch = [] + for page_idx, page in enumerate(ocr_data[start:end]): + # 第一页始终 primary;最后批次的所有页都 primary + primary = (page_idx == 0) or is_last + batch.append(dict(page, is_primary=primary)) + batches.append(batch) + if is_last: break return batches @@ -240,7 +259,7 @@ def _identify_subject( # ── 第 1 层:LLM 预检 ── try: - from error_correction_agent.agent import detect_subject_via_llm + from agents.error_correction.agent import detect_subject_via_llm llm_result = detect_subject_via_llm(text_sample, db_subjects, provider=model_provider) if llm_result: @@ -299,18 +318,218 @@ def _content_fingerprint(q: Dict[str, Any]) -> str: return "" +def _question_text(q: Dict[str, Any], chars: int = 400) -> str: + """提取题目纯文本,用于相似度计算。 + + 包含:content_blocks 中的 text 块(去除 HTML 标签)+ options 选项列表。 + image 块不参与计算。 + + chars 上限设为 400:覆盖大多数题目的完整内容,同时避免超长题目的 O(n²) 开销。 + + 去除 HTML 标签的原因:表格题目在不同批次的 table 结构可能不同(th/td 排列差异), + 但纯文本内容相同。去标签后两个版本的实验描述文字完全一致,相似度可正确判断。 + + 纳入 options 的原因:跨页截断时题干可能不同,但选项往往相同,是最稳定的指纹。 + """ + parts = [] + for block in q.get("content_blocks", []): + if block.get("block_type") == "text": + content = block.get("content", "") + content = _HTML_TAG_RE.sub("", content) # 去除 HTML 标签,保留文本 + parts.append(content) + for opt in q.get("options") or []: + if isinstance(opt, str): + parts.append(opt) + return "".join(parts)[:chars] + + +def _image_paths(q: Dict[str, Any]) -> set: + """提取题目所有 image block 的路径集合,用于图片指纹比对。""" + paths = set() + for block in q.get("content_blocks", []): + if block.get("block_type") == "image": + p = block.get("content", "").strip() + if p: + paths.add(p) + return paths + + +def _text_similarity(q1: Dict[str, Any], q2: Dict[str, Any]) -> float: + """计算两道题目内容相似度,返回 0~1。 + + 优先用文本相似度(difflib);若两题文本均过短(图片题), + 则用图片路径 Jaccard 系数兜底,避免纯图片题的重复无法被检测到。 + """ + t1 = _question_text(q1) + t2 = _question_text(q2) + + # 文本足够长时直接用 difflib + if len(t1) >= 10 and len(t2) >= 10: + return _difflib.SequenceMatcher(None, t1, t2).ratio() + + # 文本过短(图片题):用图片路径 Jaccard 系数兜底 + imgs1 = _image_paths(q1) + imgs2 = _image_paths(q2) + if imgs1 or imgs2: + intersection = len(imgs1 & imgs2) + union = len(imgs1 | imgs2) + return intersection / union if union else 0.0 + + # 文本和图片都为空:不认为是重复 + return 0.0 + + +def _fix_leading_images(questions: List[Dict[str, Any]]) -> None: + """后处理:将题目 content_blocks 中排在所有文本之前的图片移到前一道题末尾。 + + 产生原因:试卷某题的插图印在下一页顶部,OCR 按页面顺序扫描时,该图片 + 出现在下一题文字之前。Agent 在该批次里将其归入下一题的 content_blocks 首位, + 实际上属于上一题。 + + 判断规则:若一道题的 content_blocks 中第一个出现的 text block 之前存在 + image block,则这些 leading image 属于上一道题,移至上一题末尾。 + """ + for i in range(1, len(questions)): + q = questions[i] + blocks = q.get("content_blocks") or [] + if not blocks: + continue + + # 分离开头的连续 image 块(在第一个 text 块之前) + leading_images: List[Dict[str, Any]] = [] + rest: List[Dict[str, Any]] = [] + found_text = False + for block in blocks: + if not found_text and block.get("block_type") == "image": + leading_images.append(block) + else: + if block.get("block_type") == "text": + found_text = True + rest.append(block) + + if not leading_images: + continue + + # 若去掉 leading images 后该题没有任何文本,说明它本身是纯图片题,不搬移 + if not any(b.get("block_type") == "text" for b in rest): + continue + + # 移到前一道题末尾 + prev_q = questions[i - 1] + prev_blocks = prev_q.get("content_blocks") or [] + prev_q["content_blocks"] = prev_blocks + leading_images + q["content_blocks"] = rest + logger.info( + f"修复 leading image: {len(leading_images)} 张图片 " + f"从题目 {q.get('question_id')} 移至题目 {prev_q.get('question_id')}" + ) + + +def _normalize_image_paths(questions: List[Dict[str, Any]]) -> None: + """修复 LLM 可能篡改的图片路径,原地修改。 + + LLM 有时会把输入中的 '/images/xxx.jpg' 改写为 'imgs/xxx.jpg', + 导致前端通过 Vite proxy 请求时路径不匹配。统一规范为 '/images/xxx'。 + """ + def _fix(path: str) -> str: + p = path.strip() + if p.startswith("imgs/"): + return "/images/" + p[len("imgs/"):] + return p + + for q in questions: + for block in q.get("content_blocks") or []: + content = block.get("content", "") + if block.get("block_type") == "image": + block["content"] = _fix(content) + elif block.get("block_type") == "text" and "imgs/" in content: + # 修复 text block 中嵌入的 HTML 图片路径(如 table 中的 ) + block["content"] = content.replace('src="imgs/', 'src="/images/') + if q.get("image_refs"): + q["image_refs"] = [_fix(ref) for ref in q["image_refs"]] + if q.get("option_images"): + q["option_images"] = [_fix(ref) if ref else ref for ref in q["option_images"]] + + +def _propagate_section_between_batches(batch_results: List[List[Dict[str, Any]]]) -> None: + """跨批次传播 section_title,原地修改 batch_results。 + + 并行批次处理完成后调用。若批次 i 开头若干题的 section_title=None, + 说明大题标题在前一批次的页面里,当前批次窗口看不到。 + 从批次 i-1 的末尾向前找最后一个有 section_title 的题,将其 section_title + 赋给批次 i 中连续的 section=None 开头题,遇到批次 i 自己识别出 section_title + 的题时停止传播。 + + 防误传:若待传播 section 是在批次 i-1 中由题号较大的题建立的,则不向题号 + 更小的题传播(说明该 None 题属于更早的大题,而非当前 last_section)。 + """ + def _to_int_id(q) -> int | None: + try: + return int(str(q.get("question_id", "")).strip()) + except (ValueError, TypeError): + return None + + for i in range(1, len(batch_results)): + # 单次遍历上一批次:同时找 last_section 和该节首次出现的最小题号 + last_section = None + first_section_id: int | None = None + for q in batch_results[i - 1]: + s = q.get("section_title") + if s: + if s != last_section: + # 进入新节,重置首题号 + last_section = s + first_section_id = None + qid = _to_int_id(q) + if qid is not None: + if first_section_id is None or qid < first_section_id: + first_section_id = qid + + if not last_section: + continue + + # 将 last_section 赋给本批次开头连续的 section=None 题 + # 防误传:若待传播题的题号 < first_section_id,说明它属于更早的大题,跳过 + propagated = 0 + for q in batch_results[i]: + if not q.get("section_title"): + qid = _to_int_id(q) + if first_section_id is not None and qid is not None and qid < first_section_id: + # 该题题号比 last_section 第一题还小,不应继承该 section + continue + q["section_title"] = last_section + propagated += 1 + else: + break # 本批次已有自己识别出的 section,停止传播 + + if propagated: + logger.info( + f"跨批次 section 传播: 批次 {i} 前 {propagated} 道题 " + f"继承 section_title={repr(last_section)}" + ) + + def _dedup_questions(questions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """按「题号 + 内容指纹」去重。 + """按「(大题, 题号)」去重,再用内容相似度消除跨批次重复。 - - 同题号 + 相同指纹 → 重叠页产生的真重复,保留内容更丰富的版本 - - 同题号 + 不同指纹 → 不同板块的同号题(如小学试卷各大题下均有第1题),全部保留 + 第一轮:同一 (section_title, question_id) 内保留最丰富版本。 + 第二轮:同一 question_id 下若有多份,两两计算前 120 字相似度; + 相似度 ≥ 0.75 视为重复,优先保留有 section_title 的版本, + 相似度 < 0.75 视为不同大题下的同号题,全部保留。 """ if not questions: return [] from collections import defaultdict - groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + # ── 预处理:将 question_id 统一归一化为 str,防止 int/str 类型不一致导致去重失效 ── + for q in questions: + qid = q.get("question_id") + if qid is not None: + q["question_id"] = str(qid).strip() + + # ── 第一轮:按 (section, qid) 复合键去重 ────────────────── + groups: Dict[tuple, List[Dict[str, Any]]] = defaultdict(list) no_id: List[Dict[str, Any]] = [] for q in questions: @@ -318,26 +537,91 @@ def _dedup_questions(questions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: if not qid: no_id.append(q) else: - groups[qid].append(q) - - result: List[Dict[str, Any]] = list(no_id) + section = q.get("section_title") or "" + groups[(section, qid)].append(q) + after_round1: List[Dict[str, Any]] = list(no_id) for qs in groups.values(): - if len(qs) == 1: - result.append(qs[0]) + after_round1.append(max(qs, key=_question_richness)) + + # ── 第二轮:结构优先 + 内容相似度去重 ─────────────────────── + # 策略: + # 1. 同一 qid 下同时存在有 section 和无 section 版本 → 直接丢弃所有 section=None 版本 + # (section=None 说明该批次只捕获到选项/部分内容,有 section 的是完整版本) + # 2. 同一 qid 下均有 section(不同 section)→ difflib 相似度 ≥ 0.75 视为重复,保留最优 + # 3. 同一 qid 下均无 section → 保留内容最丰富的一份 + by_qid: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for q in after_round1: + by_qid[q.get("question_id", "")].append(q) + + SIMILARITY_THRESHOLD = 0.75 + final: List[Dict[str, Any]] = list(no_id) + round2_removed = 0 + + for qid, entries in by_qid.items(): + if not qid: continue + if len(entries) == 1: + final.extend(entries) + continue + + sectioned = [q for q in entries if q.get("section_title")] + unsectioned = [q for q in entries if not q.get("section_title")] - # 按内容指纹二次分组 - fp_groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list) - for q in qs: - fp_groups[_content_fingerprint(q)].append(q) + # 策略1:有 section 版本存在时,丢弃全部 section=None 版本 + if sectioned and unsectioned: + round2_removed += len(unsectioned) + for q in unsectioned: + logger.debug( + f"二次去重剔除(结构): qid={qid} " + f"section=None,保留有大题标题的版本,richness={_question_richness(q)}" + ) + entries = sectioned + + if len(entries) == 1: + final.extend(entries) + continue - # 每个指纹组只保留最丰富的版本 - for fp_qs in fp_groups.values(): - result.append(max(fp_qs, key=_question_richness)) + # 策略2/3:全部有 section 或全部无 section → difflib 去重 + entries.sort(key=lambda q: ( + 0 if q.get("section_title") else 1, + -_question_richness(q) + )) + + kept: List[Dict[str, Any]] = [] + for candidate in entries: + sim = max( + (_text_similarity(candidate, k) for k in kept), + default=0.0, + ) + if sim >= SIMILARITY_THRESHOLD: + round2_removed += 1 + logger.debug( + f"二次去重剔除(相似度): qid={qid} " + f"section={repr(candidate.get('section_title'))} " + f"相似度={sim:.2f}" + ) + else: + kept.append(candidate) + final.extend(kept) + + if round2_removed: + logger.info(f"二次去重: 剔除 {round2_removed} 道重复题目(结构优先 + 相似度阈值={SIMILARITY_THRESHOLD})") + console.print(f"[yellow]二次去重: 剔除 {round2_removed} 道重复题目[/yellow]") + + # ── 排序:有 section 的题按首次出现顺序 + 题号,section=None 的题排最后 ── + section_order: Dict[str, int] = {} + for q in questions: + s = q.get("section_title") + if s and s not in section_order: + section_order[s] = len(section_order) - result.sort(key=lambda q: _sort_key(q.get("question_id", ""))) - return result + final.sort(key=lambda q: ( + 0 if q.get("section_title") else 1, # 有 section 的排前,None 排后 + section_order.get(q.get("section_title") or "", 999), + _sort_key(q.get("question_id", "")) + )) + return final def _question_richness(q: Dict[str, Any]) -> int: @@ -388,30 +672,43 @@ def split_questions_node(state: WorkflowState) -> dict: if os.path.exists(meta_path): os.remove(meta_path) - # ── Step 1: OCR 解析 ── - console.print(f"[cyan]OCR 解析 {len(file_paths)} 个文件...[/cyan]") - ocr_start = time.time() - - ocr_data = _run_ocr_and_simplify(file_paths, ocr_credentials=ocr_credentials) + # ── Step 1: OCR 解析(优先使用缓存) ── + ocr_cache_path = os.path.join(results_dir, "ocr_cache.json") + ocr_data = None + if os.path.exists(ocr_cache_path): + try: + with open(ocr_cache_path, 'r', encoding='utf-8') as f: + ocr_data = json.load(f) + console.print(f"[green]✓ 使用 OCR 缓存: {len(ocr_data)} 页[/green]") + logger.info(f"使用 OCR 缓存: {len(ocr_data)} 页") + os.remove(ocr_cache_path) # 用完即删,避免下次误用 + except Exception as e: + logger.warning(f"读取 OCR 缓存失败: {e},将重新执行 OCR") + ocr_data = None if not ocr_data: - logger.error("OCR 解析失败,无数据返回") - console.print("[red]⚠ OCR 解析失败[/red]") - return { - "questions": [], - "warnings": ["步骤 2(OCR 解析)失败:无法解析图片内容,请检查 PaddleOCR API Token 配置"], - } + console.print(f"[cyan]OCR 解析 {len(file_paths)} 个文件...[/cyan]") + ocr_start = time.time() + ocr_data = _run_ocr_and_simplify(file_paths, ocr_credentials=ocr_credentials) + + if not ocr_data: + logger.error("OCR 解析失败,无数据返回") + console.print("[red]⚠ OCR 解析失败[/red]") + return { + "questions": [], + "warnings": ["步骤 2(OCR 解析)失败:无法解析图片内容,请检查 PaddleOCR API Token 配置"], + } + + total_blocks = sum(len(p.get("blocks", [])) for p in ocr_data) + ocr_elapsed = time.time() - ocr_start + logger.info(f"OCR 完成: {len(ocr_data)} 页, {total_blocks} 个 block, 耗时 {ocr_elapsed:.2f}s") + console.print(f"[green]✓ OCR 完成: {len(ocr_data)} 页, {total_blocks} 个 block ({ocr_elapsed:.1f}s)[/green]") # 保存 agent_input.json(供纠错节点使用) agent_input_path = os.path.join(results_dir, "agent_input.json") with open(agent_input_path, 'w', encoding='utf-8') as f: json.dump(ocr_data, f, ensure_ascii=False, indent=2) - total_blocks = sum(len(p.get("blocks", [])) for p in ocr_data) - ocr_elapsed = time.time() - ocr_start - logger.info(f"OCR 完成: {len(ocr_data)} 页, {total_blocks} 个 block, 耗时 {ocr_elapsed:.2f}s") - console.print(f"[green]✓ OCR 完成: {len(ocr_data)} 页, {total_blocks} 个 block ({ocr_elapsed:.1f}s)[/green]") - # ── Step 2: 加载 DB 上下文 ── db_subjects, db_tags = _load_db_context() @@ -435,13 +732,18 @@ def split_questions_node(state: WorkflowState) -> dict: # ── Step 4: 构建重叠批次 ── batches = _build_overlapping_batches(ocr_data, batch_size=2, overlap=1) - console.print(f"[cyan]构建 {len(batches)} 个批次(2页/批, 1页重叠)[/cyan]") + console.print(f"[cyan]构建 {len(batches)} 个批次(每批1主页+1上下文页)[/cyan]") + + # 保存完整批次数据到 agent_input.json(含 is_primary 标记,供调试和纠错节点使用) + agent_input_path = os.path.join(results_dir, "agent_input.json") + with open(agent_input_path, 'w', encoding='utf-8') as f: + json.dump(batches, f, ensure_ascii=False, indent=2) # ── Step 5: 并行分割 ── split_start = time.time() console.print(f"[cyan]并行分割 {len(batches)} 个批次...[/cyan]") - from error_correction_agent.tools import split_batch + from agents.error_correction.tools import split_batch existing_tags_str = ",".join(db_tags) if db_tags else "" max_workers = min(len(batches), 3) @@ -497,7 +799,9 @@ def _invoke_split(batch_idx: int, batch_data: list) -> None: split_elapsed = time.time() - split_start logger.info(f"并行分割完成, 耗时 {split_elapsed:.2f}s") - # ── Step 6: 合并 + 去重 ── + # ── Step 6: 跨批次 section 传播 + 合并 + 去重 ── + _propagate_section_between_batches(batch_results) + all_questions = [] for questions in batch_results: all_questions.extend(questions) @@ -510,6 +814,16 @@ def _invoke_split(batch_idx: int, batch_data: list) -> None: logger.info(f"去重: {before_dedup} → {after_dedup} 道题目(移除 {before_dedup - after_dedup} 道重复)") console.print(f"[yellow]去重: 移除 {before_dedup - after_dedup} 道重复题目[/yellow]") + # 修复 leading image(页面顶部图属于上一题) + _fix_leading_images(deduped) + + # 修复 LLM 可能篡改的图片路径(如 imgs/xxx → /images/xxx) + _normalize_image_paths(deduped) + + # 为每道题赋全局唯一 uid(顺序字符串),供前端选择和导出时使用 + for i, q in enumerate(deduped): + q["uid"] = str(i) + # ── Step 7: 保存结果 ── with open(questions_file, 'w', encoding='utf-8') as f: json.dump(deduped, f, ensure_ascii=False, indent=2) @@ -567,15 +881,29 @@ def correct_questions_node(state: WorkflowState) -> dict: console.print(f"[cyan]发现 {len(flagged)} 道题目需要纠错(共 {len(questions)} 道)[/cyan]") logger.info(f"开始纠错: {len(flagged)}/{len(questions)} 道题目") - # 加载原始 OCR 数据作为纠错上下文 + # 加载 OCR 数据作为纠错上下文 + # agent_input.json 现在保存的是批次数据([[page, ...], ...]),需要还原为扁平页面列表 ocr_context = "{}" agent_input_path = os.path.join(settings.results_dir, "agent_input.json") if os.path.exists(agent_input_path): with open(agent_input_path, 'r', encoding='utf-8') as f: - ocr_context = f.read() + raw = json.load(f) + # 兼容新格式(批次列表)和旧格式(扁平页面列表) + if raw and isinstance(raw[0], list): + # 新格式:按 page_index 去重,还原为扁平页面列表 + seen = {} + for batch in raw: + for page in batch: + idx = page.get("page_index", id(page)) + if idx not in seen: + seen[idx] = page + flat_pages = [seen[k] for k in sorted(seen)] + ocr_context = json.dumps(flat_pages, ensure_ascii=False) + else: + ocr_context = json.dumps(raw, ensure_ascii=False) # 调用纠错工具 - from error_correction_agent.tools import correct_batch + from agents.error_correction.tools import correct_batch model_provider = state.get("model_provider", "openai") model_name = state.get("model_name") @@ -597,19 +925,25 @@ def correct_questions_node(state: WorkflowState) -> dict: console.print("[red]⚠ 纠错结果解析失败,保留原始题目[/red]") return {"questions": questions} - # 按 question_id 合并纠错结果 - corrected_map = {q["question_id"]: q for q in corrected} + # 按 (section_title, question_id) 复合键合并纠错结果,避免不同大题同号题互相覆盖 + corrected_map = { + (q.get("section_title") or "", q["question_id"]): q + for q in corrected + } merged = [] for q in questions: qid = q.get("question_id") - if qid in corrected_map: - cq = corrected_map[qid] + section = q.get("section_title") or "" + key = (section, qid) + if key in corrected_map: + cq = corrected_map[key] corrections = cq.pop("corrections_applied", []) cq["needs_correction"] = False cq["ocr_issues"] = None + cq["uid"] = q.get("uid") # 纠错对象来自 LLM 输出,不含 uid,从原题还原 merged.append(cq) - logger.info(f"题目 {qid} 已纠错: {corrections}") + logger.info(f"题目 {section}-{qid} 已纠错: {corrections}") else: merged.append(q) diff --git a/backend/tests/test_agent_capabilities.py b/backend/tests/test_agent_capabilities.py new file mode 100644 index 00000000..1a051b3f --- /dev/null +++ b/backend/tests/test_agent_capabilities.py @@ -0,0 +1,21 @@ +from agents.error_correction.agent import _should_avoid_tool_strategy + + +class _DummyCfg: + def __init__(self, model_name: str, base_url: str = ""): + self._model_name = model_name + self.base_url = base_url + + def resolve_model_name(self, model_name=None, *, use_light=False): + return model_name or self._model_name + + +def test_should_avoid_tool_strategy_for_deepseek_reasoner(): + cfg = _DummyCfg(model_name="deepseek-reasoner", base_url="https://api.deepseek.com") + assert _should_avoid_tool_strategy("openai", None, cfg) is True + + +def test_should_not_avoid_tool_strategy_for_deepseek_chat(): + cfg = _DummyCfg(model_name="deepseek-chat", base_url="https://api.deepseek.com") + assert _should_avoid_tool_strategy("openai", None, cfg) is False + diff --git a/backend/tests/test_chat_routes.py b/backend/tests/test_chat_routes.py index 4b856bcc..76e16ba0 100644 --- a/backend/tests/test_chat_routes.py +++ b/backend/tests/test_chat_routes.py @@ -52,10 +52,17 @@ def __enter__(self): def __exit__(self, *args): pass - with patch("web_app.SessionLocal", FakeSessionLocal()): + fake = FakeSessionLocal() + with patch("routes.chat.SessionLocal", fake), \ + patch("routes.questions.SessionLocal", fake), \ + patch("routes.settings.SessionLocal", fake), \ + patch("routes.auth.SessionLocal", fake): from web_app import app app.config["TESTING"] = True with app.test_client() as c: + with c.session_transaction() as sess: + sess['user_id'] = 1 + sess['username'] = 'test' yield c diff --git a/backend/tests/test_correct_node.py b/backend/tests/test_correct_node.py index 5aa58c1c..3a6c63c6 100644 --- a/backend/tests/test_correct_node.py +++ b/backend/tests/test_correct_node.py @@ -40,7 +40,7 @@ def test_skip_when_no_flagged(self): result = correct_questions_node(state) assert result["questions"] == qs - @patch("error_correction_agent.tools.correct_batch") + @patch("agents.error_correction.tools.correct_batch") def test_merge_corrected(self, mock_correct_batch, tmp_path): """纠错结果应按 question_id 合并回原列表""" q1 = _q("1") @@ -62,7 +62,7 @@ def test_merge_corrected(self, mock_correct_batch, tmp_path): with open(os.path.join(results_dir, "agent_input.json"), "w") as f: f.write(agent_input) - with patch("config.settings.results_dir", Path(results_dir)): + with patch("core.config.settings.results_dir", Path(results_dir)): state = {"questions": [q1, q2, q3]} result = correct_questions_node(state) @@ -79,7 +79,7 @@ def test_merge_corrected(self, mock_correct_batch, tmp_path): merged_q1 = next(q for q in merged if q["question_id"] == "1") assert merged_q1["content_blocks"][0]["content"] == "内容" - @patch("error_correction_agent.tools.correct_batch") + @patch("agents.error_correction.tools.correct_batch") def test_invalid_json_keeps_original(self, mock_correct_batch, tmp_path): """纠错返回无效 JSON 时应保留原始题目""" q1 = _q("1", needs_correction=True) @@ -89,7 +89,7 @@ def test_invalid_json_keeps_original(self, mock_correct_batch, tmp_path): with open(os.path.join(results_dir, "agent_input.json"), "w") as f: f.write("{}") - with patch("config.settings.results_dir", Path(results_dir)): + with patch("core.config.settings.results_dir", Path(results_dir)): state = {"questions": [q1]} result = correct_questions_node(state) diff --git a/backend/tests/test_ocr_api.py b/backend/tests/test_ocr_api.py index 14cf78e6..57474a30 100644 --- a/backend/tests/test_ocr_api.py +++ b/backend/tests/test_ocr_api.py @@ -5,7 +5,7 @@ pytest tests/test_ocr_api.py -v -s 需要配置环境变量:PADDLEOCR_API_URL、PADDLEOCR_API_TOKEN。 -测试图片使用 example_uploads/ 目录下的文件(test.jpg + test4.pdf)。 +测试图片使用 example_uploads/notes/test.jpg,测试 PDF 使用 example_uploads/exams/test4.pdf。 """ import os @@ -19,15 +19,46 @@ # ── 配置 ──────────────────────────────────────────────── -API_URL = os.getenv("PADDLEOCR_API_URL", "") -TOKEN = os.getenv("PADDLEOCR_API_TOKEN", "") -MODEL = os.getenv("PADDLEOCR_MODEL", "PaddleOCR-VL-1.5") + +def _load_ocr_creds_from_db() -> dict: + """从数据库读取激活的 PaddleOCR provider 凭据(作为环境变量的 fallback)""" + try: + import sys + _backend = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if _backend not in sys.path: + sys.path.insert(0, _backend) + from db import SessionLocal + from db.models import ProviderConfig + with SessionLocal() as db: + p = db.query(ProviderConfig).filter( + ProviderConfig.category == "paddleocr", + ProviderConfig.is_active == True, + ).first() + if p: + return { + "api_url": p.base_url or "", + "token": p.api_key or "", + "model": p.model_name or "PaddleOCR-VL-1.5", + "use_doc_orientation": p.use_doc_orientation, + "use_doc_unwarping": p.use_doc_unwarping, + "use_chart_recognition": p.use_chart_recognition, + } + except Exception: + pass + return {} + + +_DB_CREDS = _load_ocr_creds_from_db() + +API_URL = os.getenv("PADDLEOCR_API_URL") or _DB_CREDS.get("api_url", "") +TOKEN = os.getenv("PADDLEOCR_API_TOKEN") or _DB_CREDS.get("token", "") +MODEL = os.getenv("PADDLEOCR_MODEL") or _DB_CREDS.get("model", "PaddleOCR-VL-1.5") EXAMPLE_DIR = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..", "example_uploads") ) -TEST_IMAGE = os.path.join(EXAMPLE_DIR, "test.jpg") -TEST_PDF = os.path.join(EXAMPLE_DIR, "test4.pdf") +TEST_IMAGE = os.path.join(EXAMPLE_DIR, "notes", "test.jpg") +TEST_PDF = os.path.join(EXAMPLE_DIR, "exams", "test4.pdf") POLL_INTERVAL = 3 POLL_TIMEOUT = 120 # 最长等待秒数 @@ -144,7 +175,7 @@ class TestImageApiConnection: def test_submit_returns_job_id(self, image_job_result): """提交图片任务应返回非空 jobId""" job_id, _ = image_job_result - assert job_id and job_id.startswith("ocrjob-") + assert job_id def test_job_completes_successfully(self, image_job_result): """图片任务应在超时前完成""" @@ -242,7 +273,7 @@ class TestPdfApiConnection: def test_submit_returns_job_id(self, pdf_job_result): """提交 PDF 任务应返回非空 jobId""" job_id, _ = pdf_job_result - assert job_id and job_id.startswith("ocrjob-") + assert job_id def test_job_completes_successfully(self, pdf_job_result): """PDF 任务应在超时前完成""" @@ -295,7 +326,7 @@ def test_parse_image_returns_result(self, tmp_path): """parse_image 应返回包含 layoutParsingResults 的结果""" from src.paddleocr_client import PaddleOCRClient - client = PaddleOCRClient() + client = PaddleOCRClient(**_DB_CREDS) result = client.parse_image(TEST_IMAGE, save_output=True, output_dir=str(tmp_path)) assert "layoutParsingResults" in result @@ -306,7 +337,7 @@ def test_saves_struct_json(self, tmp_path): from src.paddleocr_client import PaddleOCRClient from pathlib import Path - client = PaddleOCRClient() + client = PaddleOCRClient(**_DB_CREDS) client.parse_image(TEST_IMAGE, save_output=True, output_dir=str(tmp_path)) stem = Path(TEST_IMAGE).stem @@ -322,7 +353,7 @@ def test_simplify_ocr_results_compatible(self): from src.paddleocr_client import PaddleOCRClient from src.utils import simplify_ocr_results - client = PaddleOCRClient() + client = PaddleOCRClient(**_DB_CREDS) result = client.parse_image(TEST_IMAGE, save_output=False) simplified = simplify_ocr_results([result]) @@ -342,7 +373,7 @@ def test_parse_pdf_returns_result(self, tmp_path): """parse_pdf 应返回包含 layoutParsingResults 的结果""" from src.paddleocr_client import PaddleOCRClient - client = PaddleOCRClient() + client = PaddleOCRClient(**_DB_CREDS) result = client.parse_pdf(TEST_PDF, save_output=True, output_dir=str(tmp_path)) assert "layoutParsingResults" in result @@ -353,7 +384,7 @@ def test_pdf_simplify_compatible(self): from src.paddleocr_client import PaddleOCRClient from src.utils import simplify_ocr_results - client = PaddleOCRClient() + client = PaddleOCRClient(**_DB_CREDS) result = client.parse_pdf(TEST_PDF, save_output=False) simplified = simplify_ocr_results([result]) @@ -367,7 +398,7 @@ def test_pdf_multipage_index_continuity(self): from src.paddleocr_client import PaddleOCRClient from src.utils import simplify_ocr_results - client = PaddleOCRClient() + client = PaddleOCRClient(**_DB_CREDS) result = client.parse_pdf(TEST_PDF, save_output=False) simplified = simplify_ocr_results([result]) diff --git a/backend/tests/test_question_tools.py b/backend/tests/test_question_tools.py index ff262cc1..f1af1cf5 100644 --- a/backend/tests/test_question_tools.py +++ b/backend/tests/test_question_tools.py @@ -10,7 +10,7 @@ import json import pytest from unittest.mock import patch -from error_correction_agent.tools.question_tools import save_questions, log_issue +from agents.error_correction.tools.question_tools import save_questions, log_issue # ═══════════════════════════════════════════════════════════ @@ -25,7 +25,7 @@ def test_save_new_file(self, tmp_path): out = str(tmp_path / "questions.json") questions = [{"question_id": "1", "content_blocks": []}] - with patch("config.settings.results_dir", tmp_path): + with patch("core.config.settings.results_dir", tmp_path): result = save_questions.invoke({ "questions": questions, "output_path": out, @@ -41,7 +41,7 @@ def test_append_to_existing(self, tmp_path): q1 = [{"question_id": "1", "content_blocks": []}] q2 = [{"question_id": "2", "content_blocks": []}] - with patch("config.settings.results_dir", tmp_path): + with patch("core.config.settings.results_dir", tmp_path): save_questions.invoke({"questions": q1, "output_path": out}) save_questions.invoke({"questions": q2, "output_path": out}) @@ -53,7 +53,7 @@ def test_subject_metadata(self, tmp_path): out = str(tmp_path / "questions.json") questions = [{"question_id": "1", "content_blocks": []}] - with patch("config.settings.results_dir", tmp_path): + with patch("core.config.settings.results_dir", tmp_path): save_questions.invoke({ "questions": questions, "subject": "高中数学", @@ -70,7 +70,7 @@ def test_empty_subject_no_metadata(self, tmp_path): out = str(tmp_path / "questions.json") questions = [{"question_id": "1", "content_blocks": []}] - with patch("config.settings.results_dir", tmp_path): + with patch("core.config.settings.results_dir", tmp_path): save_questions.invoke({ "questions": questions, "subject": "", @@ -90,7 +90,7 @@ class TestLogIssue: """log_issue 工具测试""" def test_basic_log(self, tmp_path): - with patch("config.settings.results_dir", tmp_path): + with patch("core.config.settings.results_dir", tmp_path): result = log_issue.invoke({ "issue_type": "unclear_boundary", "description": "第3题和第4题边界不清", @@ -105,7 +105,7 @@ def test_basic_log(self, tmp_path): assert "第3题" in line["description"] def test_with_block_info(self, tmp_path): - with patch("config.settings.results_dir", tmp_path): + with patch("core.config.settings.results_dir", tmp_path): log_issue.invoke({ "issue_type": "missing_question_number", "description": "缺少题号", @@ -119,7 +119,7 @@ def test_with_block_info(self, tmp_path): def test_append_multiple(self, tmp_path): """多次调用应追加(JSONL 格式)""" - with patch("config.settings.results_dir", tmp_path): + with patch("core.config.settings.results_dir", tmp_path): log_issue.invoke({"issue_type": "a", "description": "issue 1"}) log_issue.invoke({"issue_type": "b", "description": "issue 2"}) diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 178e1d7a..f89b2f27 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -11,7 +11,7 @@ import pytest from pydantic import ValidationError -from error_correction_agent.schemas import ( +from agents.error_correction.schemas import ( ContentBlock, Question, QuestionSplitResult, diff --git a/backend/tests/test_solve_integration.py b/backend/tests/test_solve_integration.py index 8b533a79..00ab1643 100644 --- a/backend/tests/test_solve_integration.py +++ b/backend/tests/test_solve_integration.py @@ -55,7 +55,7 @@ @pytest.fixture(scope="session") def solve_results(model_provider): """session 级 fixture:只调用一次 API,所有测试共享结果""" - from solve_agent import invoke_solve + from agents.solve import invoke_solve return invoke_solve(SAMPLE_QUESTIONS, provider=model_provider) diff --git a/backend/tests/test_solve_schemas.py b/backend/tests/test_solve_schemas.py index e887dcf8..b843049b 100644 --- a/backend/tests/test_solve_schemas.py +++ b/backend/tests/test_solve_schemas.py @@ -8,7 +8,7 @@ import pytest from pydantic import ValidationError -from solve_agent.schemas import SolveResult, SolveBatchResult +from agents.solve.schemas import SolveResult, SolveBatchResult # ═══════════════════════════════════════════════════════════ diff --git a/backend/tests/test_split_integration.py b/backend/tests/test_split_integration.py index 0e07b037..0c314ca4 100644 --- a/backend/tests/test_split_integration.py +++ b/backend/tests/test_split_integration.py @@ -48,7 +48,7 @@ def ocr_data(): @pytest.fixture(scope="session") def split_result(ocr_data, model_provider): """调用 split_batch 一次,所有测试共享结果(节省 API 调用)""" - from error_correction_agent.tools import split_batch + from agents.error_correction.tools import split_batch result = split_batch.invoke({ "ocr_data": json.dumps(ocr_data, ensure_ascii=False), @@ -79,7 +79,7 @@ def test_returns_non_empty(self, split_result): def test_question_schema(self, split_result): """每道题目应符合 Question schema""" - from error_correction_agent.schemas import Question + from agents.error_correction.schemas import Question for q_data in split_result: q = Question(**q_data) diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py index 3c8bd52f..e73a408a 100644 --- a/backend/tests/test_utils.py +++ b/backend/tests/test_utils.py @@ -14,7 +14,7 @@ def mock_env(monkeypatch, tmp_path): """使用临时目录作为 settings.pages_dir""" pages_dir = tmp_path / "pages" - monkeypatch.setattr("config.settings.pages_dir", pages_dir) + monkeypatch.setattr("core.config.settings.pages_dir", pages_dir) return pages_dir diff --git a/backend/tests/test_web_helpers.py b/backend/tests/test_web_helpers.py index 9bdad5fe..1c9ced04 100644 --- a/backend/tests/test_web_helpers.py +++ b/backend/tests/test_web_helpers.py @@ -8,7 +8,8 @@ import os import pytest -from web_app import allowed_file, _safe_join +from routes.upload import allowed_file +from web_app import _safe_join # ═══════════════════════════════════════════════════════════ diff --git a/backend/tests/test_web_routes.py b/backend/tests/test_web_routes.py index b7f1bac4..9e7e775f 100644 --- a/backend/tests/test_web_routes.py +++ b/backend/tests/test_web_routes.py @@ -18,13 +18,16 @@ from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker -from db.models import Base +from db.models import Base, User from db import crud +# 测试用户 ID,与 client fixture 中的 session['user_id'] 一致 +TEST_USER_ID = 1 + @pytest.fixture def test_db(): - """内存数据库 + 建表""" + """内存数据库 + 建表 + 创建测试用户""" engine = create_engine("sqlite:///:memory:", echo=False) @event.listens_for(engine, "connect") @@ -36,6 +39,10 @@ def set_sqlite_pragma(dbapi_connection, connection_record): Base.metadata.create_all(bind=engine) Session = sessionmaker(bind=engine) session = Session() + # 创建测试用户(与 client session 中的 user_id 匹配) + user = User(id=TEST_USER_ID, username="test", email="test@test.com", password_hash="x") + session.add(user) + session.commit() yield session session.close() @@ -54,10 +61,20 @@ def __enter__(self): def __exit__(self, *args): pass - with patch("web_app.SessionLocal", FakeSessionLocal()): + fake = FakeSessionLocal() + with patch("routes.settings.SessionLocal", fake), \ + patch("routes.questions.SessionLocal", fake), \ + patch("routes.stats.SessionLocal", fake), \ + patch("routes.upload.SessionLocal", fake), \ + patch("routes.chat.SessionLocal", fake), \ + patch("routes.auth.SessionLocal", fake): from web_app import app app.config["TESTING"] = True with app.test_client() as c: + with c.session_transaction() as sess: + sess['user_id'] = TEST_USER_ID + sess['username'] = 'test' + sess['session_version'] = 0 yield c @@ -176,7 +193,7 @@ def test_delete_existing(self, client, test_db): save_questions_to_db(test_db, qs, { "original_filename": "test.pdf", "subject": "数学", - }) + }, user_id=TEST_USER_ID) # 查询刚插入的题目 ID from db.models import Question @@ -230,7 +247,7 @@ def test_with_data(self, client, test_db): save_questions_to_db(test_db, qs, { "original_filename": "test.pdf", "subject": "数学", - }) + }, user_id=TEST_USER_ID) resp = client.get("/api/error-bank") data = resp.get_json() assert data["total"] == 1 @@ -260,7 +277,7 @@ def test_with_data(self, client, test_db): "question_type": "选择题", "content_blocks": [{"block_type": "text", "content": "t"}], "has_formula": False, "has_image": False, - }], {"original_filename": "a.pdf", "subject": "高中数学"}) + }], {"original_filename": "a.pdf", "subject": "高中数学"}, user_id=TEST_USER_ID) resp = client.get("/api/subjects") assert "高中数学" in resp.get_json()["subjects"] @@ -300,7 +317,7 @@ def test_save_answer(self, client, test_db): "question_type": "选择题", "content_blocks": [{"block_type": "text", "content": "答案测试"}], "has_formula": False, "has_image": False, - }], {"original_filename": "a.pdf", "subject": "数学"}) + }], {"original_filename": "a.pdf", "subject": "数学"}, user_id=TEST_USER_ID) from db.models import Question q = test_db.query(Question).first() resp = client.patch(f"/api/question/{q.id}/answer", json={"user_answer": "选B"}) @@ -342,7 +359,7 @@ def test_invalid_status(self, client, test_db): "question_type": "选择题", "content_blocks": [{"block_type": "text", "content": "状态测试"}], "has_formula": False, "has_image": False, - }], {"original_filename": "a.pdf", "subject": "数学"}) + }], {"original_filename": "a.pdf", "subject": "数学"}, user_id=TEST_USER_ID) from db.models import Question q = test_db.query(Question).first() resp = client.patch(f"/api/question/{q.id}/review-status", json={"review_status": "无效"}) @@ -359,7 +376,7 @@ def test_update_status(self, client, test_db): "question_type": "选择题", "content_blocks": [{"block_type": "text", "content": "复习状态测试"}], "has_formula": False, "has_image": False, - }], {"original_filename": "a.pdf", "subject": "数学"}) + }], {"original_filename": "a.pdf", "subject": "数学"}, user_id=TEST_USER_ID) from db.models import Question q = test_db.query(Question).first() resp = client.patch(f"/api/question/{q.id}/review-status", json={"review_status": "已掌握"}) @@ -396,7 +413,7 @@ def test_with_data(self, client, test_db): "content_blocks": [{"block_type": "text", "content": "统计测试"}], "has_formula": False, "has_image": False, "knowledge_tags": ["导数"], - }], {"original_filename": "a.pdf", "subject": "数学"}) + }], {"original_filename": "a.pdf", "subject": "数学"}, user_id=TEST_USER_ID) resp = client.get("/api/dashboard-stats") data = resp.get_json() assert data["total_questions"] == 1 @@ -413,14 +430,14 @@ def test_subject_filter(self, client, test_db): "content_blocks": [{"block_type": "text", "content": "数学题"}], "has_formula": False, "has_image": False, "knowledge_tags": ["函数"], - }], {"original_filename": "a.pdf", "subject": "数学"}) + }], {"original_filename": "a.pdf", "subject": "数学"}, user_id=TEST_USER_ID) save_questions_to_db(test_db, [{ "question_id": "2", "question_type": "选择题", "content_blocks": [{"block_type": "text", "content": "英语题"}], "has_formula": False, "has_image": False, "knowledge_tags": ["语法"], - }], {"original_filename": "b.pdf", "subject": "英语"}) + }], {"original_filename": "b.pdf", "subject": "英语"}, user_id=TEST_USER_ID) resp = client.get("/api/dashboard-stats") data = resp.get_json() assert data["total_questions"] == 2 @@ -451,7 +468,7 @@ def test_filter_by_review_status(self, client, test_db): "content_blocks": [{"block_type": "text", "content": "已掌握题"}], "has_formula": False, "has_image": False, }, - ], {"original_filename": "a.pdf", "subject": "数学"}) + ], {"original_filename": "a.pdf", "subject": "数学"}, user_id=TEST_USER_ID) from db.models import Question qs = test_db.query(Question).all() update_review_status(test_db, qs[1].id, "已掌握") @@ -492,7 +509,7 @@ def test_success(self, client, test_db): "content_blocks": [{"block_type": "text", "content": "AI分析测试"}], "has_formula": False, "has_image": False, "knowledge_tags": ["导数"], - }], {"original_filename": "a.pdf", "subject": "数学"}) + }], {"original_filename": "a.pdf", "subject": "数学"}, user_id=TEST_USER_ID) from db.models import Question q = test_db.query(Question).first() resp = client.post("/api/ai-analysis", json={"question_ids": [q.id]}) diff --git a/backend/tests/test_workflow_helpers.py b/backend/tests/test_workflow_helpers.py index 57b648f1..27b948ed 100644 --- a/backend/tests/test_workflow_helpers.py +++ b/backend/tests/test_workflow_helpers.py @@ -519,7 +519,7 @@ class TestIdentifySubject: @pytest.fixture(autouse=True) def _mock_llm(self): with patch( - "error_correction_agent.agent.detect_subject_via_llm", + "agents.error_correction.agent.detect_subject_via_llm", return_value="", ) as mock: self.mock_llm = mock @@ -655,7 +655,7 @@ def _make_ocr(self, text: str) -> list: }] }] - @patch("error_correction_agent.agent.detect_subject_via_llm", return_value="高中数学") + @patch("agents.error_correction.agent.detect_subject_via_llm", return_value="高中数学") def test_llm_success(self, mock_llm): """LLM 返回有效科目时直接采用""" ocr = self._make_ocr("普通内容无关键词") @@ -663,14 +663,14 @@ def test_llm_success(self, mock_llm): assert result == "高中数学" mock_llm.assert_called_once() - @patch("error_correction_agent.agent.detect_subject_via_llm", return_value="初中地理") + @patch("agents.error_correction.agent.detect_subject_via_llm", return_value="初中地理") def test_llm_returns_new_subject(self, mock_llm): """LLM 返回不在 db_subjects 中的新科目也应采用""" ocr = self._make_ocr("普通内容") result = _identify_subject(ocr, ["高中数学"]) assert result == "初中地理" - @patch("error_correction_agent.agent.detect_subject_via_llm", return_value="") + @patch("agents.error_correction.agent.detect_subject_via_llm", return_value="") def test_llm_empty_fallback_to_keyword(self, mock_llm): """LLM 返回空时应 fallback 到关键词匹配""" ocr = self._make_ocr("数学试卷") @@ -678,7 +678,7 @@ def test_llm_empty_fallback_to_keyword(self, mock_llm): assert result == "高中数学" @patch( - "error_correction_agent.agent.detect_subject_via_llm", + "agents.error_correction.agent.detect_subject_via_llm", side_effect=Exception("API error"), ) def test_llm_exception_fallback(self, mock_llm): @@ -687,7 +687,7 @@ def test_llm_exception_fallback(self, mock_llm): result = _identify_subject(ocr, []) assert result == "高中物理" - @patch("error_correction_agent.agent.detect_subject_via_llm", return_value="高中物理") + @patch("agents.error_correction.agent.detect_subject_via_llm", return_value="高中物理") def test_llm_receives_provider(self, mock_llm): """model_provider 应正确传递到 LLM 函数""" ocr = self._make_ocr("普通内容") diff --git a/backend/web_app.py b/backend/web_app.py index 8411ea0c..6e576aba 100644 --- a/backend/web_app.py +++ b/backend/web_app.py @@ -1,56 +1,66 @@ """ -错题本生成系统 - Web应用 -提供前端界面用于执行工作流 +错题本生成系统 - Web应用(纯 API 服务) + +职责: + - 创建 Flask 应用实例,配置全局参数 + - 注册所有 Blueprint 路由(routes/ 目录) + - 提供全局错误处理和登录鉴权中间件 + - 提供 OCR 图片、下载文件、擦除结果等静态资源接口 + +前端由 Vite dev server 独立运行(localhost:5173), +通过代理将 /api 等请求转发到本服务(localhost:5001)。 """ import os -import json -import uuid +import sys import logging -import threading -import mimetypes -from datetime import datetime -from pathlib import PurePath -from typing import Optional -import requests as http_requests +# 无论从项目根目录执行 `python backend/web_app.py` 还是在 `backend` 下执行 `python web_app.py`, +# 都把 backend 目录加入 sys.path,保证 `core`、`routes`、`db` 等包解析一致。 +_BACKEND_ROOT = os.path.dirname(os.path.abspath(__file__)) +if _BACKEND_ROOT not in sys.path: + sys.path.insert(0, _BACKEND_ROOT) -# Windows 注册表可能将 .js 映射为 text/plain,导致浏览器拒绝加载 ES module -mimetypes.add_type('application/javascript', '.js') -from flask import Flask, request, jsonify, send_file, send_from_directory, redirect, session +from flask import Flask, request, jsonify, send_file, session +from flask_cors import CORS from dotenv import load_dotenv -from werkzeug.security import generate_password_hash, check_password_hash -from src.workflow import build_workflow -from src.utils import export_wrongbook as export_wrongbook_md -from config import settings +from core.config import settings from db import init_db, SessionLocal from db import crud -from db.models import Question, UploadBatch, KnowledgeTag +from routes import register_routes -# 加载环境变量 -load_dotenv() +# 加载 backend/.env(无论从哪个目录启动都指向同一文件) +load_dotenv(os.path.join(_BACKEND_ROOT, ".env")) +# 模块级日志记录器,日志名称为 'web_app' logger = logging.getLogger(__name__) +# ============================================================ +# Flask 应用初始化 +# ============================================================ + app = Flask(__name__) + +# 会话密钥:用于加密 Flask session cookie,生产环境必须修改 app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-change-in-production') -# 配置(统一从 config.py 导入) +# 跨域支持:前端 dev server (5173) 和后端 (5001) 端口不同,需要 CORS +# supports_credentials=True 允许携带 cookie(登录态依赖 session cookie) +CORS(app, supports_credentials=True) + +# 文件上传配置 app.config['UPLOAD_FOLDER'] = settings.upload_dir -app.config['MAX_CONTENT_LENGTH'] = settings.max_file_size_mb * 1024 * 1024 +app.config['MAX_CONTENT_LENGTH'] = settings.max_file_size_mb * 1024 * 1024 # MB → 字节 -def _safe_join(base_dir: str, rel_path: str) -> str | None: - base = os.path.abspath(base_dir) - target = os.path.abspath(os.path.join(base, rel_path)) - if os.path.normcase(target).startswith(os.path.normcase(base + os.sep)): - return target - return None +# ============================================================ +# 全局错误处理 +# ============================================================ @app.errorhandler(413) def request_entity_too_large(error): - """文件大小超出Flask限制""" + """上传文件超出 MAX_CONTENT_LENGTH 限制时触发""" return jsonify({ 'success': False, 'error': f'文件大小超出限制,最大允许 {settings.max_file_size_mb}MB' @@ -59,7 +69,7 @@ def request_entity_too_large(error): @app.errorhandler(404) def not_found(error): - """页面未找到""" + """请求的路由不存在""" return jsonify({ 'success': False, 'error': '请求的资源不存在' @@ -68,714 +78,112 @@ def not_found(error): @app.errorhandler(500) def internal_error(error): - """服务器内部错误""" + """未捕获的服务端异常""" return jsonify({ 'success': False, 'error': '服务器内部错误,请稍后重试' }), 500 -# 全局工作流图(带 MemorySaver,通过 thread_id 管理会话状态) -workflow_graph = build_workflow() -current_thread_id = None -session_files = {} -session_file_order = [] -cancelled_file_keys = set() -session_lock = threading.Lock() - - -def allowed_file(filename): - """检查文件扩展名是否允许""" - return PurePath(filename).suffix.lower().lstrip('.') in settings.allowed_extensions - - -def _read_split_subject() -> Optional[str]: - """从 split_metadata.json 读取学科信息""" - meta_path = os.path.join(str(settings.results_dir), "split_metadata.json") - if os.path.exists(meta_path): - with open(meta_path, 'r', encoding='utf-8') as f: - return json.load(f).get("subject") - return None - - -def _serialize_split_record(r) -> dict: - """将 SplitRecord ORM 对象序列化为前端 JSON 格式""" - return { - "id": r.id, - "subject": r.subject, - "model_provider": r.model_provider, - "file_names": json.loads(r.file_names_json) if r.file_names_json else [], - "question_count": r.question_count, - "created_at": r.created_at.isoformat() if r.created_at else None, - } - - -def _serialize_question(q: Question) -> dict: - """将 Question ORM 对象序列化为前端 JSON 格式""" - subject = None - if q.batch: - subject = q.batch.subject - knowledge_tags = [] - if q.tags: - for mapping in q.tags: - if mapping.tag: - knowledge_tags.append(mapping.tag.tag_name) - - return { - 'id': q.id, - 'question_type': q.question_type, - 'content_json': json.loads(q.content_json) if q.content_json else [], - 'options_json': json.loads(q.options_json) if q.options_json else None, - 'has_formula': q.has_formula, - 'has_image': q.has_image, - 'needs_correction': q.needs_correction, - 'answer': q.answer, - 'subject': subject, - 'knowledge_tags': knowledge_tags, - 'created_at': q.created_at.isoformat() if q.created_at else None, - } - - -def _serialize_chat_session(session) -> dict: - """将 ChatSession ORM 对象序列化为前端 JSON""" - return { - 'id': session.id, - 'question_id': session.question_id, - 'created_at': session.created_at.isoformat() if session.created_at else None, - 'updated_at': session.updated_at.isoformat() if session.updated_at else None, - } - - -def _serialize_question_detail(q: Question) -> dict: - """将 Question ORM 对象序列化为详情 JSON(含科目、标签、答案等)""" - base = _serialize_question(q) - # subject / knowledge_tags already set by _serialize_question - base['original_filename'] = q.batch.original_filename if q.batch else None - base['user_answer'] = q.user_answer - base['updated_at'] = q.updated_at.isoformat() if q.updated_at else None - base['review_status'] = q.review_status or '待复习' - base['image_refs_json'] = json.loads(q.image_refs_json) if q.image_refs_json else None - return base - - # ============================================================ -# 前端托管(生产模式:直接返回 Vite 构建产物) -# ============================================================ -FRONTEND_DIST = os.path.join(settings.project_root, 'frontend', 'dist') - - - - -# ============================================================ -# 认证工具 +# 全局中间件 # ============================================================ @app.before_request def require_login(): - """所有 /api/ 路由(除 /api/auth/ 和 /api/status)要求登录""" + """登录鉴权:所有 /api/ 路由默认要求登录 + + 豁免列表: + - /api/auth/* — 登录、注册、获取当前用户等认证接口 + - /api/status — 系统状态查询(前端初始化时需要) + """ if request.path.startswith('/api/'): if request.path.startswith('/api/auth/') or request.path == '/api/status': return None if 'user_id' not in session: return jsonify({'error': '请先登录', 'code': 'UNAUTHORIZED'}), 401 + # 密码重置后失效旧 session:仅在写操作时查 DB 比对 session_version, + # GET 请求无需每次查库,降低高频只读接口的数据库压力 + if request.method != 'GET': + user_id = session['user_id'] + sess_ver = session.get('session_version') + with SessionLocal() as db: + user = crud.get_user_by_id(db, user_id) + if not user: + session.clear() + return jsonify({'error': '用户不存在', 'code': 'UNAUTHORIZED'}), 401 + db_ver = user.session_version or 0 + if sess_ver != db_ver: + session.clear() + return jsonify({'error': '登录已失效,请重新登录', 'code': 'SESSION_EXPIRED'}), 401 return None -@app.route('/api/auth/register', methods=['POST']) -def auth_register(): - """用户注册""" - data = request.get_json() or {} - email = (data.get('email') or '').strip().lower() - username = (data.get('username') or '').strip() - password = data.get('password') or '' - - if not email or '@' not in email: - return jsonify({'error': '邮箱格式不正确'}), 400 - if not username: - return jsonify({'error': '用户名不能为空'}), 400 - if len(password) < 6: - return jsonify({'error': '密码至少 6 位'}), 400 - - with SessionLocal() as db: - if crud.get_user_by_email(db, email): - return jsonify({'error': '该邮箱已注册'}), 409 - pwd_hash = generate_password_hash(password) - user = crud.create_user(db, email=email, password_hash=pwd_hash, username=username) - session['user_id'] = user.id - session['username'] = user.username - return jsonify({'user': {'id': user.id, 'email': user.email, 'username': user.username, 'is_admin': user.is_admin}}), 201 - - -@app.route('/api/auth/login', methods=['POST']) -def auth_login(): - """用户登录(支持邮箱或用户名)""" - data = request.get_json() or {} - identifier = (data.get('email') or data.get('identifier') or '').strip() - password = data.get('password') or '' - - if not identifier: - return jsonify({'error': '请输入邮箱或用户名'}), 400 - - with SessionLocal() as db: - user = crud.get_user_by_login(db, identifier) - if not user or not check_password_hash(user.password_hash, password): - return jsonify({'error': '账号或密码错误'}), 401 - session['user_id'] = user.id - session['username'] = user.username - return jsonify({'user': {'id': user.id, 'email': user.email, 'username': user.username, 'is_admin': user.is_admin}}) - - -@app.route('/api/auth/logout', methods=['POST']) -def auth_logout(): - """退出登录""" - session.clear() - return jsonify({'ok': True}) - - -@app.route('/api/auth/me', methods=['GET']) -def auth_me(): - """获取当前登录用户""" - user_id = session.get('user_id') - if not user_id: - return jsonify({'error': '未登录', 'code': 'UNAUTHORIZED'}), 401 - with SessionLocal() as db: - user = crud.get_user_by_id(db, user_id) - if not user: - session.clear() - return jsonify({'error': '用户不存在', 'code': 'UNAUTHORIZED'}), 401 - return jsonify({'user': {'id': user.id, 'email': user.email, 'username': user.username, 'is_admin': user.is_admin}}) - - -@app.route('/') -def index(): - """主页 - 返回 Vue SPA(落地页由前端路由 / 渲染)""" - return send_from_directory(FRONTEND_DIST, 'app.html') - - -@app.route('/app.html') -def app_page_redirect(): - """旧路径重定向到规范 URL""" - return redirect('/app', code=301) - - -@app.route('/app') -@app.route('/app/') -def app_page(subpath=''): - """工作台及子路由 - 返回 Vue SPA""" - return send_from_directory(FRONTEND_DIST, 'app.html') - - -@app.route('/auth') -def auth_page(): - """登录/注册页 - 返回 Vue SPA""" - return send_from_directory(FRONTEND_DIST, 'app.html') - - -@app.route('/static/vue/') -def serve_vue_dist(filename): - """提供 Vue 前端构建产物""" - return send_from_directory(FRONTEND_DIST, filename) - - # ============================================================ -# 遗留独立页面 +# 注册 Blueprint 路由 # ============================================================ +# 各 Blueprint 定义在 routes/ 目录下: +# - auth.py → /api/auth/* 用户认证 +# - upload.py → /api/* 文件上传、分割、导出 +# - questions.py → /api/* 题目 CRUD、错题库、搜索 +# - chat.py → /api/* AI 对话 +# - stats.py → /api/* 统计分析 +# - settings.py → /api/* 系统配置、模型管理 - -@app.route('/record') -def record_page(): - """错题本记录页面""" - record_file = os.path.join(settings.project_root, 'record.html') - if os.path.exists(record_file): - return send_from_directory(settings.project_root, 'record.html') - return "记录页文件不存在", 404 +register_routes(app) # ============================================================ -# API 接口 +# 静态资源服务 # ============================================================ +# 以下路由提供后端生成的文件资源,前端通过 Vite 代理访问: +# /download/* — 导出的 Markdown 错题本文件 +# /images/* — PaddleOCR 解析出的图片(存储在 struct_dir/imgs/) +# /erased/* — EnsExam 擦除手写字迹后的图片 +def _safe_join(base_dir: str, rel_path: str) -> str | None: + """安全路径拼接,防止目录遍历攻击(如 ../../etc/passwd) -@app.route('/api/upload', methods=['POST']) -def upload_file(): - """处理文件上传(支持多文件)。 - - 这里只负责把原始文件保存到 uploads 并登记到会话中; - 标准化(PDF/图片 → 图片列表) + OCR + 分割,会在 /api/split 里由用户点击“开始分割”后统一触发。 - - Returns: - JSON响应,包含上传结果 - """ - # 支持多文件:前端用 'files' 字段发送,兼容单文件 'file' 字段 - files = request.files.getlist('files') - if not files: - files = request.files.getlist('file') - if not files or all(f.filename == '' for f in files): - return jsonify({'error': '没有上传文件'}), 400 - - # 校验每个文件 - for file in files: - if file.filename == '': - continue - - if not allowed_file(file.filename): - return jsonify({ - 'error': f'不支持的文件格式: {file.filename}。支持: {", ".join(settings.allowed_extensions)}' - }), 400 - - file.seek(0, 2) - file_size = file.tell() - file.seek(0) - file_size_mb = file_size / (1024 * 1024) - - if file_size_mb > settings.max_file_size_mb: - return jsonify({ - 'error': f'{file.filename} 大小为 {file_size_mb:.1f}MB,超出最大限制 {settings.max_file_size_mb}MB' - }), 400 - - if file_size == 0: - return jsonify({ - 'error': f'{file.filename} 为空文件,请重新选择' - }), 400 - - try: - global current_thread_id, session_files, session_file_order - - file_keys = request.form.getlist('file_key') - if not file_keys: - file_keys = request.form.getlist('file_keys') - - prepared = [] - for i, file in enumerate(files): - if file.filename == '': - continue - fk = file_keys[i] if i < len(file_keys) and file_keys[i] else None - prepared.append((fk, file)) - - if not prepared: - return jsonify({'error': '没有上传文件'}), 400 - - results_out = [] - for fk, file in prepared: - file_key = fk or f"{uuid.uuid4().hex}" - - original_ext = os.path.splitext(file.filename)[1].lower().lstrip('.') - filename = f"{uuid.uuid4().hex}.{original_ext}" - filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) - file.save(filepath) - - with session_lock: - cancelled = file_key in cancelled_file_keys - if cancelled: - cancelled_file_keys.discard(file_key) - - if cancelled: - try: - os.remove(filepath) - except FileNotFoundError: - pass - continue - - with session_lock: - if current_thread_id is not None: - current_thread_id = None - session_files = {} - session_file_order = [] - - session_files[file_key] = { - "filename": file.filename, - "filepath": filepath, - } - if file_key not in session_file_order: - session_file_order.append(file_key) - - results_out.append({ - "file_key": file_key, - "filename": file.filename, - }) - - return jsonify({ - 'success': True, - 'message': '上传成功', - 'result': { - 'file_count': len(results_out), - 'files': results_out, - } - }) - - except FileNotFoundError: - return jsonify({ - 'success': False, - 'error': '文件保存失败,请重新上传' - }), 500 - except Exception as e: - logger.exception("文件处理失败") - return jsonify({ - 'success': False, - 'error': '文件处理失败,请稍后重试' - }), 500 - - -@app.route('/api/cancel_file', methods=['POST']) -def cancel_file(): - try: - global current_thread_id, session_files, session_file_order - - data = request.get_json(silent=True) or {} - file_key = data.get('file_key') - if not file_key: - return jsonify({ - 'success': False, - 'error': '缺少 file_key' - }), 400 - - if current_thread_id is not None: - return jsonify({ - 'success': False, - 'error': '已开始分割,无法撤销单个文件;请重置后重新上传' - }), 400 - - filepath = None - existed = False - with session_lock: - cancelled_file_keys.add(file_key) - - existed = file_key in session_files - v = session_files.pop(file_key, None) or {} - filepath = v.get('filepath') - if file_key in session_file_order: - session_file_order.remove(file_key) - - cancelled_file_keys.discard(file_key) - - if filepath: - try: - os.remove(filepath) - except FileNotFoundError: - pass - - return jsonify({ - 'success': True, - 'message': '已撤销该文件' if existed else '已标记撤销该文件', - }) - - except Exception as e: - logger.exception("撤销文件失败") - return jsonify({ - 'success': False, - 'error': '撤销失败,请稍后重试' - }), 500 - - -@app.route('/api/split', methods=['POST']) -def split_questions(): - """开始执行标准化 + OCR + 分割。 - - 用户点击前端“开始分割题目”后调用该接口: - - 标准化输入(PDF/图片 → 图片列表) - - 触发 Agent/OCR 并分割题目 - - Returns: - JSON响应,包含分割后的题目 - """ - try: - global workflow_graph, current_thread_id, session_files, session_file_order - - # 读取请求体参数(模型供应商 + 模型名称) - data = request.get_json(silent=True) or {} - model_provider = data.get("model_provider", "openai") - model_name = data.get("model_name") # 可选,None 时使用 provider 默认模型 - if not settings.is_valid_provider(model_provider): - return jsonify({ - 'success': False, - 'error': f'不支持的模型供应商: {model_provider}' - }), 400 - - # 从数据库加载用户的 LLM + OCR 凭据 - user_id = session.get('user_id') - ocr_credentials = {} - if user_id: - settings.load_providers_from_db(user_id) - with SessionLocal() as db: - ocr_provider = crud.get_active_provider(db, user_id, 'paddleocr') - if ocr_provider: - ocr_credentials = { - "api_url": ocr_provider.base_url, - "token": ocr_provider.api_key, - "model": ocr_provider.model_name, - "use_doc_orientation": ocr_provider.use_doc_orientation, - "use_doc_unwarping": ocr_provider.use_doc_unwarping, - "use_chart_recognition": ocr_provider.use_chart_recognition, - } - - with session_lock: - keys = list(session_file_order) - file_paths = [] - for k in keys: - v = session_files.get(k) or {} - fp = v.get('filepath') - if fp: - file_paths.append(fp) - - if not file_paths: - return jsonify({ - 'success': False, - 'error': '请先上传文件' - }), 400 - - # 擦除手写字迹(EnsExam 已接入且前端未关闭擦除开关时执行) - erase = data.get("erase", True) - ensexam_configured = settings.model_path.exists() - if ensexam_configured and erase: - try: - from models.inference import InferenceEngine - engine = InferenceEngine() - erased_paths = [] - for fp in file_paths: - # 如果是 PDF 转换为图片的临时路径,或者是直接上传的图片 - with open(fp, 'rb') as f: - img_bytes = f.read() - result_img = engine.run(img_bytes) - - # 生成擦除后的文件名,保存在 erased_dir - erased_name = f"auto_{uuid.uuid4().hex[:8]}_{os.path.basename(fp)}" - if not erased_name.lower().endswith(('.png', '.jpg', '.jpeg')): - erased_name += '.png' - - erased_path = settings.erased_dir / erased_name - result_img.save(str(erased_path), format='PNG') - erased_paths.append(str(erased_path)) - - file_paths = erased_paths - logger.info("EnsExam 已接入,自动擦除 %d 张图片的手写字迹", len(erased_paths)) - except Exception: - logger.exception("自动擦除手写字迹失败,使用原图继续流程") - - current_thread_id = str(uuid.uuid4()) - config = {"configurable": {"thread_id": current_thread_id}} - - initial_state = { - "file_paths": file_paths, - "model_provider": model_provider, - "model_name": model_name, - "ocr_credentials": ocr_credentials, - } - workflow_graph.invoke(initial_state, config=config) - state = workflow_graph.invoke(None, config=config) - - questions = state.get('questions', []) - warnings = state.get('warnings', []) - - # 自动保存分割记录 - try: - subject = _read_split_subject() - - with session_lock: - file_names = [ - session_files.get(k, {}).get("filename", "未知") - for k in session_file_order - ] - - with SessionLocal() as db: - crud.save_split_record(db, subject, model_provider, file_names, questions, user_id=session.get('user_id')) - except Exception: - logger.warning("保存分割记录失败,不影响主流程", exc_info=True) - - return jsonify({ - 'success': True, - 'message': f'成功分割 {len(questions)} 道题目', - 'questions': questions, - 'warnings': warnings, - }) - - except Exception as e: - logger.exception("题目分割失败") - return jsonify({ - 'success': False, - 'error': '题目分割失败,请稍后重试' - }), 500 - - -@app.route('/api/split-records', methods=['GET']) -def get_split_records(): - """获取最近 N 次分割历史记录,limit 由前端通过查询参数指定""" - try: - limit = request.args.get('limit', 10, type=int) - limit = max(1, min(limit, crud.MAX_SPLIT_RECORDS)) # 上限与保留条数一致 - - with SessionLocal() as db: - records = crud.get_recent_split_records(db, limit, user_id=session.get('user_id')) - result = [_serialize_split_record(r) for r in records] - - return jsonify({"success": True, "records": result}) - - except Exception as e: - logger.exception("获取分割记录失败") - return jsonify({"success": False, "error": "获取分割记录失败"}), 500 - - -@app.route('/api/split-records/', methods=['GET']) -def get_split_record_detail(record_id): - """获取单条分割记录的完整数据(含 questions)""" - try: - with SessionLocal() as db: - record = crud.get_split_record_by_id(db, record_id) - if not record: - return jsonify({"success": False, "error": "记录不存在"}), 404 - - result = _serialize_split_record(record) - result["questions"] = json.loads(record.questions_json) if record.questions_json else [] - - return jsonify({"success": True, "record": result}) - - except Exception as e: - logger.exception("获取分割记录详情失败") - return jsonify({"success": False, "error": "获取分割记录详情失败"}), 500 - - -@app.route('/api/export', methods=['POST']) -def export_wrongbook(): - """ - 注入选中题目 ID 并恢复图执行导出 - - 图执行: export → END - - Returns: - JSON响应,包含导出文件路径 - """ - try: - global current_thread_id, session_files, session_file_order - - data = request.get_json(silent=True) or {} - selected_ids = data.get('selected_ids', []) - - if not isinstance(selected_ids, list): - return jsonify({ - 'success': False, - 'error': 'selected_ids 必须为列表' - }), 400 - - if not selected_ids: - return jsonify({ - 'success': False, - 'error': '未选择任何题目' - }), 400 - - # 检查是否已分割(通过 questions.json 存在性判断,不依赖内存中的 current_thread_id) - results_dir = settings.results_dir - questions_file = os.path.join(results_dir, "questions.json") - if not os.path.exists(questions_file): - return jsonify({ - 'success': False, - 'error': '请先分割题目' - }), 400 - - with open(questions_file, 'r', encoding='utf-8') as f: - questions = json.load(f) - - output_path = export_wrongbook_md( - questions, - selected_ids, - ) - - return jsonify({ - 'success': True, - 'message': '错题本导出成功', - 'output_path': output_path - }) - - except Exception as e: - logger.exception("导出失败") - return jsonify({ - 'success': False, - 'error': '导出失败,请稍后重试' - }), 500 - - -@app.route('/api/questions', methods=['GET']) -def get_questions(): - """ - 获取已分割的题目列表 - - Returns: - JSON响应,包含题目列表 + 将 rel_path 拼接到 base_dir 后,检查结果路径是否仍在 base_dir 内。 + 如果路径跳出了 base_dir,返回 None。 """ - try: - results_dir = settings.results_dir - questions_file = os.path.join(results_dir, "questions.json") - - if not os.path.exists(questions_file): - return jsonify({ - 'success': True, - 'questions': [], - 'message': '暂无题目数据' - }) - - with open(questions_file, 'r', encoding='utf-8') as f: - questions = json.load(f) - - return jsonify({ - 'success': True, - 'questions': questions - }) - - except json.JSONDecodeError: - return jsonify({ - 'success': False, - 'error': '题目数据文件格式错误,请重新分割题目' - }), 500 - except Exception as e: - logger.exception("获取题目列表失败") - return jsonify({ - 'success': False, - 'error': '获取题目列表失败,请稍后重试' - }), 500 - - -@app.route('/preview') -def preview(): - """显示预览页面""" - results_dir = settings.results_dir - preview_file = os.path.join(results_dir, "preview.html") - - if os.path.exists(preview_file): - with open(preview_file, 'r', encoding='utf-8') as f: - content = f.read() - return content - else: - return "预览文件不存在,请先分割题目", 404 + base = os.path.abspath(base_dir) + target = os.path.abspath(os.path.join(base, rel_path)) + if os.path.normcase(target).startswith(os.path.normcase(base + os.sep)): + return target + return None -# ============================================================ -# 资源服务 -# ============================================================ +@app.route('/uploads/') +def serve_upload(filename): + """提供用户上传的原始文件(笔记图片等)""" + file_path = _safe_join(str(settings.upload_dir), filename) + if not file_path or not os.path.exists(file_path): + return jsonify({'success': False, 'error': '文件不存在'}), 404 + return send_file(file_path) @app.route('/download/') def download_file(filename): - """下载结果文件""" - results_dir = settings.results_dir - file_path = _safe_join(results_dir, filename) - if not file_path: - return jsonify({ - 'success': False, - 'error': '非法文件路径' - }), 400 + """下载结果文件(如导出的 Markdown 错题本) + 禁用浏览器缓存,确保每次下载都是最新版本。 + """ + file_path = _safe_join(settings.results_dir, filename) + if not file_path: + return jsonify({'success': False, 'error': '非法文件路径'}), 400 if not os.path.exists(file_path): - return jsonify({ - 'success': False, - 'error': '文件不存在' - }), 404 + return jsonify({'success': False, 'error': '文件不存在'}), 404 resp = send_file( file_path, - as_attachment=True, - download_name=os.path.basename(filename), + as_attachment=True, # 触发浏览器下载而非预览 + download_name=os.path.basename(filename), # 下载时显示的文件名 conditional=False, etag=False, max_age=0, ) + # 强制禁用缓存,避免浏览器返回旧文件 resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' resp.headers['Pragma'] = 'no-cache' resp.headers['Expires'] = '0' @@ -784,7 +192,11 @@ def download_file(filename): @app.route('/images/') def serve_image(filename): - """提供 OCR 解析出的图片资源""" + """提供 PaddleOCR 解析出的图片资源 + + OCR 处理后的结构化图片存储在 struct_dir/imgs/ 目录下, + 前端题目卡片中的图片通过此接口加载。 + """ base = os.path.join(settings.struct_dir, "imgs") file_path = _safe_join(base, filename) if not file_path or not os.path.exists(file_path): @@ -794,1108 +206,39 @@ def serve_image(filename): @app.route('/erased/') def serve_erased_image(filename): - """提供擦除结果图片""" + """提供 EnsExam 擦除手写字迹后的图片 + + 用户上传试卷后,EnsExam 模型会擦除手写笔迹, + 还原干净的题目底图,结果保存在 erased_dir。 + """ file_path = _safe_join(str(settings.erased_dir), filename) if not file_path or not os.path.exists(file_path): return jsonify({'success': False, 'error': '图片不存在'}), 404 return send_file(file_path) -@app.route('/api/models/erase', methods=['POST']) -def erase_text(): - """文字擦除接口 - - 使用 GAN 模型将试卷图片中的手写笔迹擦除,还原干净的题目底图。 - - Request: - multipart/form-data,字段 file:图片文件(JPEG/PNG/BMP 等) - - Response: - {"success": true, "result_url": "/erased/"} - """ - if 'file' not in request.files: - return jsonify({'success': False, 'error': '缺少 file 字段'}), 400 - - file = request.files['file'] - if not file or not file.filename: - return jsonify({'success': False, 'error': '文件为空'}), 400 - - ext = os.path.splitext(file.filename)[1].lower() or '.png' - allowed = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'} - if ext not in allowed: - return jsonify({'success': False, 'error': f'不支持的图片格式: {ext}'}), 400 - - try: - image_bytes = file.read() - except Exception: - return jsonify({'success': False, 'error': '读取文件失败'}), 400 - - try: - from models.inference import InferenceEngine - - result_img = InferenceEngine().run(image_bytes) - - filename = uuid.uuid4().hex + '.png' - save_path = settings.erased_dir / filename - result_img.save(save_path, format='PNG') - - return jsonify({'success': True, 'result_url': f'/erased/{filename}'}) - - except FileNotFoundError as e: - logger.error("模型权重缺失: %s", e) - return jsonify({'success': False, 'error': str(e)}), 503 - except Exception: - logger.exception("文字擦除失败") - return jsonify({'success': False, 'error': '擦除处理失败,请稍后重试'}), 500 - - -@app.route('/api/status', methods=['GET']) -def get_status(): - """ - 获取系统状态 - - Returns: - JSON响应,包含系统配置和状态 - """ - try: - user_id = session.get('user_id') - - # 检查 EnsExam 模型权重是否存在 - ensexam_configured = settings.model_path.exists() - - if user_id: - # 已登录:从数据库读取用户配置 - with SessionLocal() as db: - paddle_provider = crud.get_active_provider(db, user_id, 'paddleocr') - paddleocr_configured = bool(paddle_provider and paddle_provider.api_key and paddle_provider.base_url) - - # 构建用户级可用模型列表 - available_models = [] - for category, label in [('openai', 'OpenAI'), ('anthropic', 'Anthropic')]: - provider = crud.get_active_provider(db, user_id, category) - if provider and provider.api_key: - available_models.append({ - 'value': category, - 'label': provider.name or label, - 'configured': True, - 'status': '配置成功', - 'default_model': provider.model_name or '', - 'models': [provider.model_name] if provider.model_name else [], - }) - else: - available_models.append({ - 'value': category, - 'label': label, - 'configured': False, - 'status': '未配置', - 'default_model': '', - 'models': [], - }) - else: - # 未登录:返回空状态 - paddleocr_configured = False - available_models = [] - - status = { - 'paddleocr_configured': paddleocr_configured, - 'ensexam_configured': ensexam_configured, - 'available_models': available_models, - 'langsmith_enabled': os.getenv('LANGSMITH_TRACING', 'false').lower() == 'true', - 'output_dirs': { - 'pages': str(settings.pages_dir), - 'struct': str(settings.struct_dir), - 'results': str(settings.results_dir), - } - } - - return jsonify({ - 'success': True, - 'status': status - }) - - except Exception as e: - logger.exception("获取系统状态失败") - return jsonify({ - 'success': False, - 'error': '获取系统状态失败,请稍后重试' - }), 500 - - -@app.route('/api/config', methods=['GET']) -def get_config(): - """获取当前用户的 provider 配置""" - try: - user_id = session.get('user_id') - if not user_id: - return jsonify({'success': False, 'error': '请先登录'}), 401 - with SessionLocal() as db: - config = crud.get_user_providers(db, user_id) - return jsonify({'success': True, 'config': config}) - except Exception as e: - logger.exception("获取配置失败") - return jsonify({'success': False, 'error': '获取配置失败'}), 500 - - -@app.route('/api/config', methods=['PUT']) -def update_config(): - """保存当前用户的 provider 配置到数据库""" - try: - data = request.get_json(force=True) or {} - user_id = session.get('user_id') - - if not user_id: - return jsonify({'success': False, 'error': '请先登录'}), 401 - - with SessionLocal() as db: - try: - crud.save_user_providers(db, user_id, data) - except Exception: - db.rollback() - raise - return jsonify({'success': True}) - - except Exception as e: - logger.exception("更新配置失败") - return jsonify({'success': False, 'error': '更新配置失败,请检查日志'}), 500 - - -@app.route('/api/models/list', methods=['POST']) -def list_models(): - """代理请求目标 API 的模型列表,绕过浏览器 CORS 限制。 - - 请求体: - type: 'openai' | 'anthropic' - api_key: API Key(可选,留空则使用系统已配置的 key) - base_url: Base URL(可选) - provider_id: 已有 provider 的 id(可选,用于回退读取已存 key) - """ - try: - data = request.get_json(force=True) or {} - provider_type = data.get('type', 'openai') - api_key = data.get('api_key') or '' - base_url = data.get('base_url') or '' - - # 如果前端没传 key,从数据库读取已保存的配置 - if not api_key: - user_id = session.get('user_id') - provider_id = data.get('provider_id') - if user_id: - with SessionLocal() as db: - if provider_id: - provider = db.query(crud.ProviderConfig).filter_by( - id=provider_id, user_id=user_id - ).first() - else: - provider = crud.get_active_provider(db, user_id, provider_type) - if provider: - if not api_key: - api_key = provider.api_key or '' - if not base_url: - base_url = provider.base_url or '' - - if not api_key: - return jsonify({'error': '未提供 API Key,请先在设置中配置'}), 400 - - if provider_type == 'openai': - url = (base_url.rstrip('/') if base_url else 'https://api.openai.com') + '/v1/models' - headers = {'Authorization': f'Bearer {api_key}'} - resp = http_requests.get(url, headers=headers, timeout=15) - - # 部分 OpenAI 兼容 API 的路径可能不带 /v1 - if resp.status_code == 404: - url_alt = (base_url.rstrip('/') if base_url else 'https://api.openai.com') + '/models' - resp = http_requests.get(url_alt, headers=headers, timeout=15) - - if resp.status_code != 200: - return jsonify({'error': f'API 返回 {resp.status_code}: {resp.text[:200]}'}), 502 - - body = resp.json() - models = sorted([m['id'] for m in body.get('data', [])]) if 'data' in body else [] - return jsonify({'models': models}) - - elif provider_type == 'anthropic': - # Anthropic 没有官方 list models 接口,返回常用模型列表 - models = [ - 'claude-opus-4-20250514', - 'claude-sonnet-4-20250514', - 'claude-haiku-4-20250506', - 'claude-3-5-sonnet-20241022', - 'claude-3-5-haiku-20241022', - 'claude-3-opus-20240229', - ] - return jsonify({'models': models}) - - else: - return jsonify({'error': f'不支持的 provider 类型: {provider_type}'}), 400 - - except http_requests.Timeout: - return jsonify({'error': '请求超时,请检查 Base URL 是否正确'}), 504 - except http_requests.ConnectionError: - return jsonify({'error': '无法连接目标 API,请检查 Base URL'}), 502 - except Exception as e: - logger.exception("获取模型列表失败") - return jsonify({'error': str(e)}), 500 - - -@app.route('/api/paddleocr/test', methods=['POST']) -def test_paddleocr(): - """测试 PaddleOCR API Token 是否可用。 - - 请求体: - api_token: API Token(可选,留空则从数据库读取) - api_url: API URL(可选,留空则从数据库读取) - provider_id: 已有 provider 的 id(可选,用于回退读取已存 token) - """ - try: - data = request.get_json(force=True) or {} - api_token = data.get('api_token') or '' - api_url = data.get('api_url') or '' - - # 前端未提供凭据时,从数据库读取已保存的配置 - if not api_token or not api_url: - user_id = session.get('user_id') - provider_id = data.get('provider_id') - if user_id: - with SessionLocal() as db: - if provider_id: - provider = db.query(crud.ProviderConfig).filter_by( - id=provider_id, user_id=user_id - ).first() - else: - provider = crud.get_active_provider(db, user_id, 'paddleocr') - if provider: - if not api_token: - api_token = provider.api_key or '' - if not api_url: - api_url = provider.base_url or '' - - if not api_token or not api_url: - return jsonify({'error': '未提供 API Token 或 API URL'}), 400 - - # 用 GET 请求 API URL,带 token 验证认证是否有效 - headers = {'Authorization': f'bearer {api_token}'} - resp = http_requests.get(api_url, headers=headers, timeout=10) - - # 401/403 表示 token 无效 - if resp.status_code in (401, 403): - return jsonify({'success': False, 'error': '认证失败,请检查 API Token 是否正确'}) - - # 其他非 5xx 状态码都认为 token 有效(包括 404/405 等,因为 GET 可能不被支持但认证通过了) - if resp.status_code >= 500: - return jsonify({'success': False, 'error': f'服务端错误 ({resp.status_code}),请检查 API URL'}) - - return jsonify({'success': True, 'message': 'API Token 验证通过'}) - - except http_requests.Timeout: - return jsonify({'success': False, 'error': '请求超时,请检查 API URL 是否正确'}), 504 - except http_requests.ConnectionError: - return jsonify({'success': False, 'error': '无法连接,请检查 API URL'}), 502 - except Exception as e: - logger.exception("测试 PaddleOCR 连接失败") - return jsonify({'success': False, 'error': str(e)}), 500 - - -@app.route('/api/history', methods=['GET']) -def get_history(): - """ - 分页查询历史题目(全部题目,非仅错题) - - Query参数: - page: 页码(默认1) - page_size: 每页数量(默认20) - start_date: 开始日期(可选,格式:YYYY-MM-DD) - end_date: 结束日期(可选,格式:YYYY-MM-DD) - """ - try: - page = max(1, request.args.get('page', 1, type=int)) - page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) - start_date_str = request.args.get('start_date') - end_date_str = request.args.get('end_date') - - # 解析日期 - start_date = None - end_date = None - if start_date_str: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - if end_date_str: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d') - - with SessionLocal() as db: - questions, total = crud.get_history_questions( - db, - start_date=start_date, - end_date=end_date, - page=page, - page_size=page_size - ) - - total_pages = (total + page_size - 1) // page_size - items = [_serialize_question(q) for q in questions] - - return jsonify({ - 'success': True, - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': total_pages - }) - - except ValueError: - return jsonify({'success': False, 'error': '日期格式错误,请使用 YYYY-MM-DD 格式'}), 400 - except Exception as e: - logger.exception("查询历史题目失败") - return jsonify({'success': False, 'error': '查询失败,请稍后重试'}), 500 - - -@app.route('/api/search', methods=['GET']) -def search_questions(): - """ - 搜索题目(知识点/题型/关键字) - - Query参数: - keyword: 关键字搜索(匹配题目内容) - knowledge_tag: 知识点标签筛选 - question_type: 题型筛选 - page: 页码(默认1) - page_size: 每页数量(默认20) - """ - try: - page = max(1, request.args.get('page', 1, type=int)) - page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) - keyword = request.args.get('keyword', type=str) - knowledge_tag = request.args.get('knowledge_tag', type=str) - question_type = request.args.get('question_type', type=str) - - # 至少需要一个搜索条件 - if not any([keyword, knowledge_tag, question_type]): - return jsonify({ - 'success': False, - 'error': '至少需要提供一个搜索条件(keyword/knowledge_tag/question_type)' - }), 400 - - with SessionLocal() as db: - questions, total = crud.search_questions( - db, - keyword=keyword if keyword else None, - knowledge_tag=knowledge_tag if knowledge_tag else None, - question_type=question_type if question_type else None, - page=page, - page_size=page_size - ) - - total_pages = (total + page_size - 1) // page_size - items = [_serialize_question(q) for q in questions] - - return jsonify({ - 'success': True, - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': total_pages - }) - - except Exception as e: - logger.exception("搜索题目失败") - return jsonify({'success': False, 'error': '搜索失败,请稍后重试'}), 500 - - -@app.route('/api/stats', methods=['GET']) -def get_stats(): - """ - 获取知识点统计信息 - """ - try: - subject = request.args.get('subject', type=str) or None - with SessionLocal() as db: - stats = crud.get_knowledge_stats(db, subject=subject, user_id=session.get('user_id')) - return jsonify({ - 'success': True, - 'stats': stats, - 'total_tags': len(stats) - }) - - except Exception as e: - logger.exception("获取统计失败") - return jsonify({'success': False, 'error': '获取统计失败,请稍后重试'}), 500 - - -@app.route('/api/question/', methods=['DELETE']) -def delete_question(question_id): - """ - 删除题目 - - Path参数: - question_id: 题目ID - """ - try: - with SessionLocal() as db: - success = crud.delete_question(db, question_id) - if success: - return jsonify({ - 'success': True, - 'message': '题目已删除' - }) - else: - return jsonify({ - 'success': False, - 'error': '题目不存在' - }), 404 - - except Exception as e: - logger.exception("删除题目失败") - return jsonify({'success': False, 'error': '删除失败,请稍后重试'}), 500 - - # ============================================================ -# 错题库 API +# 启动入口 # ============================================================ - -@app.route('/api/error-bank', methods=['GET']) -def get_error_bank(): - """ - 错题库统一查询 - - Query参数: - subject: 科目筛选 - knowledge_tag: 知识点标签筛选 - question_type: 题型筛选 - keyword: 关键字搜索 - start_date: 开始日期(YYYY-MM-DD) - end_date: 结束日期(YYYY-MM-DD) - page: 页码(默认1) - page_size: 每页数量(默认20) - """ - try: - page = max(1, request.args.get('page', 1, type=int)) - page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) - subject = request.args.get('subject', type=str) or None - knowledge_tag = request.args.get('knowledge_tag', type=str) or None - question_type = request.args.get('question_type', type=str) or None - keyword = request.args.get('keyword', type=str) or None - review_status = request.args.get('review_status', type=str) or None - start_date_str = request.args.get('start_date') - end_date_str = request.args.get('end_date') - - start_date = None - end_date = None - if start_date_str: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - if end_date_str: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d') - - with SessionLocal() as db: - questions, total = crud.query_questions( - db, - user_id=session.get('user_id'), - subject=subject, - knowledge_tag=knowledge_tag, - question_type=question_type, - keyword=keyword, - start_date=start_date, - end_date=end_date, - review_status=review_status, - page=page, - page_size=page_size, - ) - - total_pages = (total + page_size - 1) // page_size - items = [_serialize_question_detail(q) for q in questions] - - return jsonify({ - 'success': True, - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'total_pages': total_pages, - }) - - except ValueError: - return jsonify({'success': False, 'error': '日期格式错误,请使用 YYYY-MM-DD 格式'}), 400 - except Exception as e: - logger.exception("查询错题库失败") - return jsonify({'success': False, 'error': '查询失败,请稍后重试'}), 500 - - -@app.route('/api/subjects', methods=['GET']) -def get_subjects(): - """获取所有科目列表""" - try: - with SessionLocal() as db: - subjects = crud.get_existing_subjects(db, user_id=session.get('user_id')) - return jsonify({'success': True, 'subjects': subjects}) - except Exception as e: - logger.exception("获取科目列表失败") - return jsonify({'success': False, 'error': '获取科目列表失败'}), 500 - - -@app.route('/api/question-types', methods=['GET']) -def get_question_types(): - """获取所有题型列表""" - try: - with SessionLocal() as db: - types = crud.get_existing_question_types(db, user_id=session.get('user_id')) - return jsonify({'success': True, 'question_types': types}) - except Exception as e: - logger.exception("获取题型列表失败") - return jsonify({'success': False, 'error': '获取题型列表失败'}), 500 - - -@app.route('/api/question/', methods=['PATCH']) -def update_question(question_id): - """编辑题目内容和/或答案""" - try: - data = request.get_json(silent=True) or {} - with SessionLocal() as db: - question = db.get(Question, question_id) - if not question: - return jsonify({'success': False, 'error': '题目不存在'}), 404 - try: - if 'content' in data: - text = str(data['content'])[:20000] - blocks = [{'block_type': 'text', 'content': text}] - question.content_json = json.dumps(blocks, ensure_ascii=False) - question.content_hash = crud.compute_content_hash(blocks) - if 'answer' in data: - question.answer = str(data['answer'])[:10000] if data['answer'] else None - question.updated_at = datetime.utcnow() - db.commit() - return jsonify({'success': True}) - except Exception: - db.rollback() - raise - except Exception: - logger.exception("编辑题目失败") - return jsonify({'success': False, 'error': '保存失败,请稍后重试'}), 500 - - -@app.route('/api/question//answer', methods=['PATCH']) -def update_question_answer(question_id): - """保存用户答案""" - try: - data = request.get_json(silent=True) or {} - user_answer = data.get('user_answer') - if user_answer is None: - return jsonify({'success': False, 'error': '缺少 user_answer 字段'}), 400 - if len(user_answer) > 10000: - return jsonify({'success': False, 'error': '答案内容过长(最多 10000 字符)'}), 400 - - with SessionLocal() as db: - question = crud.update_user_answer(db, question_id, user_answer) - if not question: - return jsonify({'success': False, 'error': '题目不存在'}), 404 - - return jsonify({ - 'success': True, - 'message': '答案已保存', - 'user_answer': question.user_answer, - 'updated_at': question.updated_at.isoformat() if question.updated_at else None, - }) - - except Exception as e: - logger.exception("保存答案失败") - return jsonify({'success': False, 'error': '保存答案失败,请稍后重试'}), 500 - - -@app.route('/api/question//review-status', methods=['PATCH']) -def update_question_review_status(question_id): - """更新题目复习状态""" - try: - data = request.get_json(silent=True) or {} - review_status = data.get('review_status') - if not review_status: - return jsonify({'success': False, 'error': '缺少 review_status 字段'}), 400 - - with SessionLocal() as db: - question = crud.update_review_status(db, question_id, review_status) - if not question: - return jsonify({'success': False, 'error': '题目不存在'}), 404 - - return jsonify({ - 'success': True, - 'message': '复习状态已更新', - 'review_status': question.review_status, - 'updated_at': question.updated_at.isoformat() if question.updated_at else None, - }) - - except ValueError: - return jsonify({'success': False, 'error': f'无效的复习状态,可选值: {", ".join(crud.VALID_REVIEW_STATUSES)}'}), 400 - except Exception as e: - logger.exception("更新复习状态失败") - return jsonify({'success': False, 'error': '更新复习状态失败,请稍后重试'}), 500 - - -@app.route('/api/dashboard-stats', methods=['GET']) -def get_dashboard_stats(): - """获取 Dashboard 所需的完整统计数据,支持 ?subject= 学科筛选""" - try: - subject = request.args.get('subject') or None - uid = session.get('user_id') - with SessionLocal() as db: - # 可用学科列表(按当前用户隔离) - subjects = crud.get_existing_subjects(db, user_id=uid) - - # 复习状态统计 - review_stats = crud.get_review_status_stats(db, subject=subject, user_id=uid) - - # 总体统计 - statistics = crud.get_statistics(db, subject=subject, user_id=uid) - - # 知识点标签统计 top 10(横向条形图) - tag_stats = crud.get_knowledge_stats(db, subject=subject, limit=10, user_id=uid) - - # 知识点 × 掌握状态(堆叠柱状图) - tag_status_stats = crud.get_tag_status_stats(db, subject=subject, limit=10, user_id=uid) - - # 知识点 × 题型(热力图) - tag_type_stats = crud.get_tag_type_stats(db, subject=subject, tag_limit=8, user_id=uid) - - # 最近30天每日新增 + 已掌握趋势 - daily_counts = crud.get_daily_counts(db, days=30, subject=subject, user_id=uid) - - return jsonify({ - 'success': True, - 'subjects': subjects, - 'review_stats': review_stats, - 'total_questions': statistics['total_questions'], - 'by_subject': statistics['by_subject'], - 'tag_stats': tag_stats, - 'tag_status_stats': tag_status_stats, - 'tag_type_stats': tag_type_stats, - 'daily_counts': daily_counts, - }) - - except Exception as e: - logger.exception("获取 Dashboard 统计失败") - return jsonify({'success': False, 'error': '获取统计失败,请稍后重试'}), 500 - - -@app.route('/api/save-to-db', methods=['POST']) -def save_to_db(): - """ - 将分割好的题目导入错题库(仅入库,不导出 Markdown) - - Request Body: - { - "selected_ids": ["q_0", "q_1", ...] # 选中的题目 ID 列表 - } - """ - try: - data = request.get_json(silent=True) or {} - selected_ids = data.get('selected_ids', []) - - if not isinstance(selected_ids, list) or not selected_ids: - return jsonify({'success': False, 'error': '请选择至少一道题目'}), 400 - - results_dir = settings.results_dir - questions_file = os.path.join(results_dir, "questions.json") - if not os.path.exists(questions_file): - return jsonify({'success': False, 'error': '请先分割题目'}), 400 - - with open(questions_file, 'r', encoding='utf-8') as f: - questions = json.load(f) - - selected_questions = [q for q in questions if q.get('question_id') in selected_ids] - if not selected_questions: - return jsonify({'success': False, 'error': '未找到选中的题目'}), 400 - - # 合并前端传来的 answer/user_answer(按 question_id 匹配) - answers_map = {a['question_id']: a for a in data.get('answers', []) if 'question_id' in a} - for sq in selected_questions: - qid = sq.get('question_id') - if qid and qid in answers_map: - if 'answer' in answers_map[qid]: - sq['answer'] = answers_map[qid]['answer'] - if 'user_answer' in answers_map[qid]: - sq['user_answer'] = answers_map[qid]['user_answer'] - - # 读取科目信息 - subject = _read_split_subject() - - with session_lock: - batch_info = { - "original_filename": ", ".join( - session_files.get(k, {}).get("filename", "未知") - for k in session_file_order - ), - "subject": subject, - "file_path": "", - } - - with SessionLocal() as db: - result = crud.save_questions_to_db(db, selected_questions, batch_info, user_id=session.get('user_id')) - - return jsonify({ - 'success': True, - 'message': f'已导入 {result["created"]} 道题目(跳过 {result["duplicates"]} 道重复)', - 'created': result['created'], - 'duplicates': result['duplicates'], - }) - - except Exception as e: - logger.exception("导入错题库失败") - return jsonify({'success': False, 'error': '导入错题库失败,请稍后重试'}), 500 - - -@app.route('/api/ai-analysis', methods=['POST']) -def ai_analysis(): - """ - AI 错题分析(骨架路由) - - 接收一组题目 ID,调用 AI 对这些错题进行综合分析,返回: - - 薄弱知识点诊断 - - 错因归纳 - - 针对性复习建议 - - Request Body: - { - "question_ids": [1, 2, 3] # 必填,至少 1 道,最多 20 道 - } - - Response: - { - "success": true, - "analysis": { - "summary": "综合诊断摘要", - "weak_points": ["知识点A", "知识点B"], - "error_patterns": [ - {"pattern": "错因类型", "description": "详细描述", "question_ids": [1, 2]} - ], - "suggestions": ["建议1", "建议2"], - "per_question": [ - {"question_id": 1, "diagnosis": "该题错因分析", "hint": "解题提示"} - ] - } - } - - TODO(后端开发者): - 1. 从数据库查询题目详情(content_json, options_json, user_answer, knowledge_tags) - 2. 构建 prompt,将题目内容 + 用户答案 + 知识点标签传给 LLM - 3. 调用 LLM(建议使用 llm.py 中的 init_model,支持 openai/anthropic) - 4. 解析 LLM 返回的结构化结果,填充 analysis 字段 - 5. 可选:将分析结果缓存到数据库,避免重复分析 - """ - try: - data = request.get_json(silent=True) or {} - question_ids = data.get('question_ids', []) - - if not isinstance(question_ids, list) or not question_ids: - return jsonify({'success': False, 'error': '请选择至少一道题目'}), 400 - - if len(question_ids) > 20: - return jsonify({'success': False, 'error': '单次最多分析 20 道题目'}), 400 - - with SessionLocal() as db: - questions = crud.get_questions_by_ids(db, question_ids) - if not questions: - return jsonify({'success': False, 'error': '未找到对应题目'}), 404 - - # ── TODO: 替换为真实 AI 分析逻辑 ────────────────── - # 当前返回占位数据,供前端联调使用 - knowledge_tags = set() - per_question = [] - for q in questions: - tags = [m.tag.tag_name for m in (q.tags or []) if m.tag] - knowledge_tags.update(tags) - per_question.append({ - 'question_id': q.id, - 'diagnosis': f'题目 #{q.id} 的错因分析(待 AI 生成)', - 'hint': f'题目 #{q.id} 的解题提示(待 AI 生成)', - }) - - analysis = { - 'summary': f'已选择 {len(questions)} 道题目进行分析(AI 分析功能待接入)', - 'weak_points': list(knowledge_tags) or ['暂无知识点数据'], - 'error_patterns': [ - { - 'pattern': '待 AI 识别', - 'description': '错因模式将由 AI 自动归纳', - 'question_ids': [q.id for q in questions], - } - ], - 'suggestions': [ - '建议回顾相关知识点章节', - '建议针对薄弱环节进行专项练习', - ], - 'per_question': per_question, - } - # ── END TODO ────────────────────────────────────── - - return jsonify({ - 'success': True, - 'analysis': analysis, - }) - - except Exception as e: - logger.exception("AI 分析失败") - return jsonify({'success': False, 'error': 'AI 分析失败,请稍后重试'}), 500 - - -@app.route('/api/export-from-db', methods=['POST']) -def export_from_db(): - """从数据库按 ID 列表导出 Markdown 错题本""" - try: - data = request.get_json(silent=True) or {} - selected_ids = data.get('selected_ids', []) - - if not isinstance(selected_ids, list) or not selected_ids: - return jsonify({'success': False, 'error': '请选择至少一道题目'}), 400 - - with SessionLocal() as db: - questions_orm = crud.get_questions_by_ids(db, selected_ids) - if not questions_orm: - return jsonify({'success': False, 'error': '未找到对应题目'}), 404 - - # 转换为 export_wrongbook_md 所需的 dict 格式 - questions = [] - for q in questions_orm: - questions.append({ - 'question_id': str(q.id), - 'question_type': q.question_type, - 'content_blocks': json.loads(q.content_json) if q.content_json else [], - 'options': json.loads(q.options_json) if q.options_json else None, - 'has_formula': q.has_formula, - 'has_image': q.has_image, - 'knowledge_tags': [m.tag.tag_name for m in (q.tags or []) if m.tag], - }) - - str_ids = [str(qid) for qid in selected_ids] - output_path = export_wrongbook_md(questions, str_ids) - - return jsonify({ - 'success': True, - 'message': '错题本导出成功', - 'output_path': output_path, - }) - - except Exception as e: - logger.exception("从数据库导出失败") - return jsonify({'success': False, 'error': '导出失败,请稍后重试'}), 500 - - -# ============================================================ -# 教学辅导对话 API -# ============================================================ - - -@app.route('/api/question//answer', methods=['PUT']) -def save_question_answer(question_id): - """保存题目答案(Markdown 格式,用于 AI 辅导)""" - try: - data = request.get_json(silent=True) or {} - answer = data.get('answer') - if answer is None: - return jsonify({'success': False, 'error': '缺少 answer 字段'}), 400 - if len(answer) > 50000: - return jsonify({'success': False, 'error': '答案内容过长(最多 50000 字符)'}), 400 - - with SessionLocal() as db: - question = crud.update_question_answer(db, question_id, answer) - if not question: - return jsonify({'success': False, 'error': '题目不存在'}), 404 - - return jsonify({ - 'success': True, - 'message': '答案已保存', - 'answer': question.answer, - }) - - except Exception as e: - logger.exception("保存答案失败") - return jsonify({'success': False, 'error': '保存答案失败,请稍后重试'}), 500 - - -@app.route('/api/question//chats', methods=['GET']) -def get_question_chats(question_id): - """获取某道题目的所有对话会话""" - try: - with SessionLocal() as db: - sessions = crud.get_chat_sessions_by_question(db, question_id) - return jsonify({ - 'success': True, - 'sessions': [_serialize_chat_session(s) for s in sessions], - }) - except Exception as e: - logger.exception("获取对话列表失败") - return jsonify({'success': False, 'error': '获取对话列表失败'}), 500 - - -@app.route('/api/chat', methods=['POST']) -def create_chat(): - """创建新对话""" - try: - data = request.get_json(silent=True) or {} - question_id = data.get('question_id') - if not question_id: - return jsonify({'success': False, 'error': '缺少 question_id'}), 400 - - with SessionLocal() as db: - question = db.query(Question).filter(Question.id == question_id).first() - if not question: - return jsonify({'success': False, 'error': '题目不存在'}), 404 - - session = crud.create_chat_session(db, question_id) - return jsonify({ - 'success': True, - 'session': _serialize_chat_session(session), - }) - - except Exception as e: - logger.exception("创建对话失败") - return jsonify({'success': False, 'error': '创建对话失败'}), 500 - - -@app.route('/api/chat/sessions', methods=['GET']) -def get_chat_sessions(): - """分页获取所有对话会话""" - try: - page = max(1, request.args.get('page', 1, type=int)) - page_size = min(100, max(1, request.args.get('page_size', 20, type=int))) - - with SessionLocal() as db: - sessions, total = crud.get_all_chat_sessions(db, page=page, page_size=page_size) - total_pages = (total + page_size - 1) // page_size - - return jsonify({ - 'success': True, - 'sessions': [_serialize_chat_session(s) for s in sessions], - 'total': total, - 'page': page, - 'total_pages': total_pages, - }) - - except Exception as e: - logger.exception("获取对话会话列表失败") - return jsonify({'success': False, 'error': '获取对话会话列表失败'}), 500 - - -@app.route('/api/chat//messages', methods=['GET']) -def get_chat_messages(session_id): - """游标分页获取对话消息""" - try: - limit = min(100, max(1, request.args.get('limit', 30, type=int))) - before_id = request.args.get('before_id', type=int) - - with SessionLocal() as db: - result = crud.get_chat_messages(db, session_id, limit=limit, before_id=before_id) - return jsonify({ - 'success': True, - 'messages': result['messages'], - 'hasMore': result['hasMore'], - }) - - except Exception as e: - logger.exception("获取对话消息失败") - return jsonify({'success': False, 'error': '获取对话消息失败'}), 500 - - -@app.route('/api/chat//stream', methods=['POST']) -def stream_chat(session_id): - """SSE 流式对话""" - from flask import Response - from teach_agent import stream_teach - - try: - data = request.get_json(silent=True) or {} - message = data.get('message', '').strip() - model_provider = data.get('model_provider', 'openai') - model_name = data.get('model_name') or None - - if not message: - return jsonify({'success': False, 'error': '消息不能为空'}), 400 - - if not settings.is_valid_provider(model_provider): - return jsonify({'success': False, 'error': f'不支持的模型供应商: {model_provider}'}), 400 - - # 从数据库加载用户的 LLM 凭据 - user_id = session.get('user_id') - if user_id: - settings.load_providers_from_db(user_id) - - with SessionLocal() as db: - from db.models import ChatSession as ChatSessionModel, QuestionTagMapping - from sqlalchemy.orm import selectinload - chat_session = db.query(ChatSessionModel).options( - selectinload(ChatSessionModel.question) - .selectinload(Question.batch), - selectinload(ChatSessionModel.question) - .selectinload(Question.tags) - .selectinload(QuestionTagMapping.tag), - ).filter(ChatSessionModel.id == session_id).first() - if not chat_session: - return jsonify({'success': False, 'error': '对话不存在'}), 404 - - question = chat_session.question - if not question: - return jsonify({'success': False, 'error': '关联题目不存在'}), 404 - - # 序列化题目数据 - q_data = _serialize_question(question) - - # 加载历史消息(最近 20 条) - history_result = crud.get_chat_messages(db, session_id, limit=20) - history = [{"role": m["role"], "content": m["content"]} for m in history_result["messages"]] - - # 追加用户消息 - history.append({"role": "user", "content": message}) - crud.add_chat_message(db, session_id, "user", message) - - def generate(): - full_response = [] - try: - for token in stream_teach( - question=q_data, - messages=history, - provider=model_provider, - model_name=model_name, - ): - full_response.append(token) - yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n" - except Exception as e: - logger.exception("流式对话错误") - yield f"data: {json.dumps({'error': str(e)}, ensure_ascii=False)}\n\n" - - # 保存完整的 assistant 回复 - assistant_content = "".join(full_response) - if assistant_content: - try: - with SessionLocal() as db: - crud.add_chat_message(db, session_id, "assistant", assistant_content) - except Exception as e: - logger.error(f"保存 assistant 回复失败: {e}") - - yield f"data: {json.dumps({'done': True})}\n\n" - - resp = Response(generate(), mimetype='text/event-stream') - resp.headers['Cache-Control'] = 'no-cache' - resp.headers['X-Accel-Buffering'] = 'no' - return resp - - except Exception as e: - logger.exception("流式对话失败") - return jsonify({'success': False, 'error': '对话失败,请稍后重试'}), 500 - - if __name__ == '__main__': - # 确保运行时目录存在 + # 创建运行时目录(uploads、pages、results 等) settings.ensure_dirs() - # 初始化数据库 + + # 初始化数据库(建表 + 自动迁移) init_db() - # 自动迁移(添加新列等) from db.migrate import migrate migrate() - print("[数据库] 初始化完成") - print("=" * 60) - print("错题本生成系统 - Web应用") - print("=" * 60) - print("访问地址: http://localhost:5001") - print("=" * 60) + logger.info("错题本生成系统 - API 服务") + logger.info("API 地址: http://localhost:5001") + logger.info("=" * 50) + # 从环境变量读取调试模式开关(默认关闭) + # 开启后 Flask 会自动重载代码变更,并在浏览器显示详细错误信息 + debug = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' app.run( - host='0.0.0.0', port=5001, debug=True, - exclude_patterns=["*site-packages*"], + host='0.0.0.0', port=5001, debug=debug, + threaded=True, # 多线程处理并发请求 + exclude_patterns=["*site-packages*"], # 排除第三方库文件变更触发的重载 ) diff --git a/data-processing/README.md b/data-processing/README.md deleted file mode 100644 index 31434c33..00000000 --- a/data-processing/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# Data Processing - -OCR 图像预处理与识别准确率评估系统。 - -## 目录结构 - -``` -data-processing/ -├── preprocessing/ # 图像预处理模块 -│ ├── data_preprocessing.py # 预处理核心 -│ ├── preprocessing_mode.json # 预处理几种方案 -│ └── README.md -│ -│ -├── processing/ # 基于OmniDocBench的识别评估模块 -│ ├── evaluation_recognition.py # 记录识别结果 -│ ├── exam_accuracy_results.json # 试卷样例识别结果 -│ ├── jiaocai_accuracy_results.json #教程样例识别结果 -│ ├── OmniDocBench.json # OmniDocBench数据集答案 -│ └── sample_image # 优秀样例图片 -│ ├──exam/ # 试卷 -│ └──jiaocai # 教材 -│ -├── standard_doc/ # 图像扫描规范文档、数据标注规范 -│ ├── DATA_LABELING_STANDARDS.md # 数据标注 -│ └── SCANNING_STANDARDS.md # 扫描规范 -└── README.md -``` - -## 快速开始 - -### 1. 原始图片识别评估 - -```bash -cd processing -python evaluation_recognition.py -``` - -评估原始图片的 OCR 识别准确率,与 OmniDocBench 标注对比,生成 `exam_accuracy_results.json`。 - -### 2. 图像预处理 - -```python -from preprocessing.data_preprocessing import run_pipeline - -# 单张图片 -run_pipeline(file_path="test.png", mode="auto", save_log=True) - -# 批量处理 -import glob -for img in glob.glob("sample_image/exam/*.png"): - run_pipeline(file_path=img, mode="auto", save_log=True) -``` - -**预处理模式**: - -| 模式 | 适用场景 | -|------|---------| -| `auto` | 自动选择最佳策略(推荐) | -| `standard` | 一般文档 | -| `lightweight` | 高质量文档、数学公式密集 | -| `illumination_rescue` | 光照不均、阴影、反光 | -| `screen_photography` | 屏幕翻拍、摩尔纹 | -| `aggressive_compression` | 文件压缩 | - - -``` - -## 实验结果 - -基于 OmniDocBench 数据集: - -- **原始平均准确率**: 83.97% -- **预处理后平均准确率**: 85.50% -- **平均提升**: 1.5-2% -- **中文试卷**: illumination_rescue 模式提升 2-7% -- **英文数学试卷**: lightweight 模式保持原有准确率 - -## 配置 - -### 环境变量 - -创建 `.env` 文件: - -```env -PADDLEOCR_API_URL=https://your-api-endpoint -PADDLEOCR_API_TOKEN=your-token-here -``` - -### 自定义预处理参数 - -编辑 `preprocessing/preprocessing_mode.json` 添加自定义模式。 - -## 注意事项 - -1. **API 限流**: 建议 `MAX_WORKERS` 不超过 5 -2. **文件命名**: 图片文件名需与 `OmniDocBench.json` 中的 `image_path` 匹配 -3. **预处理策略**: 数学公式密集文档避免使用 `illumination_rescue` - -## 相关文档 - -- [预处理模块详细说明](preprocessing/README.md) -- [数据标注规范](standard_doc/DATA_LABELING_STANDARDS.md) -- [扫描规范](standard_doc/SCANNING_STANDARDS.md) diff --git a/data-processing/preprocessing/README.md b/data-processing/preprocessing/README.md deleted file mode 100644 index 69702fb6..00000000 --- a/data-processing/preprocessing/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# 图像预处理模块 - -智能图像预处理系统,用于优化 OCR 识别效果。 - -## 核心文件 - -- `data_preprocessing.py` - 主预处理程序 -- `preprocessing_mode.json` - 预处理策略配置 - - -## 快速开始 - -### 1. 单张图片预处理 - -```python -from data_preprocessing import run_pipeline - -# 自动模式(推荐) -run_pipeline( - file_path="your_image.png", - mode="auto", # 自动选择最佳策略 - save_log=True -) - -# 手动指定模式 -run_pipeline( - file_path="your_image.png", - mode="lightweight", # 可选: standard, lightweight, illumination_rescue, screen_photography, aggressive_compression - save_log=True -) -``` - -### 2. 批量处理 - -```python -import glob -from data_preprocessing import run_pipeline - -image_files = glob.glob("your_folder/*.png") -for img_path in image_files: - run_pipeline(file_path=img_path, mode="auto", save_log=True) -``` - - - -## 预处理模式说明 - -| 模式 | 适用场景 | 特点 | -|------|---------|------| -| `auto` | 所有场景(推荐) | 自动诊断并选择最佳策略 | -| `standard` | 一般文档 | 平衡速度与精度 | -| `lightweight` | 高质量文档、数学公式密集 | 仅做基础优化,保护细节 | -| `illumination_rescue` | 光照不均、阴影、反光 | 对比度增强,光照修复 | -| `screen_photography` | 屏幕翻拍、摩尔纹 | 去除摩尔纹 | -| `aggressive_compression` | 网络差、文件大 | 极限压缩 | - -## Auto 模式智能路由 - -系统会自动分析图像特征并选择最佳策略: - -- **数学公式密集 / 高质量文档** → `lightweight` -- **光照问题明显** → `illumination_rescue` -- **屏幕翻拍特征** → `screen_photography` -- **大文件低文本密度** → `aggressive_compression` -- **其他** → `standard` - -## 输出结构 - -``` -output/ -├── image_name_processed_strategy/ -│ └── api_analysis.log # API 识别结果 -processed_exam_images/ -└── image_name.png # 预处理后的图片 -``` - -## 配置自定义 - -编辑 `preprocessing_mode.json` 可自定义预处理参数: - -```json -{ - "modes": { - "your_custom_mode": { - "description": "自定义模式说明", - "target_dpi": 150, - "max_side_len": 1920, - "jpeg_quality": 95, - "enable_deskew": true, - "enable_contrast_enhance": false, - "enable_moire_removal": false - } - } -} -``` - -## 性能优化建议 - -1. **中文试卷**:auto 模式会自动选择 `illumination_rescue`,提升 2-7% -2. **英文数学试卷**:auto 模式会自动选择 `lightweight`,避免符号识别下降 -3. **批量处理**:建议使用 auto 模式,系统会为每张图片选择最优策略 diff --git a/data-processing/preprocessing/data_preprocessing.py b/data-processing/preprocessing/data_preprocessing.py deleted file mode 100644 index 4d72dc6e..00000000 --- a/data-processing/preprocessing/data_preprocessing.py +++ /dev/null @@ -1,508 +0,0 @@ -import base64 -import os -import requests -import time -import json -import math -import cv2 -import numpy as np -from typing import Dict, Any -from dotenv import load_dotenv -from PIL import Image - -# ========================================== -# 全局配置参数 -# ========================================== -load_dotenv() # 从 .env 文件加载环境变量 -API_URL = os.getenv("PADDLEOCR_API_URL") -TOKEN = os.getenv("PADDLEOCR_API_TOKEN") - -OUTPUT_DIR = "output" # API 识别结果、标记图片和日志保存目录 -PROCESSED_DIR = "processed_exam_images" # 预处理后的中间图片保存目录 -CONFIG_FILE = "preprocessing_mode.json" # 预处理配置文件路径 - -# ========================================== -# 模块一:配置加载 -# ========================================== -def load_preprocessing_config(mode_name=None): - """加载预处理策略配置""" - if not os.path.exists(CONFIG_FILE): - raise FileNotFoundError(f"配置文件 {CONFIG_FILE} 不存在!请先创建。") - - with open(CONFIG_FILE, 'r', encoding='utf-8') as f: - config_data = json.load(f) - - if mode_name is None: - mode_name = config_data.get("default_mode", "standard") - - if mode_name not in config_data["modes"]: - raise ValueError(f"未找到预处理模式 '{mode_name}',请检查配置文件。") - - print(f"[Config] 已加载预处理模式: '{mode_name}'") - print(f"[Config] 策略描述: {config_data['modes'][mode_name].get('description', '')}") - return config_data["modes"][mode_name] - -# ========================================== -# 模块1.5:图像特征分析 (自适应诊断) -# ========================================== -def get_file_size_kb(path: str) -> float: - return round(os.path.getsize(path) / 1024.0, 2) - -def get_dpi(path: str, default: int = 72) -> int: - try: - with Image.open(path) as im: - dpi = im.info.get('dpi') - if dpi and isinstance(dpi, tuple): - return int(dpi[0]) - except Exception: - pass - return default - -def to_gray(img: np.ndarray) -> np.ndarray: - if len(img.shape) == 3: - return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - return img - -def estimate_brightness_and_contrast(gray: np.ndarray) -> Dict[str, float]: - arr = gray.astype(np.float32) - mean = float(np.mean(arr)) - std = float(np.std(arr)) - return {"brightness": round(mean, 2), "contrast": round(std, 2)} - -def estimate_sharpness(gray: np.ndarray) -> float: - # Variance of Laplacian (常用的清晰度测量) - var_lap = cv2.Laplacian(gray, cv2.CV_64F).var() - return float(var_lap) - -def estimate_noise(img: np.ndarray) -> float: - # 通过原图与高斯模糊图之差估计高频噪声 - blur = cv2.GaussianBlur(img, (3, 3), 0) - if img.ndim == 3: - diff = cv2.cvtColor(cv2.absdiff(img, blur), cv2.COLOR_BGR2GRAY) - else: - diff = cv2.absdiff(img, blur) - return float(np.std(diff)) - -def estimate_edge_density(gray: np.ndarray) -> float: - edges = cv2.Canny(gray, 100, 200) - edge_ratio = float(np.count_nonzero(edges)) / (gray.shape[0] * gray.shape[1]) - return round(edge_ratio, 4) - -def estimate_text_density(gray: np.ndarray) -> float: - # 自适应二值化后计算黑色像素比例,作为文本/线条密度的近似 - th = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, - cv2.THRESH_BINARY_INV, 25, 15) - density = float(np.count_nonzero(th)) / (gray.shape[0] * gray.shape[1]) - return round(density, 4) - -def detect_color_mode(img: np.ndarray) -> str: - if len(img.shape) == 2: - return "grayscale" - # 如果三通道间差异很小,认为是灰度图 - channel_diff = np.max(img.astype(np.int32) - img[..., 0:1].astype(np.int32)) - if channel_diff < 5: - return "grayscale" - return "color" - -def estimate_skew_angle(gray: np.ndarray) -> float: - # 使用 Hough 直线检测(在边缘图上)来估计主方向角度 - h, w = gray.shape[:2] - # Resize reduce cost - scale = 600.0 / max(h, w) if max(h, w) > 600 else 1.0 - small = cv2.resize(gray, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) - edges = cv2.Canny(small, 50, 150) - lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=50, minLineLength=30, maxLineGap=10) - angles = [] - if lines is None: - return 0.0 - for l in lines: - x1, y1, x2, y2 = l[0] - angle = math.degrees(math.atan2(y2 - y1, x2 - x1)) - # 只考虑接近水平的直线,过滤垂直线 - if abs(angle) < 45: - angles.append(angle) - if not angles: - return 0.0 - # 取中位数以减少离群点影响 - med = float(np.median(np.array(angles))) - # 返回图片坐标系下的倾斜角(度),正值表示逆时针旋转 - return round(med / scale if scale != 1.0 else med, 2) - -def analyze_image(path: str) -> Dict[str, Any]: - if not os.path.exists(path): - raise FileNotFoundError(path) - - pil_img = Image.open(path) - w, h = pil_img.size - file_size_kb = get_file_size_kb(path) - dpi = get_dpi(path) - - # Load as OpenCV image - img_cv = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_UNCHANGED) - if img_cv is None: - # fallback via PIL - img_cv = cv2.cvtColor(np.array(pil_img.convert('RGB')), cv2.COLOR_RGB2BGR) - - gray = to_gray(img_cv) - - bc = estimate_brightness_and_contrast(gray) - sharpness = estimate_sharpness(gray) - noise = estimate_noise(img_cv) - edge_density = estimate_edge_density(gray) - text_density = estimate_text_density(gray) - color_mode = detect_color_mode(img_cv) - skew = estimate_skew_angle(gray) - - aspect_ratio = round(w / h, 4) if h != 0 else None - - return { - "file": os.path.basename(path), - "path": path, - "file_size_kb": file_size_kb, - "dpi": dpi, - "resolution": f"{w}x{h}", - "aspect_ratio": aspect_ratio, - "color_mode": color_mode, - "brightness": bc["brightness"], - "contrast": bc["contrast"], - "sharpness_variance_laplacian": round(sharpness, 2), - "noise_stddev": round(noise, 2), - "edge_density": edge_density, - "text_density": text_density, - "skew_angle_degrees": skew, - "is_blurry": sharpness < 100.0 - } - -# ========================================== -# 模块二:图像 DPI 读取与真实重采样 -# ========================================== -def get_image_dpi(file_path): - """读取图像文件的 DPI 元数据标签""" - try: - with Image.open(file_path) as img: - dpi = img.info.get('dpi') - if dpi: - return f"{int(dpi[0])} x {int(dpi[1])}" - else: - return "Not Set (System Default)" - except Exception as e: - return f"Read Failed: {e}" - -def resample_image_by_dpi(image_path, target_dpi=150, default_original_dpi=72): - """真正的 DPI 转换器:不仅修改标签,还会物理缩放像素矩阵。""" - with Image.open(image_path) as img: - orig_dpi_tuple = img.info.get('dpi') - original_dpi = orig_dpi_tuple[0] if orig_dpi_tuple else default_original_dpi - - scale_factor = target_dpi / original_dpi - - if scale_factor == 1.0: - return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) - - new_width = int(img.width * scale_factor) - new_height = int(img.height * scale_factor) - - print(f"[Preprocess] DPI Resampling: {original_dpi} -> {target_dpi} DPI") - - # 执行高质量重采样 (Lanczos) - resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - return cv2.cvtColor(np.array(resized_img), cv2.COLOR_RGB2BGR) - -# ========================================== -# 模块三:核心 CV 预处理算法 -# ========================================== -def resize_image_for_paddle(image, max_side_len=1920): - """限制图像的最大边长""" - h, w = image.shape[:2] - if max(h, w) > max_side_len: - if h > w: - new_h = max_side_len - new_w = int(w * (max_side_len / h)) - else: - new_w = max_side_len - new_h = int(h * (max_side_len / w)) - image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA) - print(f"[Preprocess] Size Truncation: ({w}x{h}) -> ({new_w}x{new_h})") - return image - -def deskew_image(image): - """倾斜校正""" - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - edges = cv2.Canny(gray, 50, 150, apertureSize=3) - lines = cv2.HoughLinesP(edges, 1, np.pi / 180, 100, minLineLength=100, maxLineGap=10) - - if lines is not None: - angles = [] - for line in lines: - x1, y1, x2, y2 = line[0] - angle = np.degrees(np.arctan2(y2 - y1, x2 - x1)) - if -45 < angle < 45: - angles.append(angle) - - if angles: - median_angle = np.median(angles) - if abs(median_angle) > 0.5: - h, w = image.shape[:2] - center = (w // 2, h // 2) - M = cv2.getRotationMatrix2D(center, median_angle, 1.0) - rotated = cv2.warpAffine(image, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT, borderValue=(255, 255, 255)) - print(f"[Preprocess] Deskew applied: {median_angle:.2f} degrees") - return rotated - return image - -def enhance_contrast_clahe(image, clip_limit=2.0, tile_size=8): - """CLAHE 增强图像局部对比度""" - lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) - l, a, b = cv2.split(lab) - clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_size, tile_size)) - cl = clahe.apply(l) - limg = cv2.merge((cl, a, b)) - final = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR) - print(f"[Preprocess] Contrast enhanced (CLAHE: clip={clip_limit}, tile={tile_size})") - return final - -def remove_moire_pattern(image): - """高斯滤波弱化摩尔纹""" - blurred = cv2.GaussianBlur(image, (3, 3), 0) - print("[Preprocess] Moire pattern removed (Gaussian Blur)") - return blurred - -# ========================================== -# 模块四:基于配置的预处理调度器 -# ========================================== -def preprocess_image(input_path, config): - """根据传入的配置字典执行预处理流水线""" - os.makedirs(PROCESSED_DIR, exist_ok=True) - base_name = os.path.basename(input_path) - file_name_without_ext = os.path.splitext(base_name)[0] - file_ext = os.path.splitext(base_name)[1] - - # 保存为原始文件名(保持原扩展名或使用 jpg) - if file_ext.lower() in ['.jpg', '.jpeg', '.png', '.bmp', '.webp']: - output_path = os.path.join(PROCESSED_DIR, base_name) - else: - output_path = os.path.join(PROCESSED_DIR, f"{file_name_without_ext}.jpg") - - print(f"\n[Step 1/3] Local Preprocessing: {input_path}") - - try: - image = resample_image_by_dpi(input_path, target_dpi=config.get('target_dpi', 150)) - except Exception as e: - raise ValueError(f"Image read/resample failed: {e}") - - # 根据配置执行流水线 - image = resize_image_for_paddle(image, max_side_len=config.get('max_side_len', 1920)) - - if config.get('enable_deskew', False): - image = deskew_image(image) - - if config.get('enable_moire_removal', False): - image = remove_moire_pattern(image) - - if config.get('enable_contrast_enhance', False): - clip_limit = config.get('clahe_clip_limit', 2.0) - tile_size = config.get('clahe_tile_size', 8) - image = enhance_contrast_clahe(image, clip_limit=clip_limit, tile_size=tile_size) - - # 统一转换色彩空间并使用 PIL 保存,应用 jpeg_quality 压缩参数 - image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - pil_img = Image.fromarray(image_rgb) - - target_dpi = config.get('target_dpi', 150) - jpeg_quality = config.get('jpeg_quality', 95) - - pil_img.save(output_path, format="JPEG", quality=jpeg_quality, dpi=(target_dpi, target_dpi)) - - file_size_kb = os.path.getsize(output_path) / 1024 - print(f"[Preprocess] Completed! Saved to: {output_path} (Size: {file_size_kb:.2f} KB, Quality: {jpeg_quality})") - return output_path - -# ========================================== -# 模块五:云端 API 调用与结果解析 -# ========================================== -def get_file_type(file_path): - ext = os.path.splitext(file_path)[-1].lower() - if ext == ".pdf": return 0 - elif ext in [".jpg", ".jpeg", ".png", ".bmp", ".webp"]: return 1 - else: raise ValueError(f"Unsupported file format: {ext}") - -def call_paddleocr_api(file_path): - print(f"\n[Step 2/3] Calling Cloud API...") - file_type = get_file_type(file_path) - with open(file_path, "rb") as file: - file_bytes = file.read() - file_data = base64.b64encode(file_bytes).decode("ascii") - - headers = {"Authorization": f"token {TOKEN}", "Content-Type": "application/json"} - payload = { - "file": file_data, - "fileType": file_type, - "useDocOrientationClassify": False, - "useDocUnwarping": False, - "useChartRecognition": False, - } - - start_time = time.perf_counter() - response = requests.post(API_URL, json=payload, headers=headers) - response.raise_for_status() - api_latency = time.perf_counter() - start_time - - return response.json(), api_latency, response.json() - -def analyze_and_save(original_file_path, api_target_file, light_data, api_latency, raw_data, suffix="", save_log=False): - print(f"\n[Step 3/3] Parsing response...") - base_name = os.path.splitext(os.path.basename(original_file_path))[0] - test_output_dir = os.path.join(OUTPUT_DIR, f"{base_name}_{suffix}" if suffix else base_name) - os.makedirs(test_output_dir, exist_ok=True) - - if save_log: - log_file_path = os.path.join(test_output_dir, "api_analysis.log") - with open(log_file_path, "w", encoding="utf-8") as log_file: - json.dump(light_data, log_file, ensure_ascii=False, indent=2) - print(f"[Preprocess] API info logged to {log_file_path}") - - try: - res = light_data.get('result', {}) - layout_results = res.get('layoutParsingResults', [{}])[0] - width, height = res.get('dataInfo', {}).get('width', 0), res.get('dataInfo', {}).get('height', 0) - num_blocks = len(layout_results.get('prunedResult', {}).get('parsing_res_list', [])) - - text_content = layout_results.get('markdown', {}).get('text', '') - text_length = len(text_content) - actual_dpi = get_image_dpi(api_target_file) if get_file_type(api_target_file) == 1 else "PDF" - - print("-" * 50) - print(f"[{suffix.upper()}] API Data Metrics") - print("-" * 50) - print(f"Latency : {api_latency:.2f} s") - print(f"DPI Tag : {actual_dpi}") - print(f"Resolution : {width} x {height}") - print(f"Blocks : {num_blocks}") - print(f"Text Length : {text_length} chars") - print("-" * 50) - except Exception: - pass - -# ========================================== -# 最终运行入口 -# ========================================== -def run_pipeline(file_path, mode="auto", save_log=False): - """ - 端到端总控调度 - :param file_path: 目标文件路径 - :param mode: "auto" 会触发自动图像诊断路由,也可直接指定配置文件里的模式 - """ - print(f"\n{'='*60}") - print(f"Task Started: {file_path} | Requested Mode: {mode}") - print(f"{'='*60}") - - if not os.path.exists(file_path): - print(f"[Error] File not found: {file_path}") - return - - is_image = (get_file_type(file_path) == 1) - diagnosis_info = None - - # Step 0: 如果是图片,我们先进行物理体检(改动最小的解耦融合方案) - if is_image: - print("\n[Step 0/3] Image Auto-Diagnosis...") - try: - diagnosis_info = analyze_image(file_path) - print(f" -> Features: Brightness={diagnosis_info['brightness']}, Blur={diagnosis_info['is_blurry']}, Skew={diagnosis_info['skew_angle_degrees']}") - - # 核心融合点:如果开启了 Auto 模式,则利用诊断结果决定该用哪套 JSON 预设 - if mode == "auto": - # 判断是否为数学公式密集文档(高文本密度 + 高边缘密度 + 较高清晰度) - is_math_heavy = ( - diagnosis_info['text_density'] > 0.08 and - diagnosis_info['edge_density'] > 0.05 and - diagnosis_info['sharpness_variance_laplacian'] > 150 - ) - - # 判断是否为质量已经较好的文档 - is_good_quality = ( - diagnosis_info['brightness'] >= 120 and - diagnosis_info['brightness'] <= 200 and - diagnosis_info['contrast'] >= 50 and - not diagnosis_info['is_blurry'] and - abs(diagnosis_info['skew_angle_degrees']) < 2.0 - ) - - # 智能路由逻辑 - if is_math_heavy or is_good_quality: - # 数学公式密集或质量已经很好的文档,使用轻量级模式 - mode = "lightweight" - print(f" -> [Auto-Routing] Detected high-quality or math-heavy document, using lightweight mode") - elif diagnosis_info['brightness'] < 100 or diagnosis_info['brightness'] > 220 or diagnosis_info['contrast'] < 40: - # 光照问题明显,使用光照拯救模式 - mode = "illumination_rescue" - print(f" -> [Auto-Routing] Detected illumination issues, using illumination_rescue mode") - elif diagnosis_info['noise_stddev'] > 3.0 and not diagnosis_info['is_blurry']: - # 噪声明显但不模糊,可能是屏幕翻拍 - mode = "screen_photography" - print(f" -> [Auto-Routing] Detected screen photography patterns, using screen_photography mode") - elif diagnosis_info['file_size_kb'] > 3000 and diagnosis_info['text_density'] < 0.03: - # 文件大但文本少,需要压缩 - mode = "aggressive_compression" - print(f" -> [Auto-Routing] Large file with low text density, using aggressive_compression mode") - else: - # 默认使用标准模式 - mode = "standard" - print(f" -> [Auto-Routing] Using standard mode for general document") - except Exception as e: - print(f" -> [Warning] Diagnosis failed: {e}") - if mode == "auto": mode = "standard" - - if is_image and mode != "raw": - # 读取配置并预处理 - config = load_preprocessing_config(mode_name=mode) - api_target_file = preprocess_image(file_path, config) - suffix = f"processed_{mode}" - else: - print(f"\n[Step 1/3] Skipping preprocessing, using raw file...") - api_target_file = file_path - suffix = "raw" - - try: - light_log, latency, raw_json = call_paddleocr_api(api_target_file) - except Exception as e: - print(f"[Error] API call failed: {e}") - return - - # 把物理特征的诊断记录随同 API 结果一并写入 log 文件,辅助后期深度分析 - if diagnosis_info and isinstance(light_log, dict): - light_log["image_diagnosis"] = diagnosis_info - - analyze_and_save(file_path, api_target_file, light_log, latency, raw_json, suffix=suffix, save_log=save_log) - print("\n[Done] Pipeline execution completed successfully.") - -if __name__ == "__main__": - # ========================================== - # 1. 单张图片测试示例 - # ========================================== - # test_file = r"test_image/raw_image/raw_test_1_copy.png" - - # # 使用自适应模式 "auto",系统将自己进行体检并匹配预处理 - # run_pipeline( - # file_path=test_file, - # mode="standard", # 可选: auto, standard, aggressive_compression, illumination_rescue, screen_photography, raw - # save_log=True # 开启 Log 可以将诊断特征一并写入结果中 - # ) - - # ========================================== - # 2. 文件夹批处理测试示例 (若需使用,请取消下方注释) - # ========================================== - import glob - test_dir = r"D:\my_data\code\error_correction\data-processing\processing\sample_image\exam" - # 获取目录下所有的文件 - image_files = glob.glob(os.path.join(test_dir, "*.*")) - - print(f"\n📁 启动文件夹批处理模式: {test_dir} (共找到 {len(image_files)} 个文件)") - for img_path in image_files: - # 仅处理常见图像格式 - if img_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.webp')): - run_pipeline( - file_path=img_path, - mode="auto", # 批处理强烈建议用 auto 模式 - save_log=True # 批处理时建议开启日志以便后续分析 - ) \ No newline at end of file diff --git a/data-processing/preprocessing/examples.py b/data-processing/preprocessing/examples.py deleted file mode 100644 index d7d44f34..00000000 --- a/data-processing/preprocessing/examples.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -预处理模块使用示例 -""" -from data_preprocessing import run_pipeline -import glob -import os - -# ========================================== -# 示例 1: 单张图片预处理(自动模式) -# ========================================== -def example_single_image_auto(): - """单张图片自动预处理""" - test_file = r"test_image/sample.png" - - if os.path.exists(test_file): - run_pipeline( - file_path=test_file, - mode="auto", # 自动选择最佳策略 - save_log=True - ) - - -# ========================================== -# 示例 2: 单张图片预处理(指定模式) -# ========================================== -def example_single_image_manual(): - """单张图片手动指定模式""" - test_file = r"test_image/sample.png" - - if os.path.exists(test_file): - run_pipeline( - file_path=test_file, - mode="lightweight", # 手动指定轻量级模式 - save_log=True - ) - - -# ========================================== -# 示例 3: 批量处理文件夹 -# ========================================== -def example_batch_processing(): - """批量处理文件夹中的所有图片""" - test_dir = r"test_image/batch" - - # 获取所有图片文件 - image_files = glob.glob(os.path.join(test_dir, "*.*")) - - print(f"\n启动批处理: {test_dir} (共 {len(image_files)} 个文件)") - - for img_path in image_files: - # 仅处理常见图像格式 - if img_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.webp')): - print(f"\n处理: {os.path.basename(img_path)}") - run_pipeline( - file_path=img_path, - mode="auto", # 批处理推荐使用 auto 模式 - save_log=True - ) - - - -if __name__ == "__main__": - # 取消注释以运行相应示例 - - # example_single_image_auto() - # example_single_image_manual() - # example_batch_processing() - - print("请取消注释相应的示例函数以运行") diff --git a/data-processing/preprocessing/preprocessing_mode.json b/data-processing/preprocessing/preprocessing_mode.json deleted file mode 100644 index 23ff8765..00000000 --- a/data-processing/preprocessing/preprocessing_mode.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "default_mode": "standard", - "modes": { - "standard": { - "description": "标准通用模式:平衡了速度与精度,适合大部分正常文档", - "target_dpi": 100, - "max_side_len": 1920, - "jpeg_quality": 95, - "enable_deskew": true, - "enable_contrast_enhance": false, - "enable_moire_removal": false - }, - "lightweight": { - "description": "轻量级模式:适合质量已经较好的文档,仅做基础优化(数学公式密集文档推荐)", - "target_dpi": 150, - "max_side_len": 1920, - "jpeg_quality": 95, - "enable_deskew": true, - "enable_contrast_enhance": false, - "enable_moire_removal": false - }, - "aggressive_compression": { - "description": "极限压缩模式:网络极差或对时间要求极高时的备选方案", - "target_dpi": 72, - "max_side_len": 1600, - "jpeg_quality": 85, - "enable_deskew": false, - "enable_contrast_enhance": false, - "enable_moire_removal": false - }, - "illumination_rescue": { - "description": "光照拯救模式:针对阴影、反光、过曝等光照不均场景特调", - "target_dpi": 150, - "max_side_len": 1920, - "jpeg_quality": 92, - "enable_deskew": true, - "enable_contrast_enhance": true, - "enable_moire_removal": false, - "clahe_clip_limit": 1.5, - "clahe_tile_size": 8 - }, - "screen_photography": { - "description": "屏幕翻拍模式:专门处理电脑屏幕、投影仪翻拍产生的摩尔纹", - "target_dpi": 150, - "max_side_len": 2048, - "jpeg_quality": 95, - "enable_deskew": true, - "enable_contrast_enhance": false, - "enable_moire_removal": true - } - } -} \ No newline at end of file diff --git a/data-processing/processing/evaluation_recognition.py b/data-processing/processing/evaluation_recognition.py deleted file mode 100644 index f8dcecca..00000000 --- a/data-processing/processing/evaluation_recognition.py +++ /dev/null @@ -1,191 +0,0 @@ -import os -import json -import base64 -import requests -import time -import glob -from concurrent.futures import ThreadPoolExecutor, as_completed -import difflib -from dotenv import load_dotenv - -load_dotenv() - -# ========================================== -# 全局配置参数 -# ========================================== - -API_URL = os.getenv("PADDLEOCR_API_URL") -TOKEN = os.getenv("PADDLEOCR_API_TOKEN") - -# 文件夹与文件配置 -GT_JSON_FILE = "OmniDocBench.json" # 官方下载的标注文件 -RAW_DIR = "sample_image/jiaocai" # 存放样例原图的目录 -OUTPUT_LOG_FILE = "jiaocai_accuracy_results.json" # 样例原图识别结果 - -# 并发线程数 (不建议过高,避免触发 API 限流) -MAX_WORKERS = 5 - -# ========================================== -# 模块一:解析 OmniDocBench 官方真值 (Ground Truth) -# ========================================== -def load_omnidocbench_gt(json_path): - print(f"正在加载官方标准答案库: {json_path}...") - if not os.path.exists(json_path): - raise FileNotFoundError(f"找不到文件 {json_path},请确保已下载并放置在同级目录。") - - with open(json_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - gt_dict = {} - for page in data: - img_name = os.path.basename(page['page_info']['image_path']) - - page_texts = [] - for det in page.get('layout_dets', []): - if det.get('ignore', False): - continue - - if 'text' in det and det['text'].strip(): - page_texts.append(det['text'].strip()) - elif 'latex' in det and det['latex'].strip(): - page_texts.append(det['latex'].strip()) - - gt_dict[img_name] = "\n".join(page_texts) - - print(f"成功加载 {len(gt_dict)} 张图片的标准答案!\n") - return gt_dict - -def calculate_accuracy(gt_text, pred_text): - gt_clean = gt_text.strip().lower() - pred_clean = pred_text.strip().lower() - - if not gt_clean and not pred_clean: return 100.0 - if not gt_clean or not pred_clean: return 0.0 - - matcher = difflib.SequenceMatcher(None, gt_clean, pred_clean) - return round(matcher.ratio() * 100, 2) - -# ========================================== -# 模块二:云端 API 调用 -# ========================================== -def call_api(file_path): - with open(file_path, "rb") as f: - file_data = base64.b64encode(f.read()).decode("ascii") - - payload = { - "file": file_data, - "fileType": 1, - "useDocOrientationClassify": False, - "useDocUnwarping": False - } - headers = {"Authorization": f"token {TOKEN}", "Content-Type": "application/json"} - - for i in range(3): - try: - start = time.perf_counter() - response = requests.post(API_URL, json=payload, headers=headers, timeout=60) - response.raise_for_status() - latency = time.perf_counter() - start - return latency, response.json() - except Exception as e: - time.sleep(2 ** i) - return None, {"error": "API 请求失败"} - -# ========================================== -# 模块三:多线程任务单元 -# ========================================== -def process_single_task(img_path, gt_dict): - filename = os.path.basename(img_path) - file_size_kb = round(os.path.getsize(img_path) / 1024, 2) - - # Raw 文件名就是标准答案的 Key - if filename not in gt_dict: - return filename, {"error": f"未在官方 JSON 中找到对应的标准答案: {filename}"} - - gt_text = gt_dict[filename] - strategy = "raw" - - latency, res = call_api(img_path) - if not latency: - return filename, {"error": "API 调用失败"} - - try: - data = res.get('result', {}) - layout = data.get('layoutParsingResults', [{}])[0] - pred_text = layout.get('markdown', {}).get('text', '') - blocks_count = len(layout.get('prunedResult', {}).get('parsing_res_list', [])) - except Exception: - pred_text = "" - blocks_count = 0 - - accuracy = calculate_accuracy(gt_text, pred_text) - - return filename, { - "original_img": filename, - "strategy": strategy, - "latency_s": round(latency, 2), - "size_kb": file_size_kb, - "text_len": len(pred_text), - "true_accuracy_pct": accuracy, - "blocks": blocks_count - } - -# ========================================== -# 模块四:主控引擎 (多线程并发) -# ========================================== -def run_raw_evaluation(): - try: - gt_dict = load_omnidocbench_gt(GT_JSON_FILE) - except Exception as e: - print(e) - return - - final_results = {} - if os.path.exists(OUTPUT_LOG_FILE): - with open(OUTPUT_LOG_FILE, "r", encoding="utf-8") as f: - try: - final_results = json.load(f) - print(f"发现已有成绩单,已加载 {len(final_results)} 条记录。") - except: - pass - - if not os.path.exists(RAW_DIR): - print(f"错误: 找不到文件夹 {RAW_DIR},请检查路径。") - return - - files = glob.glob(os.path.join(RAW_DIR, "*.*")) - image_files = [f for f in files if f.lower().endswith(('.jpg', '.jpeg', '.png'))] - - # 剔除已完成 - pending_files = [f for f in image_files if os.path.basename(f) not in final_results] - - if not pending_files: - print("所有 RAW 图片均已测试完毕!") - return - - total_files = len(pending_files) - print(f"开始测试 RAW 图片,共 {total_files} 个样本...") - - completed = 0 - start_time = time.time() - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - future_to_img = {executor.submit(process_single_task, img_path, gt_dict): img_path for img_path in pending_files} - - for future in as_completed(future_to_img): - filename, metrics = future.result() - final_results[filename] = metrics - - completed += 1 - acc = metrics.get('true_accuracy_pct', 'N/A') - lat = metrics.get('latency_s', 'N/A') - print(f"[{completed}/{total_files}] RAW 处理完成: {filename[:30]}... | 准确率: {acc}% | 耗时: {lat}s") - - with open(OUTPUT_LOG_FILE, "w", encoding="utf-8") as f: - json.dump(final_results, f, ensure_ascii=False, indent=4) - - total_time = round(time.time() - start_time, 2) - print(f"\nRAW 图片测试全部完成!总耗时: {total_time} 秒") - -if __name__ == "__main__": - run_raw_evaluation() \ No newline at end of file diff --git a/data-processing/processing/exam_accuracy_results.json b/data-processing/processing/exam_accuracy_results.json deleted file mode 100644 index ea1b1d64..00000000 --- a/data-processing/processing/exam_accuracy_results.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "exam_paper_en-file-putnam-archive_1997_Solutions_1997s_page_003.png": { - "original_img": "exam_paper_en-file-putnam-archive_1997_Solutions_1997s_page_003.png", - "strategy": "raw", - "latency_s": 1.48, - "size_kb": 430.64, - "text_len": 3214, - "true_accuracy_pct": 78.42, - "blocks": 16 - }, - "exam_paper_2018年广西北海、钦州、南宁、来宾、崇左、防城港、北部湾经济区中考英语试题(空白卷)_page_001.png": { - "original_img": "exam_paper_2018年广西北海、钦州、南宁、来宾、崇左、防城港、北部湾经济区中考英语试题(空白卷)_page_001.png", - "strategy": "raw", - "latency_s": 1.62, - "size_kb": 448.54, - "text_len": 1178, - "true_accuracy_pct": 85.9, - "blocks": 16 - }, - "exam_paper_2016年安徽省中考英语试题(解析)_page_003.png": { - "original_img": "exam_paper_2016年安徽省中考英语试题(解析)_page_003.png", - "strategy": "raw", - "latency_s": 1.95, - "size_kb": 346.44, - "text_len": 899, - "true_accuracy_pct": 91.58, - "blocks": 13 - }, - "exam_paper_en-file-putnam-archive_2006_Problems_2006_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2006_Problems_2006_page_001.png", - "strategy": "raw", - "latency_s": 2.24, - "size_kb": 478.25, - "text_len": 3861, - "true_accuracy_pct": 78.96, - "blocks": 24 - }, - "exam_paper_en-file-putnam-archive_2007_Problems_2007_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2007_Problems_2007_page_001.png", - "strategy": "raw", - "latency_s": 1.48, - "size_kb": 424.95, - "text_len": 3441, - "true_accuracy_pct": 79.24, - "blocks": 20 - }, - "exam_paper_2016年安徽省中考英语试题(解析)_page_015.png": { - "original_img": "exam_paper_2016年安徽省中考英语试题(解析)_page_015.png", - "strategy": "raw", - "latency_s": 3.12, - "size_kb": 417.37, - "text_len": 2060, - "true_accuracy_pct": 96.34, - "blocks": 12 - }, - "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_002.png": { - "original_img": "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_002.png", - "strategy": "raw", - "latency_s": 1.63, - "size_kb": 642.68, - "text_len": 5019, - "true_accuracy_pct": 77.01, - "blocks": 29 - }, - "exam_paper_en-file-putnam-archive_2014_Problems_2014_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2014_Problems_2014_page_001.png", - "strategy": "raw", - "latency_s": 1.63, - "size_kb": 479.37, - "text_len": 3534, - "true_accuracy_pct": 84.34, - "blocks": 29 - }, - "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_003.png": { - "original_img": "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_003.png", - "strategy": "raw", - "latency_s": 1.98, - "size_kb": 714.66, - "text_len": 5844, - "true_accuracy_pct": 85.32, - "blocks": 24 - }, - "exam_paper_en-file-putnam-archive_2015_Problems_2015_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2015_Problems_2015_page_001.png", - "strategy": "raw", - "latency_s": 1.35, - "size_kb": 426.23, - "text_len": 3329, - "true_accuracy_pct": 82.49, - "blocks": 27 - } -} \ No newline at end of file diff --git a/data-processing/processing/jiaocai_accuracy_results.json b/data-processing/processing/jiaocai_accuracy_results.json deleted file mode 100644 index a4c0807f..00000000 --- a/data-processing/processing/jiaocai_accuracy_results.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "jiaocaineedrop_624b60c58c9d8bfb6ff1886c2fd605d2adeb6ea4da576068201b6c6958ce93f4.pdf_8.jpg": { - "original_img": "jiaocaineedrop_624b60c58c9d8bfb6ff1886c2fd605d2adeb6ea4da576068201b6c6958ce93f4.pdf_8.jpg", - "strategy": "raw", - "latency_s": 1.1, - "size_kb": 182.96, - "text_len": 1440, - "true_accuracy_pct": 91.17, - "blocks": 9 - }, - "jiaocaineedrop_eng-45646.pdf_30.jpg": { - "original_img": "jiaocaineedrop_eng-45646.pdf_30.jpg", - "strategy": "raw", - "latency_s": 1.18, - "size_kb": 184.09, - "text_len": 2348, - "true_accuracy_pct": 93.59, - "blocks": 21 - }, - "jiaocaineedrop_jiaocai_needrop_en_1249.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1249.jpg", - "strategy": "raw", - "latency_s": 1.24, - "size_kb": 234.65, - "text_len": 2859, - "true_accuracy_pct": 95.9, - "blocks": 10 - }, - "jiaocaineedrop_jiaocai_needrop_en_1303.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1303.jpg", - "strategy": "raw", - "latency_s": 1.28, - "size_kb": 286.56, - "text_len": 1264, - "true_accuracy_pct": 91.93, - "blocks": 13 - }, - "jiaocaineedrop_jiaocai_needrop_en_1544.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1544.jpg", - "strategy": "raw", - "latency_s": 1.29, - "size_kb": 251.11, - "text_len": 1191, - "true_accuracy_pct": 91.86, - "blocks": 31 - }, - "jiaocaineedrop_jiaocai_needrop_en_1884.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1884.jpg", - "strategy": "raw", - "latency_s": 0.89, - "size_kb": 226.79, - "text_len": 980, - "true_accuracy_pct": 95.21, - "blocks": 38 - }, - "jiaocai_jiaocai_en_117.jpg": { - "original_img": "jiaocai_jiaocai_en_117.jpg", - "strategy": "raw", - "latency_s": 0.7, - "size_kb": 62.76, - "text_len": 188, - "true_accuracy_pct": 94.51, - "blocks": 7 - }, - "jiaocaineedrop_jiaocai_needrop_en_2002.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_2002.jpg", - "strategy": "raw", - "latency_s": 0.97, - "size_kb": 259.87, - "text_len": 916, - "true_accuracy_pct": 96.73, - "blocks": 12 - }, - "jiaocaineedrop_jiaocai_needrop_en_3428.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_3428.jpg", - "strategy": "raw", - "latency_s": 1.11, - "size_kb": 364.84, - "text_len": 1422, - "true_accuracy_pct": 96.99, - "blocks": 13 - }, - "jiaocaineedrop_jiaocai_needrop_en_377.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_377.jpg", - "strategy": "raw", - "latency_s": 1.44, - "size_kb": 372.6, - "text_len": 5329, - "true_accuracy_pct": 96.81, - "blocks": 78 - } -} \ No newline at end of file diff --git a/data-processing/processing/sample_image/exam/exam_accuracy_results.json b/data-processing/processing/sample_image/exam/exam_accuracy_results.json deleted file mode 100644 index fdadd52a..00000000 --- a/data-processing/processing/sample_image/exam/exam_accuracy_results.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "exam_paper_en-file-putnam-archive_1997_Solutions_1997s_page_003.png": { - "original_img": "exam_paper_en-file-putnam-archive_1997_Solutions_1997s_page_003.png", - "strategy": "raw", - "latency_s": 1.95, - "size_kb": 430.64, - "text_len": 3214, - "true_accuracy_pct": 78.42, - "blocks": 16 - }, - "exam_paper_2016年安徽省中考英语试题(解析)_page_015.png": { - "original_img": "exam_paper_2016年安徽省中考英语试题(解析)_page_015.png", - "strategy": "raw", - "latency_s": 1.97, - "size_kb": 417.37, - "text_len": 2060, - "true_accuracy_pct": 96.34, - "blocks": 12 - }, - "exam_paper_2016年安徽省中考英语试题(解析)_page_003.png": { - "original_img": "exam_paper_2016年安徽省中考英语试题(解析)_page_003.png", - "strategy": "raw", - "latency_s": 2.02, - "size_kb": 346.44, - "text_len": 899, - "true_accuracy_pct": 91.58, - "blocks": 13 - }, - "exam_paper_2018年广西北海、钦州、南宁、来宾、崇左、防城港、北部湾经济区中考英语试题(空白卷)_page_001.png": { - "original_img": "exam_paper_2018年广西北海、钦州、南宁、来宾、崇左、防城港、北部湾经济区中考英语试题(空白卷)_page_001.png", - "strategy": "raw", - "latency_s": 2.23, - "size_kb": 448.54, - "text_len": 1178, - "true_accuracy_pct": 85.9, - "blocks": 16 - }, - "exam_paper_en-file-putnam-archive_2006_Problems_2006_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2006_Problems_2006_page_001.png", - "strategy": "raw", - "latency_s": 2.26, - "size_kb": 478.25, - "text_len": 3861, - "true_accuracy_pct": 78.96, - "blocks": 24 - }, - "exam_paper_en-file-putnam-archive_2007_Problems_2007_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2007_Problems_2007_page_001.png", - "strategy": "raw", - "latency_s": 1.75, - "size_kb": 424.95, - "text_len": 3441, - "true_accuracy_pct": 79.24, - "blocks": 20 - }, - "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_002.png": { - "original_img": "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_002.png", - "strategy": "raw", - "latency_s": 1.98, - "size_kb": 642.68, - "text_len": 5019, - "true_accuracy_pct": 77.01, - "blocks": 29 - }, - "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_003.png": { - "original_img": "exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_003.png", - "strategy": "raw", - "latency_s": 2.09, - "size_kb": 714.66, - "text_len": 5844, - "true_accuracy_pct": 85.32, - "blocks": 24 - }, - "exam_paper_en-file-putnam-archive_2014_Problems_2014_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2014_Problems_2014_page_001.png", - "strategy": "raw", - "latency_s": 1.95, - "size_kb": 479.37, - "text_len": 3534, - "true_accuracy_pct": 84.34, - "blocks": 29 - }, - "exam_paper_en-file-putnam-archive_2015_Problems_2015_page_001.png": { - "original_img": "exam_paper_en-file-putnam-archive_2015_Problems_2015_page_001.png", - "strategy": "raw", - "latency_s": 2.04, - "size_kb": 426.23, - "text_len": 3329, - "true_accuracy_pct": 82.49, - "blocks": 27 - } -} \ No newline at end of file diff --git "a/data-processing/processing/sample_image/exam/exam_paper_2016\345\271\264\345\256\211\345\276\275\347\234\201\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\350\247\243\346\236\220\357\274\211_page_003.png" "b/data-processing/processing/sample_image/exam/exam_paper_2016\345\271\264\345\256\211\345\276\275\347\234\201\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\350\247\243\346\236\220\357\274\211_page_003.png" deleted file mode 100644 index f6097dc7..00000000 Binary files "a/data-processing/processing/sample_image/exam/exam_paper_2016\345\271\264\345\256\211\345\276\275\347\234\201\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\350\247\243\346\236\220\357\274\211_page_003.png" and /dev/null differ diff --git "a/data-processing/processing/sample_image/exam/exam_paper_2016\345\271\264\345\256\211\345\276\275\347\234\201\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\350\247\243\346\236\220\357\274\211_page_015.png" "b/data-processing/processing/sample_image/exam/exam_paper_2016\345\271\264\345\256\211\345\276\275\347\234\201\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\350\247\243\346\236\220\357\274\211_page_015.png" deleted file mode 100644 index c6beb4ef..00000000 Binary files "a/data-processing/processing/sample_image/exam/exam_paper_2016\345\271\264\345\256\211\345\276\275\347\234\201\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\350\247\243\346\236\220\357\274\211_page_015.png" and /dev/null differ diff --git "a/data-processing/processing/sample_image/exam/exam_paper_2018\345\271\264\345\271\277\350\245\277\345\214\227\346\265\267\343\200\201\351\222\246\345\267\236\343\200\201\345\215\227\345\256\201\343\200\201\346\235\245\345\256\276\343\200\201\345\264\207\345\267\246\343\200\201\351\230\262\345\237\216\346\270\257\343\200\201\345\214\227\351\203\250\346\271\276\347\273\217\346\265\216\345\214\272\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\347\251\272\347\231\275\345\215\267\357\274\211_page_001.png" "b/data-processing/processing/sample_image/exam/exam_paper_2018\345\271\264\345\271\277\350\245\277\345\214\227\346\265\267\343\200\201\351\222\246\345\267\236\343\200\201\345\215\227\345\256\201\343\200\201\346\235\245\345\256\276\343\200\201\345\264\207\345\267\246\343\200\201\351\230\262\345\237\216\346\270\257\343\200\201\345\214\227\351\203\250\346\271\276\347\273\217\346\265\216\345\214\272\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\347\251\272\347\231\275\345\215\267\357\274\211_page_001.png" deleted file mode 100644 index 97183707..00000000 Binary files "a/data-processing/processing/sample_image/exam/exam_paper_2018\345\271\264\345\271\277\350\245\277\345\214\227\346\265\267\343\200\201\351\222\246\345\267\236\343\200\201\345\215\227\345\256\201\343\200\201\346\235\245\345\256\276\343\200\201\345\264\207\345\267\246\343\200\201\351\230\262\345\237\216\346\270\257\343\200\201\345\214\227\351\203\250\346\271\276\347\273\217\346\265\216\345\214\272\344\270\255\350\200\203\350\213\261\350\257\255\350\257\225\351\242\230\357\274\210\347\251\272\347\231\275\345\215\267\357\274\211_page_001.png" and /dev/null differ diff --git a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_1997_Solutions_1997s_page_003.png b/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_1997_Solutions_1997s_page_003.png deleted file mode 100644 index ac8aa32e..00000000 Binary files a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_1997_Solutions_1997s_page_003.png and /dev/null differ diff --git a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2006_Problems_2006_page_001.png b/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2006_Problems_2006_page_001.png deleted file mode 100644 index 27c24607..00000000 Binary files a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2006_Problems_2006_page_001.png and /dev/null differ diff --git a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2007_Problems_2007_page_001.png b/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2007_Problems_2007_page_001.png deleted file mode 100644 index 06967a96..00000000 Binary files a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2007_Problems_2007_page_001.png and /dev/null differ diff --git a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_002.png b/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_002.png deleted file mode 100644 index 93eac5c9..00000000 Binary files a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_002.png and /dev/null differ diff --git a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_003.png b/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_003.png deleted file mode 100644 index 6ea86371..00000000 Binary files a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2009_Solutions_2009s_page_003.png and /dev/null differ diff --git a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2014_Problems_2014_page_001.png b/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2014_Problems_2014_page_001.png deleted file mode 100644 index 16062a55..00000000 Binary files a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2014_Problems_2014_page_001.png and /dev/null differ diff --git a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2015_Problems_2015_page_001.png b/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2015_Problems_2015_page_001.png deleted file mode 100644 index 564ef041..00000000 Binary files a/data-processing/processing/sample_image/exam/exam_paper_en-file-putnam-archive_2015_Problems_2015_page_001.png and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocai_accuracy_results.json b/data-processing/processing/sample_image/jiaocai/jiaocai_accuracy_results.json deleted file mode 100644 index 2cb21262..00000000 --- a/data-processing/processing/sample_image/jiaocai/jiaocai_accuracy_results.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "jiaocaineedrop_jiaocai_needrop_en_1249.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1249.jpg", - "strategy": "raw", - "latency_s": 0.99, - "size_kb": 234.65, - "text_len": 2859, - "true_accuracy_pct": 95.9, - "blocks": 10 - }, - "jiaocaineedrop_jiaocai_needrop_en_1544.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1544.jpg", - "strategy": "raw", - "latency_s": 1.09, - "size_kb": 251.11, - "text_len": 1191, - "true_accuracy_pct": 91.86, - "blocks": 31 - }, - "jiaocaineedrop_eng-45646.pdf_30.jpg": { - "original_img": "jiaocaineedrop_eng-45646.pdf_30.jpg", - "strategy": "raw", - "latency_s": 1.11, - "size_kb": 184.09, - "text_len": 2348, - "true_accuracy_pct": 93.59, - "blocks": 21 - }, - "jiaocaineedrop_624b60c58c9d8bfb6ff1886c2fd605d2adeb6ea4da576068201b6c6958ce93f4.pdf_8.jpg": { - "original_img": "jiaocaineedrop_624b60c58c9d8bfb6ff1886c2fd605d2adeb6ea4da576068201b6c6958ce93f4.pdf_8.jpg", - "strategy": "raw", - "latency_s": 1.85, - "size_kb": 182.96, - "text_len": 1440, - "true_accuracy_pct": 91.17, - "blocks": 9 - }, - "jiaocaineedrop_jiaocai_needrop_en_1884.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1884.jpg", - "strategy": "raw", - "latency_s": 1.06, - "size_kb": 226.79, - "text_len": 980, - "true_accuracy_pct": 95.21, - "blocks": 38 - }, - "jiaocaineedrop_jiaocai_needrop_en_1303.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_1303.jpg", - "strategy": "raw", - "latency_s": 2.11, - "size_kb": 286.56, - "text_len": 1264, - "true_accuracy_pct": 91.93, - "blocks": 13 - }, - "jiaocaineedrop_jiaocai_needrop_en_2002.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_2002.jpg", - "strategy": "raw", - "latency_s": 1.18, - "size_kb": 259.87, - "text_len": 916, - "true_accuracy_pct": 96.73, - "blocks": 12 - }, - "jiaocaineedrop_jiaocai_needrop_en_3428.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_3428.jpg", - "strategy": "raw", - "latency_s": 1.27, - "size_kb": 364.84, - "text_len": 1422, - "true_accuracy_pct": 96.99, - "blocks": 13 - }, - "jiaocaineedrop_jiaocai_needrop_en_377.jpg": { - "original_img": "jiaocaineedrop_jiaocai_needrop_en_377.jpg", - "strategy": "raw", - "latency_s": 1.46, - "size_kb": 372.6, - "text_len": 5329, - "true_accuracy_pct": 96.81, - "blocks": 78 - }, - "jiaocai_jiaocai_en_117.jpg": { - "original_img": "jiaocai_jiaocai_en_117.jpg", - "strategy": "raw", - "latency_s": 0.6, - "size_kb": 62.76, - "text_len": 188, - "true_accuracy_pct": 94.51, - "blocks": 7 - } -} \ No newline at end of file diff --git a/data-processing/processing/sample_image/jiaocai/jiaocai_jiaocai_en_117.jpg b/data-processing/processing/sample_image/jiaocai/jiaocai_jiaocai_en_117.jpg deleted file mode 100644 index 7419de25..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocai_jiaocai_en_117.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_624b60c58c9d8bfb6ff1886c2fd605d2adeb6ea4da576068201b6c6958ce93f4.pdf_8.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_624b60c58c9d8bfb6ff1886c2fd605d2adeb6ea4da576068201b6c6958ce93f4.pdf_8.jpg deleted file mode 100644 index a00788a4..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_624b60c58c9d8bfb6ff1886c2fd605d2adeb6ea4da576068201b6c6958ce93f4.pdf_8.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_eng-45646.pdf_30.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_eng-45646.pdf_30.jpg deleted file mode 100644 index e4c93db0..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_eng-45646.pdf_30.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1249.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1249.jpg deleted file mode 100644 index 3548fc85..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1249.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1303.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1303.jpg deleted file mode 100644 index efdc7cda..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1303.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1544.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1544.jpg deleted file mode 100644 index 703881c7..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1544.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1884.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1884.jpg deleted file mode 100644 index b3d81f4c..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_1884.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_2002.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_2002.jpg deleted file mode 100644 index 8c5251bd..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_2002.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_3428.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_3428.jpg deleted file mode 100644 index 5ff6f3c2..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_3428.jpg and /dev/null differ diff --git a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_377.jpg b/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_377.jpg deleted file mode 100644 index 95d2faaa..00000000 Binary files a/data-processing/processing/sample_image/jiaocai/jiaocaineedrop_jiaocai_needrop_en_377.jpg and /dev/null differ diff --git a/data-processing/standard_doc/DATA_LABELING_STANDARDS.md b/data-processing/standard_doc/DATA_LABELING_STANDARDS.md deleted file mode 100644 index c9722099..00000000 --- a/data-processing/standard_doc/DATA_LABELING_STANDARDS.md +++ /dev/null @@ -1,62 +0,0 @@ -# 题目标注格式与数据规范 (V1.0) - -本规范旨在定义试卷经过 PaddleOCR 解析后,如何将非结构化的文本内容标准化为可供数据库存储、搜索引擎检索及 AI 模型使用的结构化数据(JSON 格式)。 - -## 一、 核心字段定义规范 - -无论什么题型(单选、多选、填空、解答),一道标准化的题目必须包含以下核心结构: - -1. **`question_type` (题型)** - * **枚举值**:`single_choice` (单选), `multiple_choice` (多选), `fill_blank` (填空), `essay` (解答题/主观题)。 - -2. **`stem` (题干)** - * **规范**:包含题目主体描述。如果是填空题,填空处强制统一使用 `___`(三个下划线)占位。 - -3. **`options` (选项 - 仅限选择题)** - * **规范**:必须是一个包含完整选项的数组(Array)或键值对(Object)。禁止将选项 A、B、C 杂糅在题干文本中。 - -4. **`answer` (标准答案)** - * **规范**: - * 选择题:存大写字母,如 `"A"` 或 `["A", "C"]`。 - * 填空题:按空序存储字符串数组,如 `["3.14", "牛顿"]`。 - -5. **`analysis` (解析)** - * **规范**:详细的解题步骤。可选字段,若原卷未提供可置为空串。 - -## 二、 富文本与多媒体格式规范 - -试卷中往往包含大量的公式和配图,必须严格按照以下格式进行转录和标注: - -1. **数学与物理公式 (Math & Physics)** - * **行内公式**:强制使用单美元符号包裹。例:`设圆的半径为 $R$`。 - * **独立段落公式**:强制使用双美元符号包裹。例:`$$E = mc^2$$` - * **禁忌**:严禁混用 Unicode 特殊字符(如使用 `㎡` 代替 `$m^2$`,使用 `α` 代替 `$\alpha$`),一切以 LaTeX 语法为准。 - -2. **配图与占位 (Images)** - * **格式**:强制使用标准 Markdown 图片语法 `![图片描述](图片本地或OSS存储路径)`。 - * **命名规范**:图片路径需与题目 ID 强绑定,例如 `![题1配图](/images/q_1001_fig1.jpg)`。 - -## 三、 标准化 JSON 结构示例 - -在完成人工校对或大模型结构化提取后,最终入库的数据必须符合以下 JSON Schema 结构: - -### 示例:一道标准的单选题 - -```json -{ - "question_id": "Q_2023_MATH_001", - "question_type": "single_choice", - "difficulty": "medium", - "knowledge_tags": ["二次函数", "极值"], - "content": { - "stem": "已知二次函数 $y = ax^2 + bx + c$ 的图像如图所示,则下列结论正确的是?\n![二次函数图](/assets/images/q001.jpg)", - "options": { - "A": "$a > 0$", - "B": "$b < 0$", - "C": "$c = 0$", - "D": "$b^2 - 4ac < 0$" - } - }, - "answer": "A", - "analysis": "由图像开口向上可知 $a > 0$,故选 A。" -} \ No newline at end of file diff --git a/data-processing/standard_doc/SCANNING_STANDARDS.md b/data-processing/standard_doc/SCANNING_STANDARDS.md deleted file mode 100644 index 118d4c79..00000000 --- a/data-processing/standard_doc/SCANNING_STANDARDS.md +++ /dev/null @@ -1,32 +0,0 @@ -# 试卷数字化扫描规范 (V1.0) - -基于对 [Real5-OmniDocBench](https://huggingface.co/datasets/PaddlePaddle/Real5-OmniDocBench)场景的本地初步实验验证,为最大化 PaddleOCR 引擎的识别精度并最小化网络与计算延迟,特制定本参数标准。 - -## 一、 确定分辨率要求 - -禁止扫描设备直接输出 4K (3840px) 原始大图。过大的分辨率会引入背景高频噪点并造成极大的传输浪费。 - -* **推荐长边像素**:**1920px** -* **极限上限像素**:**2048px** - * *说明:1080P 级别是 PaddleOCR 识别试卷结构和文本的最佳感受野,能拦截大量环境噪点,且传输耗时大幅降低70%。* -* **物理像素密度**:**150 DPI** - * *说明:试卷包含大量密集小字与数学公式。72 DPI 易导致笔画粘连,300 DPI 则会徒增云端显存压力,150 DPI 为实测最优解。* - -## 二、 确定格式要求 - -* **强制输出格式**:**JPEG** -* **压缩质量 (Quality)**:**Q90 - Q95** - * *说明:在 1920px 尺度下,Q90 是兼顾“极小文件体积(省带宽)”与“零精度损耗(保识别)”的最佳平衡点。* - -## 三、 硬件采集防失真规范 - -前端采集必须从物理层面规避以下四种典型的试卷失真类型: - -1. **光照不均 (Illumination)**:必须采用均匀的顶部或双侧布光,严禁试卷局部被阴影遮挡或桌面强烈反光。 -2. **变形褶皱 (Warping)**:必须确保试卷完全展平,严禁边缘卷曲或装订线中缝深陷。 -3. **倾斜 (Skew)**:试卷边缘与取景框应保持平行,物理倾斜角严格控制在 **±5°** 以内。 -4. **屏幕翻拍 (Screen-Photography)**:强制直接扫描物理纸张,严禁对着电脑屏幕或投影仪进行二次翻拍(会产生致命摩尔纹)。 - -## 四、 其他 - -若受客观环境限制(如考场恶劣光线下的随手拍),必须在调用 PaddleOCR API 前,接入自适应预处理诊断模块(`mode="auto"`),利用 CLAHE 光照增强和自动降维来挽救极端畸变样本。 \ No newline at end of file diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index c289e00e..00000000 --- a/docs/API.md +++ /dev/null @@ -1,571 +0,0 @@ -# API 接口文档 - -错题本生成系统提供 RESTful API 接口,用于文件上传、OCR 解析、题目分割和错题本导出。 - -**Base URL**: `http://localhost:5001` - ---- - -## 目录 - -- [概览](#概览) -- [接口列表](#接口列表) - - [GET / — 主页](#get-——主页) - - [GET /api/status — 系统状态](#get-apistatus——系统状态) - - [POST /api/upload — 上传文件](#post-apiupload——上传文件) - - [POST /api/split — 分割题目](#post-apisplit——分割题目) - - [POST /api/export — 导出错题本](#post-apiexport——导出错题本) - - [GET /api/questions — 获取题目列表](#get-apiquestions——获取题目列表) - - [GET /preview — 预览页面](#get-preview——预览页面) - - [GET /download/\ — 下载文件](#get-downloadfilename——下载文件) -- [数据结构](#数据结构) -- [调用流程](#调用流程) -- [错误处理](#错误处理) - ---- - -## 概览 - -### 技术栈 - -- **框架**: Flask 3.1.2 -- **状态管理**: LangGraph MemorySaver(基于 `thread_id` 的会话状态) -- **工作流**: LangGraph StateGraph,在 `split_questions` 和 `export` 节点前设置中断 - -### 典型调用流程 - -``` -1. GET /api/status ← 检查系统配置 -2. POST /api/upload ← 上传文件,触发 prepare_input + ocr_parse -3. POST /api/split ← 恢复工作流,执行 Agent 题目分割 -4. POST /api/export ← 注入选中题目 ID,执行导出 -5. GET /download/wrongbook.md ← 下载导出的错题本 -``` - -### 通用响应格式 - -成功响应: -```json -{ - "success": true, - "message": "操作描述", - ... -} -``` - -错误响应: -```json -{ - "success": false, - "error": "错误描述" -} -``` - ---- - -## 接口列表 - -### GET / — 主页 - -返回 Web 界面主页。 - -**请求**: -``` -GET / -``` - -**响应**: HTML 页面(`templates/index.html`) - ---- - -### GET /api/status — 系统状态 - -获取系统配置状态,检查各项外部服务是否已正确配置。 - -**请求**: -``` -GET /api/status -``` - -**响应示例**: -```json -{ - "success": true, - "status": { - "paddleocr_configured": true, - "deepseek_configured": true, - "langsmith_enabled": false, - "output_dirs": { - "pages": "output/pages", - "struct": "output/struct", - "results": "results" - } - } -} -``` - -**响应字段说明**: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `status.paddleocr_configured` | boolean | PaddleOCR API 是否已配置(检查 `PADDLEOCR_API_URL` 环境变量) | -| `status.deepseek_configured` | boolean | DeepSeek API 是否已配置(检查 `DEEPSEEK_API_KEY` 环境变量) | -| `status.langsmith_enabled` | boolean | LangSmith 追踪是否已启用 | -| `status.output_dirs` | object | 输出目录路径配置 | - ---- - -### POST /api/upload — 上传文件 - -上传 PDF 或图片文件,系统自动执行预处理流程: - -1. 保存上传文件到 `uploads/` 目录 -2. 创建新的工作流会话(`thread_id`) -3. 执行 `prepare_input` 节点 — 将文件转换为标准化 PNG 图片 -4. 执行 `ocr_parse` 节点 — 调用 PaddleOCR API 解析文档结构 -5. 在 `split_questions` 节点前中断,等待用户触发 - -**请求**: -``` -POST /api/upload -Content-Type: multipart/form-data -``` - -**请求参数**: - -| 参数 | 类型 | 必需 | 说明 | -|------|------|------|------| -| `file` | File | 是 | 上传的文件(PDF 或图片) | - -**支持的文件格式**: `.pdf`, `.png`, `.jpg`, `.jpeg`, `.bmp`, `.tiff`, `.webp` - -**文件大小限制**: 50MB - -**成功响应** (200): -```json -{ - "success": true, - "message": "文件处理成功", - "result": { - "image_count": 3, - "ocr_count": 3, - "image_paths": [ - "output/pages/test_page_001.png", - "output/pages/test_page_002.png", - "output/pages/test_page_003.png" - ] - } -} -``` - -**响应字段说明**: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `result.image_count` | number | 转换后的图片数量 | -| `result.ocr_count` | number | OCR 解析完成的图片数量 | -| `result.image_paths` | array | 标准化后的图片路径列表 | - -**错误响应**: - -| 状态码 | 错误 | 说明 | -|--------|------|------| -| 400 | `没有上传文件` | 请求中未包含文件 | -| 400 | `未选择文件` | 文件名为空 | -| 400 | `不支持的文件格式` | 文件扩展名不在允许列表中 | -| 413 | — | 文件超过 50MB 大小限制(Flask 自动返回) | -| 500 | `{错误详情}` | 服务端处理异常 | - -**cURL 示例**: -```bash -curl -X POST http://localhost:5001/api/upload \ - -F "file=@/path/to/exam.pdf" -``` - ---- - -### POST /api/split — 分割题目 - -恢复工作流执行 Agent 题目分割。必须在 `/api/upload` 成功后调用。 - -**内部流程**: -1. 使用当前会话的 `thread_id` 恢复工作流 -2. 执行 `split_questions` 节点 — DeepSeek Agent 分析 OCR 结果并分割题目 -3. 在 `export` 节点前中断,等待用户选择题目 - -**请求**: -``` -POST /api/split -``` - -无需请求体。 - -**成功响应** (200): -```json -{ - "success": true, - "message": "成功分割 5 道题目", - "questions": [ - { - "question_id": "1", - "question_type": "选择题", - "content_blocks": [ - { - "block_type": "text", - "content": "下列关于函数的说法,正确的是( )", - "bbox": [100, 200, 800, 250], - "block_id": 1 - } - ], - "options": [ - "A. 函数的图像关于y轴对称", - "B. 函数是增函数", - "C. 函数的最小值为1", - "D. 函数的周期为π" - ], - "has_formula": false, - "has_image": false, - "image_refs": [] - } - ] -} -``` - -**错误响应**: - -| 状态码 | 错误 | 说明 | -|--------|------|------| -| 400 | `请先上传文件` | 未先调用 `/api/upload` | -| 500 | `{错误详情}` | Agent 执行异常 | - -**cURL 示例**: -```bash -curl -X POST http://localhost:5001/api/split -``` - ---- - -### POST /api/export — 导出错题本 - -注入用户选中的题目 ID,恢复工作流执行导出,生成 Markdown 格式的错题本。 - -**内部流程**: -1. 将 `selected_ids` 注入工作流状态 -2. 恢复工作流执行 `export` 节点 -3. 生成 `results/wrongbook.md` 文件 - -**请求**: -``` -POST /api/export -Content-Type: application/json -``` - -**请求体**: -```json -{ - "selected_ids": ["1", "3", "5"] -} -``` - -**请求参数**: - -| 字段 | 类型 | 必需 | 说明 | -|------|------|------|------| -| `selected_ids` | array of string | 是 | 要导出的题目 ID 列表 | - -**成功响应** (200): -```json -{ - "success": true, - "message": "错题本导出成功", - "output_path": "results/wrongbook.md" -} -``` - -**错误响应**: - -| 状态码 | 错误 | 说明 | -|--------|------|------| -| 400 | `未选择任何题目` | `selected_ids` 为空 | -| 400 | `请先分割题目` | 未先完成上传和分割流程 | -| 500 | `{错误详情}` | 导出处理异常 | - -**cURL 示例**: -```bash -curl -X POST http://localhost:5001/api/export \ - -H "Content-Type: application/json" \ - -d '{"selected_ids": ["1", "2", "3"]}' -``` - ---- - -### GET /api/questions — 获取题目列表 - -从 `results/questions.json` 文件读取已分割的题目列表。可在 `/api/split` 成功后随时调用获取题目数据。 - -**请求**: -``` -GET /api/questions -``` - -**成功响应** (200): -```json -{ - "success": true, - "questions": [ - { - "question_id": "1", - "question_type": "选择题", - "content_blocks": [...], - "options": [...], - "has_formula": true, - "has_image": false, - "image_refs": [] - } - ] -} -``` - -**无数据时的响应**: -```json -{ - "success": true, - "questions": [], - "message": "暂无题目数据" -} -``` - -**cURL 示例**: -```bash -curl http://localhost:5001/api/questions -``` - ---- - -### GET /preview — 预览页面 - -返回 `results/preview.html` 的内容。如果预览文件不存在,返回 404。 - -**请求**: -``` -GET /preview -``` - -**响应**: HTML 页面内容 - -**错误响应**: - -| 状态码 | 说明 | -|--------|------| -| 404 | `预览文件不存在,请先分割题目` | - ---- - -### GET /download/\ — 下载文件 - -从 `results/` 目录下载指定文件。 - -**请求**: -``` -GET /download/ -``` - -**路径参数**: - -| 参数 | 说明 | -|------|------| -| `filename` | 要下载的文件名(如 `wrongbook.md`、`questions.json`) | - -**响应**: 文件下载(`Content-Disposition: attachment`) - -**常用下载路径**: -- `/download/wrongbook.md` — 错题本 Markdown 文件 -- `/download/questions.json` — 题目 JSON 数据 -- `/download/split_issues.jsonl` — 处理问题日志 - -**cURL 示例**: -```bash -curl -O http://localhost:5001/download/wrongbook.md -``` - ---- - -## 数据结构 - -### Question 对象 - -Agent 分割后返回的题目数据结构: - -```json -{ - "question_id": "1", - "question_type": "选择题", - "content_blocks": [ - { - "block_type": "text", - "content": "题干内容...", - "bbox": [x1, y1, x2, y2], - "block_id": 1 - }, - { - "block_type": "display_formula", - "content": "x^2 + y^2 = r^2", - "bbox": [x1, y1, x2, y2], - "block_id": 2 - }, - { - "block_type": "image", - "content": "output/assets/figure_1.jpg", - "bbox": [x1, y1, x2, y2], - "block_id": 3 - } - ], - "options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], - "has_formula": true, - "has_image": true, - "image_refs": ["output/assets/figure_1.jpg"] -} -``` - -**字段说明**: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `question_id` | string | 题号标识 | -| `question_type` | string | 题型:`选择题` / `填空题` / `解答题` / `判断题` | -| `content_blocks` | array | 题目内容块列表,按 `block_id` 排序 | -| `options` | array | 选项列表(仅选择题有值) | -| `has_formula` | boolean | 是否包含数学公式 | -| `has_image` | boolean | 是否包含图片 | -| `image_refs` | array | 图片引用路径列表 | - -### ContentBlock 对象 - -题目内容块的数据结构: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `block_type` | string | 块类型:`text` / `display_formula` / `inline_formula` / `image` | -| `content` | string | 块内容(文字、LaTeX 公式或图片路径) | -| `bbox` | array | 边界框坐标 `[x1, y1, x2, y2]` | -| `block_id` | number | 块在题目中的顺序编号 | - -**block_type 取值说明**: - -| 值 | 说明 | content 格式 | -|----|------|--------------| -| `text` | 纯文字内容 | 普通文本 | -| `display_formula` | 行间公式 | LaTeX 公式(不含 `$$`) | -| `inline_formula` | 行内公式 | LaTeX 公式(不含 `$`) | -| `image` | 图片引用 | 图片文件路径 | - -### WorkflowState - -LangGraph 工作流内部状态定义(`src/workflow.py`): - -| 字段 | 类型 | 说明 | -|------|------|------| -| `file_path` | string | 上传的原始文件路径 | -| `image_paths` | array | 标准化后的图片路径列表 | -| `ocr_results` | array | PaddleOCR 解析结果列表 | -| `questions` | array | Agent 分割后的题目列表 | -| `selected_ids` | array | 用户选中的题目 ID | -| `output_path` | string | 导出文件的路径 | - ---- - -## 调用流程 - -### 完整流程示例(Python) - -```python -import requests - -BASE_URL = "http://localhost:5001" - -# 1. 检查系统状态 -status = requests.get(f"{BASE_URL}/api/status").json() -print("系统状态:", status["status"]) - -# 2. 上传文件 -with open("exam.pdf", "rb") as f: - resp = requests.post(f"{BASE_URL}/api/upload", files={"file": f}) -upload_result = resp.json() -print(f"识别到 {upload_result['result']['image_count']} 页") - -# 3. 分割题目 -resp = requests.post(f"{BASE_URL}/api/split") -split_result = resp.json() -questions = split_result["questions"] -print(f"分割出 {len(questions)} 道题目") - -# 4. 选择并导出 -selected = [q["question_id"] for q in questions] # 全选 -resp = requests.post( - f"{BASE_URL}/api/export", - json={"selected_ids": selected} -) -print("导出结果:", resp.json()["message"]) - -# 5. 下载错题本 -resp = requests.get(f"{BASE_URL}/download/wrongbook.md") -with open("wrongbook.md", "wb") as f: - f.write(resp.content) -print("错题本已下载") -``` - -### 完整流程示例(cURL) - -```bash -# 1. 检查系统状态 -curl http://localhost:5001/api/status - -# 2. 上传文件 -curl -X POST http://localhost:5001/api/upload \ - -F "file=@exam.pdf" - -# 3. 分割题目 -curl -X POST http://localhost:5001/api/split - -# 4. 导出错题本 -curl -X POST http://localhost:5001/api/export \ - -H "Content-Type: application/json" \ - -d '{"selected_ids": ["1", "2", "3"]}' - -# 5. 下载错题本 -curl -O http://localhost:5001/download/wrongbook.md -``` - ---- - -## 错误处理 - -### HTTP 状态码 - -| 状态码 | 说明 | -|--------|------| -| 200 | 请求成功 | -| 400 | 客户端错误(参数缺失、文件格式不支持等) | -| 404 | 资源不存在(预览文件未生成等) | -| 413 | 上传文件超过大小限制 | -| 500 | 服务端内部错误 | - -### 常见错误场景 - -| 场景 | 错误信息 | 解决方法 | -|------|----------|----------| -| 未上传文件直接分割 | `请先上传文件` | 先调用 `/api/upload` | -| 未分割直接导出 | `请先分割题目` | 先调用 `/api/split` | -| 上传不支持的格式 | `不支持的文件格式` | 使用支持的格式上传 | -| 导出时未选题目 | `未选择任何题目` | `selected_ids` 不能为空 | -| API 密钥未配置 | 500 错误 | 检查 `.env` 中的 API 密钥 | - -### 注意事项 - -1. **会话状态**: 系统使用全局 `thread_id` 管理会话,一次只能处理一个文件。新的上传会覆盖之前的会话状态。 -2. **调用顺序**: 必须按 `upload → split → export` 的顺序调用,不能跳步。 -3. **并发限制**: 当前实现使用全局变量管理会话,不支持多用户并发访问。生产环境部署需要改造为基于请求的会话管理。 - ---- - -*文档版本: v1.0.0* diff --git a/docs/HANDWRITTEN_API.md b/docs/HANDWRITTEN_API.md deleted file mode 100644 index 83768047..00000000 --- a/docs/HANDWRITTEN_API.md +++ /dev/null @@ -1,287 +0,0 @@ -# 手写体 vs 印刷体识别 API - -## 概述 - -`printed-vs-handwritten/app.py` 提供了一个 Flask Web API,用于分类图片内容是手写还是印刷体。 - -## 技术架构 - -- **框架**: Flask -- **模型**: TypegroupsClassifier (OCR-D typegroups classifier) -- **端口**: 5000 -- **模型路径**: `ocrd_typegroups_classifier/models/classifier.tgc` - -## API 端点 - -### 1. 健康检查 - -```http -GET / -``` - -**响应示例**: -```json -{ - "status": "ok", - "message": "Printed vs Handwritten Classification API", - "endpoints": { - "/classify": "POST - Classify an image (multipart/form-data with 'image' field)", - "/classify_base64": "POST - Classify a base64 encoded image (JSON with 'image' field)" - } -} -``` - -### 2. 分类图片(文件上传) - -```http -POST /classify -Content-Type: multipart/form-data -``` - -**请求参数**: -- `image`: 图片文件 (multipart/form-data) - -**响应示例**: -```json -{ - "success": true, - "prediction": "printed", - "confidence": 0.9523, - "probabilities": { - "printed": 0.9523, - "handwritten": 0.0477 - } -} -``` - -### 3. 分类图片(Base64) - -```http -POST /classify_base64 -Content-Type: application/json -``` - -**请求体**: -```json -{ - "image": "base64_encoded_image_string" -} -``` - -**响应示例**: -```json -{ - "success": true, - "prediction": "handwritten", - "confidence": 0.8745, - "probabilities": { - "printed": 0.1255, - "handwritten": 0.8745 - } -} -``` - -## 分类结果 - -### 可能的预测值 - -- `"printed"` - 印刷体 -- `"handwritten"` - 手写体 - -### 置信度 - -- `confidence`: 预测类别的置信度 (0-1) -- `probabilities`: 所有类别的概率分布 - -## 使用示例 - -### Python 调用示例 - -#### 方式1: 文件上传 - -```python -import requests - -url = "http://localhost:5000/classify" -files = {"image": open("test_image.jpg", "rb")} - -response = requests.post(url, files=files) -result = response.json() - -print(f"预测结果: {result['prediction']}") -print(f"置信度: {result['confidence']:.2%}") -``` - -#### 方式2: Base64 编码 - -```python -import requests -import base64 - -# 读取并编码图片 -with open("test_image.jpg", "rb") as f: - image_data = base64.b64encode(f.read()).decode("ascii") - -url = "http://localhost:5000/classify_base64" -payload = {"image": image_data} - -response = requests.post(url, json=payload) -result = response.json() - -print(f"预测结果: {result['prediction']}") -print(f"置信度: {result['confidence']:.2%}") -``` - -### 工具函数封装 - -```python -import requests -import base64 -from typing import Dict, Any - - -def classify_image_file(image_path: str, api_url: str = "http://localhost:5000") -> Dict[str, Any]: - """ - 分类图片文件(文件上传方式) - - Args: - image_path: 图片文件路径 - api_url: API服务地址 - - Returns: - 分类结果字典 - """ - url = f"{api_url}/classify" - files = {"image": open(image_path, "rb")} - - response = requests.post(url, files=files) - return response.json() - - -def classify_image_base64(image_path: str, api_url: str = "http://localhost:5000") -> Dict[str, Any]: - """ - 分类图片文件(Base64方式) - - Args: - image_path: 图片文件路径 - api_url: API服务地址 - - Returns: - 分类结果字典 - """ - # 读取并编码图片 - with open(image_path, "rb") as f: - image_data = base64.b64encode(f.read()).decode("ascii") - - url = f"{api_url}/classify_base64" - payload = {"image": image_data} - - response = requests.post(url, json=payload) - return response.json() - - -# 使用示例 -if __name__ == "__main__": - result = classify_image_file("test.jpg") - - if result.get("success"): - print(f"✓ 预测结果: {result['prediction']}") - print(f" 置信度: {result['confidence']:.2%}") - print(f" 概率分布: {result['probabilities']}") - else: - print(f"✗ 错误: {result.get('error')}") -``` - -## 启动服务 - -```bash -cd printed-vs-handwritten -python app.py -``` - -服务将在 `http://0.0.0.0:5000` 启动。 - -## 集成到错题本工作流 - -### 应用场景 - -在错题本生成流程中,可以用手写体识别来: - -1. **题目分类优化** - - 识别题目区域是手写还是印刷 - - 为手写题目提供不同的OCR参数或处理策略 - -2. **答案区域检测** - - 检测学生是否已经手写作答 - - 区分原始题目和学生答案 - -3. **质量控制** - - 过滤掉手写笔记区域 - - 只保留印刷体题目进行结构化处理 - -### 集成方式 - -可以作为 Agent 工具集成: - -```python -from langchain_core.tools import tool -import requests - -@tool -def classify_text_type(image_path: str) -> str: - """ - 判断图片中的文字是手写还是印刷体 - - Args: - image_path: 图片路径 - - Returns: - "printed" 或 "handwritten" - """ - try: - url = "http://localhost:5000/classify" - files = {"image": open(image_path, "rb")} - - response = requests.post(url, files=files) - result = response.json() - - if result.get("success"): - return f"{result['prediction']} (置信度: {result['confidence']:.2%})" - else: - return f"分类失败: {result.get('error')}" - except Exception as e: - return f"调用API出错: {str(e)}" -``` - -## 模型参数 - -在代码中使用的模型参数: - -```python -result = tgc.classify( - img, - stride=75, # 滑动窗口步长 - batch_size=64, # 批处理大小 - score_as_key=False -) -``` - -这些参数可以根据实际需求调整以优化速度和精度。 - -## 注意事项 - -1. **图片格式**: 自动转换为 RGB 模式 -2. **模型加载**: 首次调用时加载模型(约需几秒) -3. **并发**: Flask 默认单线程,高并发需要使用 gunicorn 等 -4. **内存**: 模型会常驻内存(约200-500MB) - -## 性能优化建议 - -1. **生产部署**: 使用 gunicorn 代替 Flask 开发服务器 - ```bash - gunicorn -w 4 -b 0.0.0.0:5000 app:app - ``` - -2. **批量处理**: 如果需要处理多张图片,可以扩展 API 支持批量接口 - -3. **缓存**: 对于相同图片可以缓存结果避免重复计算 diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md deleted file mode 100644 index d1864642..00000000 --- a/docs/PROJECT_STRUCTURE.md +++ /dev/null @@ -1,1359 +0,0 @@ -# 错题本生成系统 - 项目技术文档 - -## 📋 项目概述 - -### 系统简介 -这是一个基于 **LangChain Agent** 和 **PaddleOCR API** 的智能错题本生成系统。系统能够自动识别、分割和导出试卷/作业中的题目,生成结构化的错题本,支持选择题、填空题、解答题、判断题等多种题型。 - -### 核心功能 -- ✅ PDF/图片文件自动标准化处理 -- ✅ 基于PaddleOCR的文档结构化解析(支持公式、图片、布局识别) -- ✅ 基于DeepSeek的智能题目分割(AI自动识别题号、题型、选项) -- ✅ 交互式HTML预览(可视化选择题目) -- ✅ Markdown格式错题本导出 - -### 技术栈 -- **LLM框架**: LangChain + LangGraph -- **Agent**: DeepSeek Chat (via `create_deep_agent`) -- **OCR**: PaddleOCR-VL API -- **文档处理**: pdf2image, Pillow -- **CLI**: Rich (美化输出) -- **配置**: python-dotenv - ---- - -## 🗂️ 目录结构 - -``` -error_correction/ -│ -├── error_correction_agent/ # 🤖 Agent智能层 -│ ├── agent.py # Agent工厂函数和配置 -│ ├── prompts.py # 系统提示词(题目分割规则) -│ └── tools/ # Agent工具集 -│ ├── __init__.py # 工具统一导出 -│ ├── question_tools.py # 题目处理工具 -│ └── file_tools.py # 文件I/O工具 -│ -├── src/ # ⚙️ 确定性业务逻辑层 -│ ├── paddleocr_client.py # PaddleOCR API客户端 -│ ├── workflow.py # 5步工作流编排 -│ └── utils.py # 通用工具函数 -│ -├── network_search_agent/ # 🔍 网络搜索Agent(独立模块) -│ ├── prompts.py -│ └── tools.py -│ -├── printed-vs-handwritten/ # ✍️ 手写/印刷识别子项目 -│ ├── app.py # Flask API服务(端口5000) -│ ├── classify.py # 分类器CLI -│ ├── train-model.py # 模型训练 -│ └── ocrd_typegroups_classifier/ # 分类器核心库 -│ -├── input/ # 📥 输入文件目录 -├── output/ # 📤 输出目录 -│ ├── pages/ # 标准化后的PNG图片 -│ ├── struct/ # PaddleOCR解析结果(JSON) -│ └── assets/ # 下载的图片资源 -│ -├── results/ # 🎯 最终结果目录 -│ ├── questions.json # Agent分割后的题目 -│ ├── split_issues.jsonl # 分割过程问题日志 -│ ├── preview.html # 交互式预览页面 -│ └── wrongbook.md # 最终错题本 -│ -├── docs/ # 📚 文档 -│ ├── INDEX.md # LangChain文档索引 -│ ├── HANDWRITTEN_API.md # 手写体识别API文档 -│ └── PROJECT_STRUCTURE.md # 本文档 -│ -├── langgraph.json # LangGraph配置文件 -├── .env # 环境变量配置(不提交) -├── .env.example # 配置模板 -├── README.md # 快速开始指南 -├── requirements.txt # Python依赖 -├── test_paddleocr.py # PaddleOCR测试脚本 -├── test_workflow.py # 工作流测试脚本 -└── agent.py # network_search_agent入口 -``` - ---- - -## 🔧 核心模块详解 - -### 1. error_correction_agent/ - Agent智能层 - -#### 📄 agent.py - -**作用**: 创建和配置题目分割Agent - -**核心函数**: - -```python -def create_question_split_agent() -> Agent: - """ - 创建题目分割Agent - - 使用DeepSeek模型和定义的工具创建一个专门用于分割题目的Agent。 - - Returns: - 配置好的Agent实例 - """ - # 初始化DeepSeek模型 - model = init_chat_model( - model="deepseek:deepseek-chat", - temperature=0.1, # 低温度确保稳定输出 - ) - - # 绑定4个工具 - tools = [ - save_questions, - log_issue, - download_image, - read_ocr_result, - ] - - # 创建Agent - agent = create_deep_agent( - model=model, - tools=tools, - system_prompt=SYSTEM_PROMPT, - ) - - return agent -``` - -**全局导出**: -```python -agent = create_question_split_agent() # 供langgraph.json使用 -``` - -**依赖关系**: -``` -create_question_split_agent() -├─→ init_chat_model() [langchain.chat_models] -├─→ create_deep_agent() [deepagents] -├─→ SYSTEM_PROMPT [.prompts] -└─→ 4 Tools [.tools] - ├─→ save_questions - ├─→ log_issue - ├─→ download_image - └─→ read_ocr_result -``` - ---- - -#### 📄 prompts.py - -**作用**: 定义Agent的系统提示词,指导题目分割行为 - -**核心内容**: - -```python -SYSTEM_PROMPT = """# 错题本题目分割专家 - -你是一个专业的试卷题目分割专家。你的任务是分析OCR识别的文档结构,智能分割出独立的题目。 - -## 核心任务 -从PaddleOCR解析的结构化数据中: -1. 识别题目边界(题号是关键标志) -2. 提取题干内容 -3. 识别并提取选项(如果是选择题) -4. 识别公式块(display_formula 和 inline_formula) -5. 识别图片块并保留引用 -6. 按阅读顺序组织内容 - -## 题目类型识别 -- 选择题: 包含A/B/C/D选项 -- 填空题: 包含下划线或括号 -- 解答题: 要求"解答"、"证明"、"计算" -- 判断题: 要求判断"正确/错误" - -## 题号识别规则 -- 1. / 2. / 3. ... -- 1、/ 2、/ 3、... -- (1)/(2)/(3)... -- 一、/ 二、/ 三、... - -## 工作流程 -1. 分析block顺序(使用block_order) -2. 识别题目起点(查找题号) -3. 收集题目内容(直到下一个题号) -4. 结构化输出 - -...(详见完整提示词) -""" -``` - -**输出格式规范**: -```json -{ - "question_id": "1", - "question_type": "选择题", - "content_blocks": [ - { - "block_type": "text|display_formula|inline_formula|image", - "content": "内容文本", - "bbox": [x1, y1, x2, y2], - "block_id": 原始ID - } - ], - "options": ["A. ...", "B. ...", "C. ...", "D. ..."], - "has_formula": true, - "has_image": false, - "image_refs": ["imgs/xxx.jpg"] -} -``` - ---- - -#### 📄 tools/question_tools.py - -**工具1: save_questions** - -```python -@tool(parse_docstring=True) -def save_questions( - questions: List[Dict[str, Any]], - output_path: str = None -) -> str: - """ - 保存分割后的题目列表到JSON文件 - - Args: - questions: 题目列表,每个题目包含: - - question_id: 题号 - - question_type: 题型 - - content_blocks: 内容块列表 - - options: 选项(选择题) - - has_formula: 是否包含公式 - - has_image: 是否包含图片 - - image_refs: 图片引用路径列表 - output_path: 输出路径(默认: {RESULTS_DIR}/questions.json) - - Returns: - 保存结果消息 - """ -``` - -**使用场景**: Agent分割完成后,调用此工具保存题目列表 - -**工具2: log_issue** - -```python -@tool(parse_docstring=True) -def log_issue( - issue_type: str, - description: str, - block_info: Dict[str, Any] = None -) -> str: - """ - 记录题目分割过程中遇到的问题 - - Args: - issue_type: 问题类型 - - "unclear_boundary": 边界不清 - - "missing_question_number": 缺少题号 - - "complex_structure": 复杂结构 - description: 问题详细描述 - block_info: 相关block信息(可选) - - Returns: - 记录结果消息 - """ -``` - -**输出**: 追加到 `{RESULTS_DIR}/split_issues.jsonl` - -**使用场景**: Agent遇到不确定情况时记录,供人工审核或算法改进 - ---- - -#### 📄 tools/file_tools.py - -**工具3: download_image** - -```python -@tool(parse_docstring=True) -def download_image(image_url: str, save_path: str) -> str: - """ - 从URL下载图片到本地 - - Args: - image_url: 图片URL地址 - save_path: 保存路径(相对于ASSETS_DIR) - - Returns: - 下载结果消息 - """ -``` - -**使用场景**: 下载PaddleOCR返回的图片资源 - -**工具4: read_ocr_result** - -```python -@tool(parse_docstring=True) -def read_ocr_result(result_path: str) -> Dict[str, Any]: - """ - 读取PaddleOCR的解析结果 - - Args: - result_path: OCR结果文件路径(JSON) - - Returns: - OCR解析结果字典或错误信息 - """ -``` - -**使用场景**: Agent需要重新读取或分析OCR结果时 - ---- - -### 2. src/ - 确定性业务逻辑层 - -#### 📄 paddleocr_client.py - -**类: PaddleOCRClient** - -**初始化方法**: -```python -def __init__(self): - """ - 初始化客户端,从环境变量读取配置 - - 环境变量: - PADDLEOCR_API_URL: API端点 - PADDLEOCR_API_TOKEN: 认证令牌 - PADDLEOCR_USE_DOC_ORIENTATION: 启用方向检测 - PADDLEOCR_USE_DOC_UNWARPING: 启用文档展平 - PADDLEOCR_USE_CHART_RECOGNITION: 启用图表识别 - """ -``` - -**主要方法**: - -**1️⃣ parse_image** -```python -def parse_image( - self, - image_path: str, - save_output: bool = True, - output_dir: Optional[str] = None -) -> Dict[str, Any]: - """ - 解析图片并返回结构化结果 - - 流程: - 1. 读取图片并转为Base64 - 2. 构建API请求payload - 3. 调用PaddleOCR API - 4. 保存JSON结果到 {STRUCT_DIR}/{filename}_struct.json - 5. 下载并保存响应中的图片和Markdown - - Args: - image_path: 图片文件路径 - save_output: 是否保存输出(默认True) - output_dir: 输出目录(默认从STRUCT_DIR读取) - - Returns: - PaddleOCR API返回的结构化结果 - """ -``` - -**返回结构示例**: -```python -{ - "layoutParsingResults": [ - { - "prunedResult": { - "parsing_res_list": [ - { - "block_id": 1, - "block_type": "text", - "block_label": "paragraph_title", - "block_content": "1. 题目内容...", - "block_bbox": [x1, y1, x2, y2], - "block_order": 1, - "group_id": 1 - }, - { - "block_type": "display_formula", - "block_content": "x^2 + y^2 = r^2", - ... - } - ] - }, - "block_order": [1, 2, 3, 4, ...], # 推荐阅读顺序 - "markdown": { - "text": "## 第一题\n\n1. ...", - "images": { - "imgs/figure_1.jpg": "https://..." - } - }, - "outputImages": { - "layout": "https://...", - "table": "https://..." - } - } - ] -} -``` - -**2️⃣ parse_pdf** -```python -def parse_pdf( - self, - pdf_path: str, - save_output: bool = True, - output_dir: Optional[str] = None -) -> Dict[str, Any]: - """ - 解析PDF并返回结构化结果 - - 功能同parse_image,但处理PDF文件 - """ -``` - -**3️⃣ _save_images** -```python -def _save_images(self, result: Dict[str, Any], output_dir: str): - """ - 下载并保存结果中的图片资源 - - 保存内容: - 1. Markdown文档 (doc_0.md, doc_1.md, ...) - 2. Markdown中的图片 - 3. outputImages中的可视化图片 - """ -``` - ---- - -#### 📄 workflow.py - -**类: ErrorCorrectionWorkflow** - -**作用**: 编排5步完整工作流 - -**初始化**: -```python -def __init__(self): - """ - 初始化工作流 - - 属性: - paddleocr_client: PaddleOCR客户端实例 - image_paths: 步骤1输出(标准化图片路径) - ocr_results: 步骤2输出(OCR解析结果) - questions: 步骤3输出(Agent分割的题目) - """ -``` - -**5步工作流**: - -| 步骤 | 方法 | 类型 | 输入 | 输出 | -|-----|------|------|------|------| -| 1 | `prepare_input` | 确定性 | `file_path: str` | `image_paths: List[str]` | -| 2 | `paddleocr_parse` | 确定性 | `image_paths: List[str]` | `ocr_results: List[Dict]` | -| 3 | `split_questions_with_agent` | 🤖智能 | `ocr_results: List[Dict]` | `questions: List[Dict]` | -| 4 | `build_preview` | 确定性 | `questions: List[Dict]` | `preview.html` | -| 5 | `export_selected` | 确定性 | `questions, selected_ids` | `wrongbook.md` | - -**核心方法详解**: - -**主入口: run** -```python -def run(self, input_file: str, auto_split: bool = False) -> Dict[str, Any]: - """ - 运行完整工作流 - - Args: - input_file: 输入文件路径(PDF或图片) - auto_split: 是否自动调用Agent分割(默认False) - - Returns: - 包含各步骤结果的字典: - - image_paths: 标准化图片路径列表 - - ocr_results: OCR解析结果列表 - - questions: 分割后的题目列表 - - 执行步骤: - 1. 准备输入文件(调用utils.prepare_input) - 2. PaddleOCR解析(调用self.paddleocr_parse) - 3. (可选) Agent分割题目 - 4. (如有题目) 生成HTML预览 - """ -``` - -**步骤2实现**: -```python -def paddleocr_parse(self, image_paths: List[str]) -> List[Dict[str, Any]]: - """ - 调用PaddleOCR解析文档结构 - - Args: - image_paths: 图片路径列表 - - Returns: - 每张图片的PaddleOCR解析结果列表 - - 过程: - 对每张图片调用 PaddleOCRClient.parse_image() - 自动保存JSON和图片资源 - """ -``` - -**步骤3实现**(核心智能步骤): -```python -def split_questions_with_agent(self) -> List[Dict[str, Any]]: - """ - 使用Agent智能分割题目 - - Returns: - 分割后的题目列表 - - 执行流程: - 1. 创建Agent实例 - 2. 简化OCR结果为Agent友好格式 - 3. 构建提示词 - 4. 调用Agent.invoke() - 5. 从 {RESULTS_DIR}/questions.json 读取Agent保存的结果 - - 简化格式: - simplified_results = [ - { - "blocks": [...], # parsing_res_list - "block_order": [...] # 推荐阅读顺序 - } - ] - """ -``` - -**步骤5实现**: -```python -def export_selected( - self, - selected_ids: List[str], - output_path: str = None -) -> str: - """ - 导出选中的题目为错题本 - - Args: - selected_ids: 用户选择的题目ID列表 - output_path: 输出路径(可选) - - Returns: - 导出文件路径 - - 过程: - 1. 过滤选中题目 - 2. 调用 utils.export_wrongbook() - 3. 返回Markdown文件路径 - """ -``` - ---- - -#### 📄 utils.py - -**函数1: prepare_input (步骤1)** - -```python -def prepare_input(file_path: str) -> List[str]: - """ - 准备输入文件 - 统一转换为标准PNG图片 - - Args: - file_path: 输入文件路径(PDF或图片) - - Returns: - 标准化后的PNG图片路径列表 - - 处理逻辑: - 如果是PDF: - 1. 使用pdf2image转换为300dpi的图片 - 2. 逐页保存为PNG格式 - 3. 命名: {stem}_page_001.png, {stem}_page_002.png, ... - - 如果是图片: - 1. 打开图片 - 2. 转换为RGB模式 - 3. 保存为PNG格式 - 4. 命名: {stem}.png - - 输出目录: {PAGES_DIR} - - 支持格式: - - PDF: .pdf - - 图片: .jpg, .jpeg, .png, .bmp, .tiff, .webp - - Raises: - FileNotFoundError: 文件不存在 - ValueError: 不支持的文件格式 - """ -``` - -**函数2: build_preview (步骤4)** - -```python -def build_preview( - questions: List[Dict[str, Any]], - output_path: str = None -) -> str: - """ - 生成HTML预览页面 - - Args: - questions: 题目列表 - output_path: 输出路径(默认: {RESULTS_DIR}/preview.html) - - Returns: - HTML文件路径 - - 页面功能: - 1. 展示所有题目(题号、题型、内容) - 2. 支持点击选择题目 - 3. 选中状态可视化(按钮变色) - 4. 底部导出按钮(显示选中题目ID) - - 样式特点: - - 题目卡片布局 - - 题型色标(选择题=绿色) - - 公式高亮显示 - - 图片引用标记 - - 响应式设计 - """ -``` - -**函数3: export_wrongbook (步骤5)** - -```python -def export_wrongbook( - questions: List[Dict[str, Any]], - selected_ids: List[str], - output_path: str = None -) -> str: - """ - 导出错题本为Markdown格式 - - Args: - questions: 题目列表 - selected_ids: 选中的题目ID列表 - output_path: 输出路径(默认: {RESULTS_DIR}/wrongbook.md) - - Returns: - Markdown文件路径 - - Markdown格式: - # 错题本 - > 共收录 N 道题目 - --- - - ## 1. 题目 X (题型) - - [题干内容] - [选项或公式] - [图片引用] - - ### 我的答案 - _(请在此处填写你的答案)_ - - ### 正确答案 - _(请在此处填写正确答案)_ - - ### 解析 - _(请在此处填写解题思路和知识点)_ - - --- - - 特性: - - 支持LaTeX公式($...$和$$...$$) - - 图片Markdown引用 - - 预留答案和解析填写区域 - """ -``` - ---- - -## 🔗 模块依赖关系图 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ User Entry Point │ -│ ErrorCorrectionWorkflow.run() │ -└──────────────┬──────────────────────────────────────────────┘ - │ - ├── [步骤1] prepare_input(file_path) - │ │ - │ ├─ PDF? → pdf2image.convert_from_path() - │ └─ 图片? → PIL.Image.open() → save PNG - │ │ - │ └→ List[image_paths] - │ - ├── [步骤2] paddleocr_parse(image_paths) - │ │ - │ └─ for each image: - │ ├─ PaddleOCRClient.parse_image() - │ │ ├─ base64编码 - │ │ ├─ requests.post(API_URL) - │ │ ├─ 保存JSON结果 - │ │ └─ _save_images() - │ │ ├─ 下载markdown图片 - │ │ ├─ 下载output图片 - │ │ └─ 保存markdown文档 - │ │ - │ └→ List[ocr_results] - │ - ├── [步骤3] split_questions_with_agent() 🤖 - │ │ - │ ├─ create_question_split_agent() - │ │ ├─ init_chat_model("deepseek:deepseek-chat") - │ │ ├─ tools = [save_questions, log_issue, ...] - │ │ └─ create_deep_agent(model, tools, SYSTEM_PROMPT) - │ │ - │ ├─ 简化OCR结果: - │ │ simplified_results = [ - │ │ {"blocks": [...], "block_order": [...]} - │ │ ] - │ │ - │ ├─ 构建提示词 - │ │ - │ ├─ agent.invoke({ - │ │ "messages": [ - │ │ {"role": "user", "content": prompt}, - │ │ {"role": "user", "content": OCR数据} - │ │ ] - │ │ }) - │ │ │ - │ │ └─ Agent内部流程: - │ │ ├─ 分析题号和内容结构 - │ │ ├─ 识别题型 - │ │ ├─ 可能调用: download_image() - │ │ ├─ 可能调用: log_issue() - │ │ └─ 调用: save_questions() - │ │ └─ 保存到 results/questions.json - │ │ - │ └─ 读取 questions.json → List[questions] - │ - ├── [步骤4] build_preview(questions) - │ │ - │ ├─ 构建HTML内容 - │ │ ├─ 题目卡片 - │ │ ├─ 选择按钮 - │ │ └─ JavaScript交互 - │ │ - │ └─ 保存到 results/preview.html - │ - └── [步骤5] export_selected(questions, selected_ids) - │ - ├─ 过滤选中题目 - ├─ 生成Markdown内容 - │ ├─ 题干 - │ ├─ 选项/公式 - │ ├─ 答案区域(空白) - │ └─ 解析区域(空白) - │ - └─ 保存到 results/wrongbook.md -``` - ---- - -## ⚙️ 环境变量配置表 - -| 变量 | 必需? | 用途 | 示例值 | 默认值 | -|-----|------|------|--------|--------| -| **PaddleOCR配置** | | | | | -| `PADDLEOCR_API_URL` | ✅ | PaddleOCR API端点 | `https://api.xxx.com/layout-parsing` | - | -| `PADDLEOCR_API_TOKEN` | ✅ | API认证令牌 | `token_abc123` | - | -| `PADDLEOCR_USE_DOC_ORIENTATION` | ❌ | 启用文档方向检测 | `true` / `false` | `false` | -| `PADDLEOCR_USE_DOC_UNWARPING` | ❌ | 启用文档展平 | `true` / `false` | `false` | -| `PADDLEOCR_USE_CHART_RECOGNITION` | ❌ | 启用图表识别 | `true` / `false` | `false` | -| **DeepSeek配置** | | | | | -| `DEEPSEEK_API_KEY` | ✅ | DeepSeek API密钥 | `sk-xxx` | - | -| `DEEPSEEK_BASE_URL` | ❌ | DeepSeek基础URL | `https://api.deepseek.com` | 自动 | -| **LangSmith配置** | | | | | -| `LANGSMITH_TRACING` | ❌ | 启用LangSmith追踪 | `true` / `false` | `false` | -| `LANGSMITH_API_KEY` | ❌ | LangSmith认证密钥 | `lsv2_pt_xxx` | - | -| `LANGSMITH_PROJECT` | ❌ | LangSmith项目名称 | `error-correction` | - | -| `LANGSMITH_ENDPOINT` | ❌ | LangSmith端点 | `https://api.smith.langchain.com` | 默认 | -| **其他API** | | | | | -| `TAVILY_API_KEY` | ❌ | Tavily搜索API密钥 | `tvly-xxx` | - | -| `ANTHROPIC_API_KEY` | ❌ | Anthropic API密钥 | `sk-ant-xxx` | - | -| **输出目录配置** | | | | | -| `OUTPUT_DIR` | ❌ | 总输出目录 | `output` | `output` | -| `PAGES_DIR` | ❌ | 标准化图片目录 | `output/pages` | `output/pages` | -| `STRUCT_DIR` | ❌ | OCR结果目录 | `output/struct` | `output/struct` | -| `ASSETS_DIR` | ❌ | 资源目录 | `output/assets` | `output/assets` | -| `RESULTS_DIR` | ❌ | 最终结果目录 | `results` | `results` | - ---- - -## 💻 使用流程示例 - -### 方式1: 分步骤手动控制 - -```python -from src.workflow import ErrorCorrectionWorkflow - -# 创建工作流实例 -workflow = ErrorCorrectionWorkflow() - -# 步骤1-2: 准备输入和OCR解析 -result = workflow.run("input/exam_paper.pdf", auto_split=False) - -print(f"已处理 {len(result['image_paths'])} 张图片") -print(f"OCR解析完成,结果保存在 output/struct/") - -# 步骤3: 手动调用Agent分割题目 -questions = workflow.split_questions_with_agent() - -print(f"分割出 {len(questions)} 道题目") - -# 步骤4: 自动生成HTML预览 -# (在run()中已自动调用) - -# 步骤5: 导出选中的题目 -selected_ids = ["1", "2", "5", "7"] -wrongbook_path = workflow.export_selected(selected_ids) - -print(f"错题本已导出: {wrongbook_path}") -``` - -### 方式2: 自动完整流程 - -```python -from src.workflow import ErrorCorrectionWorkflow - -# 创建工作流实例 -workflow = ErrorCorrectionWorkflow() - -# 自动执行步骤1-4 -workflow.run("input/exam_paper.pdf", auto_split=True) - -# 打开 results/preview.html 查看题目 -# 选择题目后,手动导出 -workflow.export_selected(["1", "3", "5"]) -``` - -### 方式3: 仅测试PaddleOCR - -```python -from src.paddleocr_client import PaddleOCRClient - -client = PaddleOCRClient() - -# 解析单张图片 -result = client.parse_image("input/page1.jpg") - -# 查看结果 -print(f"识别的块数量: {len(result['layoutParsingResults'][0]['prunedResult']['parsing_res_list'])}") - -# 结果自动保存到 output/struct/page1_struct.json -``` - -### 方式4: 使用测试脚本 - -```bash -# 测试步骤1-2(不调用Agent) -python test_workflow.py - -# 测试完整流程(包含Agent) -python test_workflow.py --with-agent - -# 测试PaddleOCR -python test_paddleocr.py -``` - ---- - -## 🤖 Agent执行流程详解 - -### Agent接收的数据格式 - -```python -simplified_results = [ - { - "blocks": [ - { - "block_id": 1, - "block_type": "text", - "block_label": "paragraph_title", - "block_content": "1. 下列关于平行四边形的说法,正确的是( )", - "block_bbox": [120, 300, 800, 350], - "block_order": 1, - "group_id": 1 - }, - { - "block_id": 2, - "block_type": "text", - "block_label": "text", - "block_content": "A. 对边相等", - "block_bbox": [150, 360, 400, 390], - "block_order": 2, - "group_id": 1 - }, - { - "block_id": 3, - "block_type": "display_formula", - "block_content": "\\sum_{i=1}^{n} x_i = S", - "block_bbox": [200, 400, 600, 450], - "block_order": 3, - "group_id": 1 - } - ], - "block_order": [1, 2, 3, 4, 5, ...] - } -] -``` - -### Agent的思考过程 - -``` -1️⃣ 接收OCR数据 - ↓ -2️⃣ 分析block_order,按推荐顺序扫描 - ↓ -3️⃣ 识别题号模式 - - 检测 "1." / "1、" / "(1)" 等 - - 记录题目起始位置 - ↓ -4️⃣ 收集题目内容 - - 从题号开始收集后续block - - 遇到下一个题号停止 - - 包括text、formula、image等 - ↓ -5️⃣ 识别题型 - - 是否包含选项 (A/B/C/D) → 选择题 - - 是否包含下划线 → 填空题 - - 是否要求"解答" → 解答题 - ↓ -6️⃣ 提取选项(如果是选择题) - - 识别A. B. C. D.模式 - - 或A) B) C) D)模式 - ↓ -7️⃣ 处理特殊内容 - - 公式: has_formula = true - - 图片: has_image = true, image_refs = [...] - ↓ -8️⃣ 可能的工具调用 - - download_image() - 如需保存图片 - - log_issue() - 如遇到不确定情况 - ↓ -9️⃣ 结构化输出 - ↓ -🔟 调用 save_questions() 保存结果 -``` - -### Agent工具调用时机 - -| 工具 | 调用时机 | 示例场景 | -|-----|---------|---------| -| `save_questions` | 必须 | 分割完成后保存题目列表 | -| `log_issue` | 可选 | 题号识别不确定时记录 | -| `download_image` | 可选 | OCR结果包含图片URL时 | -| `read_ocr_result` | 可选 | 需要重新分析OCR数据时 | - -### Agent执行日志示例 - -``` -[Agent] 正在分析OCR数据... -[Agent] 识别到题号: "1." -[Agent] 题型判断: 选择题 (检测到选项A、B、C、D) -[Agent] 收集内容块: block_1, block_2, block_3, block_4 -[Agent] 检测到公式块: display_formula -[Tool] 调用 save_questions: 保存1道题目 -[Agent] 识别到题号: "2." -[Agent] 题型判断: 填空题 (检测到下划线) -[Tool] 调用 log_issue: 题号2的边界不清晰 -[Tool] 调用 save_questions: 保存2道题目 -[Agent] 分割完成,共处理2道题目 -``` - ---- - -## 🔧 扩展性设计 - -### 1. 添加新Agent工具 - -#### 步骤1: 创建工具文件 - -在 `error_correction_agent/tools/` 创建新文件(如 `image_tools.py`): - -```python -from langchain_core.tools import tool -from PIL import Image - -@tool(parse_docstring=True) -def crop_question_region(image_path: str, bbox: list) -> str: - """ - 根据bbox坐标裁剪题目区域 - - Args: - image_path: 原始图片路径 - bbox: 边界框坐标 [x1, y1, x2, y2] - - Returns: - 裁剪后的图片路径 - """ - img = Image.open(image_path) - cropped = img.crop(tuple(bbox)) - - output_path = f"output/assets/cropped_{os.path.basename(image_path)}" - cropped.save(output_path) - - return f"已裁剪并保存到: {output_path}" -``` - -#### 步骤2: 导出工具 - -在 `error_correction_agent/tools/__init__.py` 添加: - -```python -from .image_tools import crop_question_region - -__all__ = [ - "save_questions", - "log_issue", - "download_image", - "read_ocr_result", - "crop_question_region", # 新增 -] -``` - -#### 步骤3: 绑定到Agent - -在 `error_correction_agent/agent.py` 添加: - -```python -from .tools import ( - save_questions, - log_issue, - download_image, - read_ocr_result, - crop_question_region # 新增 -) - -def create_question_split_agent(): - tools = [ - save_questions, - log_issue, - download_image, - read_ocr_result, - crop_question_region, # 新增 - ] - ... -``` - -### 2. 自定义工作流 - -#### 在现有流程中插入步骤 - -编辑 `src/workflow.py`: - -```python -class ErrorCorrectionWorkflow: - - def run(self, input_file: str, auto_split: bool = False): - # 原有步骤 - self.image_paths = prepare_input(input_file) - self.ocr_results = self.paddleocr_parse(self.image_paths) - - # 新增步骤: 手写体识别 - self.classify_handwritten() - - # 继续原有流程 - if auto_split: - self.questions = self.split_questions_auto() - ... - - def classify_handwritten(self): - """新步骤: 识别手写/印刷体""" - import requests - - for image_path in self.image_paths: - files = {"image": open(image_path, "rb")} - response = requests.post( - "http://localhost:5000/classify", - files=files - ) - result = response.json() - - if result["prediction"] == "handwritten": - console.print(f"[yellow]检测到手写内容: {image_path}[/yellow]") -``` - -#### 创建新工作流变体 - -```python -class InteractiveWorkflow(ErrorCorrectionWorkflow): - """交互式工作流,每步都请求确认""" - - def run(self, input_file: str): - # 步骤1 - self.image_paths = prepare_input(input_file) - input("按Enter继续步骤2...") - - # 步骤2 - self.ocr_results = self.paddleocr_parse(self.image_paths) - input("按Enter继续步骤3...") - - # 步骤3 - self.questions = self.split_questions_with_agent() - ... -``` - -### 3. 调整Agent行为 - -#### 修改提示词 - -编辑 `error_correction_agent/prompts.py`: - -```python -SYSTEM_PROMPT = """# 错题本题目分割专家 - -...(原有内容) - -## 新增规则 - -### 识别小题 -如果题目包含子题(如1. (1) (2) (3)),请拆分为独立题目: -- question_id: "1-1", "1-2", "1-3" -- parent_id: "1" - -### 数学题型细分 -对于解答题,进一步识别: -- "计算题": 要求计算具体数值 -- "证明题": 要求证明结论 -- "应用题": 实际问题情景 -""" -``` - -#### 调整模型参数 - -编辑 `error_correction_agent/agent.py`: - -```python -def create_question_split_agent(): - model = init_chat_model( - model="deepseek:deepseek-chat", - temperature=0.3, # 提高温度以增加创造性 - # max_tokens=4000, # 限制输出长度 - ) - ... -``` - -#### 添加Few-shot示例 - -在提示词中添加示例: - -```python -SYSTEM_PROMPT = """ -...(原有内容) - -## 示例 - -### 示例1: 选择题 -输入: -{ - "blocks": [ - {"content": "1. 下列说法正确的是( )", ...}, - {"content": "A. 选项1", ...}, - {"content": "B. 选项2", ...} - ] -} - -输出: -{ - "question_id": "1", - "question_type": "选择题", - "options": ["A. 选项1", "B. 选项2"], - ... -} - -### 示例2: 填空题 -... -""" -``` - ---- - -## 📊 技术指标 - -### 代码规模 - -| 文件 | 行数 | 主要功能 | 复杂度 | -|-----|------|---------|--------| -| `agent.py` | 56 | Agent创建 | ⭐ | -| `prompts.py` | 133 | 系统提示词 | ⭐⭐ | -| `question_tools.py` | 82 | 题目工具 | ⭐⭐ | -| `file_tools.py` | 68 | 文件工具 | ⭐ | -| `paddleocr_client.py` | 262 | PaddleOCR客户端 | ⭐⭐⭐ | -| `workflow.py` | 214 | 工作流编排 | ⭐⭐⭐⭐ | -| `utils.py` | 365 | 工具函数 | ⭐⭐⭐ | -| **总计** | **1180** | | | - -### 核心依赖包 - -```txt -# Agent框架 -deepagents==0.3.5 -langchain==1.2.3 -langchain-core==1.2.7 -langgraph==1.0.5 -langchain-deepseek==1.0.1 - -# 文档处理 -pdf2image>=1.16.3 -Pillow>=10.0.0 - -# OCR -# (通过API调用,无需本地依赖) - -# 配置和工具 -python-dotenv==1.2.1 -requests>=2.31.0 -rich==14.2.0 -pydantic>=2.0.0 - -# 可选: 手写体识别 -torch>=2.0.0 -torchvision>=0.15.0 -``` - -### 性能指标 - -| 步骤 | 平均耗时 | 依赖 | -|-----|---------|------| -| 1. 准备输入 | ~2-5秒/PDF页 | PDF转图片 | -| 2. OCR解析 | ~5-10秒/页 | PaddleOCR API | -| 3. Agent分割 | ~10-30秒/页 | DeepSeek API | -| 4. 生成预览 | <1秒 | 本地HTML生成 | -| 5. 导出错题本 | <1秒 | 本地Markdown生成 | - -**总耗时估算**: 约20-50秒/页(主要取决于API响应速度) - ---- - -## 🔍 常见问题 - -### Q1: Agent分割效果不好怎么办? - -**方案1**: 调整提示词 -- 编辑 `prompts.py`,添加更详细的规则 -- 添加few-shot示例 - -**方案2**: 调整温度参数 -- 在 `agent.py` 中修改 `temperature` -- 建议范围: 0.1-0.3 - -**方案3**: 查看问题日志 -- 检查 `results/split_issues.jsonl` -- 分析Agent记录的不确定情况 - -### Q2: 如何调试Agent执行过程? - -**方法1**: 启用LangSmith追踪 -```bash -# 在.env中设置 -LANGSMITH_TRACING=true -LANGSMITH_API_KEY=your_key -LANGSMITH_PROJECT=error-correction -``` - -**方法2**: 添加日志输出 -```python -# 在workflow.py的split_questions_with_agent中 -response = agent.invoke(...) -print(f"Agent响应: {response}") -``` - -### Q3: 如何处理大文件? - -**方案1**: 分批处理 -```python -workflow = ErrorCorrectionWorkflow() - -# 每次只处理几页 -for i in range(0, len(pdf_pages), 5): - batch = pdf_pages[i:i+5] - workflow.run(batch, auto_split=True) -``` - -**方案2**: 调整API参数 -- 减少DPI(默认300) -- 关闭部分OCR功能开关 - -### Q4: 如何集成手写体识别? - -参考 `docs/HANDWRITTEN_API.md`: - -1. 启动服务: `cd printed-vs-handwritten && python app.py` -2. 添加工具到Agent -3. 在工作流中调用分类 - ---- - -## 📝 开发规范 - -### 代码风格 -- 遵循PEP 8 -- 使用类型提示(Type Hints) -- 函数必须包含Docstring - -### 工具定义规范 -```python -@tool(parse_docstring=True) # 必须使用parse_docstring -def tool_name(param: type) -> return_type: - """ - 简短描述(一句话) - - 详细说明(可选,多段落) - - Args: - param: 参数说明 - - Returns: - 返回值说明 - """ -``` - -### 提交规范 -- 使用语义化提交消息 -- 示例: `feat: 添加手写体识别工具` -- 类型: `feat`, `fix`, `docs`, `refactor`, `test` - ---- - -## 🎯 未来计划 - -### 短期(v1.1) -- [ ] 添加手写体识别集成 -- [ ] 优化Agent提示词(few-shot示例) -- [ ] 完善错误处理和日志 - -### 中期(v1.2) -- [ ] 支持批量处理多个文件 -- [ ] Web UI界面 -- [ ] 题目难度自动分级 - -### 长期(v2.0) -- [ ] 支持更多题型(完形填空、阅读理解) -- [ ] 答案自动校对 -- [ ] 知识点自动标注 - ---- - -## 📚 相关文档 - -- [README.md](../README.md) - 快速开始指南 -- [INDEX.md](INDEX.md) - LangChain文档索引 -- [HANDWRITTEN_API.md](HANDWRITTEN_API.md) - 手写体识别API -- [.env.example](../.env.example) - 环境配置模板 - ---- - -**文档版本**: v1.0.0 -**最后更新**: 2026-01-27 -**维护者**: Claude Code Assistant diff --git "a/example_uploads/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\345\214\226\345\255\246\347\234\237\351\242\230\357\274\210B\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" "b/example_uploads/exams/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\345\214\226\345\255\246\347\234\237\351\242\230\357\274\210B\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" similarity index 100% rename from "example_uploads/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\345\214\226\345\255\246\347\234\237\351\242\230\357\274\210B\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" rename to "example_uploads/exams/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\345\214\226\345\255\246\347\234\237\351\242\230\357\274\210B\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" diff --git "a/example_uploads/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\347\211\251\347\220\206\350\257\225\351\242\230\357\274\210A\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" "b/example_uploads/exams/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\347\211\251\347\220\206\350\257\225\351\242\230\357\274\210A\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" similarity index 100% rename from "example_uploads/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\347\211\251\347\220\206\350\257\225\351\242\230\357\274\210A\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" rename to "example_uploads/exams/2023\345\271\264\351\207\215\345\272\206\345\270\202\344\270\255\350\200\203\347\211\251\347\220\206\350\257\225\351\242\230\357\274\210A\345\215\267\357\274\211\357\274\210\345\216\237\345\215\267\347\211\210\357\274\211.pdf" diff --git a/example_uploads/test2.jpg b/example_uploads/exams/test2.jpg similarity index 100% rename from example_uploads/test2.jpg rename to example_uploads/exams/test2.jpg diff --git a/example_uploads/test3.jpg b/example_uploads/exams/test3.jpg similarity index 100% rename from example_uploads/test3.jpg rename to example_uploads/exams/test3.jpg diff --git a/example_uploads/test4.pdf b/example_uploads/exams/test4.pdf similarity index 100% rename from example_uploads/test4.pdf rename to example_uploads/exams/test4.pdf diff --git a/example_uploads/test5.jpg b/example_uploads/exams/test5.jpg similarity index 100% rename from example_uploads/test5.jpg rename to example_uploads/exams/test5.jpg diff --git a/example_uploads/test6.jpg b/example_uploads/exams/test6.jpg similarity index 100% rename from example_uploads/test6.jpg rename to example_uploads/exams/test6.jpg diff --git "a/example_uploads/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\270\200\351\235\242.jpg" "b/example_uploads/exams/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\270\200\351\235\242.jpg" similarity index 100% rename from "example_uploads/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\270\200\351\235\242.jpg" rename to "example_uploads/exams/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\270\200\351\235\242.jpg" diff --git "a/example_uploads/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\272\214\351\235\242.jpg" "b/example_uploads/exams/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\272\214\351\235\242.jpg" similarity index 100% rename from "example_uploads/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\272\214\351\235\242.jpg" rename to "example_uploads/exams/\345\260\217\345\255\246\346\225\260\345\255\246\350\257\225\345\215\267\346\211\253\346\217\217\347\254\254\344\272\214\351\235\242.jpg" diff --git a/example_uploads/test.jpg b/example_uploads/notes/test.jpg similarity index 100% rename from example_uploads/test.jpg rename to example_uploads/notes/test.jpg diff --git a/frontend/LINEAR_DESIGN.md b/frontend/LINEAR_DESIGN.md new file mode 100644 index 00000000..aacf6cc3 --- /dev/null +++ b/frontend/LINEAR_DESIGN.md @@ -0,0 +1,111 @@ +# Design System Inspiration of Linear + +## 1. Visual Theme & Atmosphere + +Linear's website is a masterclass in dark-mode-first product design — a near-black canvas (`#08090a`) where content emerges from darkness like starlight. The overall impression is one of extreme precision engineering: every element exists in a carefully calibrated hierarchy of luminance, from barely-visible borders (`rgba(255,255,255,0.05)`) to soft, luminous text (`#f7f8f8`). This is not a dark theme applied to a light design — it is darkness as the native medium, where information density is managed through subtle gradations of white opacity rather than color variation. + +The typography system is built entirely on Inter Variable with OpenType features `"cv01"` and `"ss03"` enabled globally, giving the typeface a cleaner, more geometric character. Inter is used at a remarkable range of weights — from 300 (light body) through 510 (medium, Linear's signature weight) to 590 (semibold emphasis). The 510 weight is particularly distinctive: it sits between regular and medium, creating a subtle emphasis that doesn't shout. At display sizes (72px, 64px, 48px), Inter uses aggressive negative letter-spacing (-1.584px to -1.056px), creating compressed, authoritative headlines that feel engineered rather than designed. Berkeley Mono serves as the monospace companion for code and technical labels, with fallbacks to ui-monospace, SF Mono, and Menlo. + +The color system is almost entirely achromatic — dark backgrounds with white/gray text — punctuated by a single brand accent: Linear's signature indigo-violet (`#5e6ad2` for backgrounds, `#7170ff` for interactive accents). This accent color is used sparingly and intentionally, appearing only on CTAs, active states, and brand elements. The border system uses ultra-thin, semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) that create structure without visual noise, like wireframes drawn in moonlight. + +**Key Characteristics:** +- Dark-mode-native: `#08090a` marketing background, `#0f1011` panel background, `#191a1b` elevated surfaces +- Inter Variable with `"cv01", "ss03"` globally — geometric alternates for a cleaner aesthetic +- Signature weight 510 (between regular and medium) for most UI text +- Aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px) +- Brand indigo-violet: `#5e6ad2` (bg) / `#7170ff` (accent) / `#828fff` (hover) — the only chromatic color in the system +- Semi-transparent white borders throughout: `rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)` +- Button backgrounds at near-zero opacity: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` +- Multi-layered shadows with inset variants for depth on dark surfaces +- Success green (`#27a644`, `#10b981`) used only for status indicators + +## 2. Color Palette & Roles + +### Background Surfaces +- **Marketing Black** (`#010102` / `#08090a`): The deepest background — the canvas for hero sections and marketing pages. +- **Panel Dark** (`#0f1011`): Sidebar and panel backgrounds. One step up from the marketing black. +- **Level 3 Surface** (`#191a1b`): Elevated surface areas, card backgrounds, dropdowns. +- **Secondary Surface** (`#28282c`): The lightest dark surface — used for hover states and slightly elevated components. + +### Text & Content +- **Primary Text** (`#f7f8f8`): Near-white with a barely-warm cast. Not pure white, preventing eye strain. +- **Secondary Text** (`#d0d6e0`): Cool silver-gray for body text, descriptions. +- **Tertiary Text** (`#8a8f98`): Muted gray for placeholders, metadata. +- **Quaternary Text** (`#62666d`): The most subdued text — timestamps, disabled states. + +### Brand & Accent +- **Brand Indigo** (`#5e6ad2`): Primary brand color — CTA button backgrounds, brand marks. +- **Accent Violet** (`#7170ff`): Brighter variant for interactive elements — links, active states. +- **Accent Hover** (`#828fff`): Lighter variant for hover states on accent elements. + +### Border & Divider +- **Border Subtle** (`rgba(255,255,255,0.05)`): Ultra-subtle semi-transparent border — the default. +- **Border Standard** (`rgba(255,255,255,0.08)`): Standard semi-transparent border for cards, inputs. +- **Border Primary** (`#23252a`): Solid dark border for prominent separations. + +## 3. Typography Rules + +### Font Family +- **Primary**: `Inter Variable` with `"cv01", "ss03"` OpenType features +- **Monospace**: `Berkeley Mono` + +### Key Weights +- **400**: Reading text +- **510**: Emphasis/UI (Linear's signature weight) +- **590**: Strong emphasis + +### Display Sizes Letter Spacing +- 72px: -1.584px +- 64px: -1.408px +- 48px: -1.056px +- 32px: -0.704px +- Below 16px: normal + +## 4. Component Stylings + +### Buttons +- **Ghost**: `rgba(255,255,255,0.02)` bg, `1px solid rgb(36,40,44)` border, 6px radius +- **Subtle**: `rgba(255,255,255,0.04)` bg, 6px radius +- **Primary Brand**: `#5e6ad2` bg, white text, 6px radius +- **Pill**: transparent bg, 9999px radius, `1px solid rgb(35,37,42)` border + +### Cards & Containers +- Background: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` (never solid) +- Border: `1px solid rgba(255,255,255,0.08)` +- Radius: 8px (standard), 12px (featured) + +### Inputs +- Background: `rgba(255,255,255,0.02)` +- Border: `1px solid rgba(255,255,255,0.08)` +- Radius: 6px + +## 5. Layout Principles + +### Spacing System +- Base unit: 8px +- Primary rhythm: 8px, 16px, 24px, 32px + +### Border Radius Scale +- 2px: Inline badges, toolbar buttons +- 6px: Buttons, inputs +- 8px: Cards, dropdowns +- 12px: Panels, featured cards +- 9999px: Chips, pills + +## 6. Depth & Elevation + +Surface elevation via background opacity: `rgba(255,255,255, 0.02 -> 0.04 -> 0.05)` — never solid backgrounds on dark. + +## 7. Do's and Don'ts + +### Do +- Use `#f7f8f8` for primary text — not pure `#ffffff` +- Keep button backgrounds nearly transparent +- Reserve brand indigo for primary CTAs only +- Use semi-transparent white borders + +### Don't +- Don't use pure white as primary text +- Don't use solid colored backgrounds for buttons +- Don't use weight 700 (bold) — max is 590 +- Don't use drop shadows for elevation on dark surfaces diff --git a/frontend/app.html b/frontend/app.html index 39a59650..8dfc7237 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -2,7 +2,7 @@ - + 智卷错题本系统 diff --git a/frontend/package.json b/frontend/package.json index 19480577..27775cfb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@headlessui/vue": "^1.7.23", + "@tailwindcss/typography": "^0.5.19", "dompurify": "^3.3.2", "lucide-vue-next": "^0.577.0", "vue": "^3.5.24", diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 00000000..b98ffb13 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg index 4dd7fb07..352aab88 100644 --- a/frontend/public/logo.svg +++ b/frontend/public/logo.svg @@ -1,7 +1,4 @@ - - - + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 797b19c4..42ca3f26 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,21 +1,29 @@ diff --git a/frontend/src/api.js b/frontend/src/api.js index 9ebf1f6b..6924dd7b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -75,11 +75,27 @@ export function uploadFiles(formData, { onProgress, onSuccess, onError, onAbort return xhr } -export async function splitQuestions(modelProvider, modelName, { erase = false } = {}) { +export async function runErase() { + const resp = await fetch('/api/erase', { method: 'POST' }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() + if (data && data.success) return data + throw new Error((data && data.error) || '擦除失败') +} + +export async function runOcr() { + const resp = await fetch('/api/ocr', { method: 'POST' }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data = await resp.json() + if (data && data.success) return data + throw new Error((data && data.error) || 'OCR 执行失败') +} + +export async function splitQuestions(modelProvider, modelName) { const resp = await fetch('/api/split', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(_buildModelBody(modelProvider, modelName, { erase })), + body: JSON.stringify(_buildModelBody(modelProvider, modelName)), }) if (!resp.ok) throw new Error(`HTTP ${resp.status}`) const data = await resp.json() @@ -292,12 +308,143 @@ export async function fetchMessages(sessionId, { limit = 30, beforeId } = {}) { throw new Error((data && data.error) || '获取消息失败') } -export async function streamChat(sessionId, message, modelProvider = 'openai', signal, modelName) { +export async function streamChat(sessionId, message, modelProvider = 'openai', signal, modelName, { deepThink = false } = {}) { const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(_buildModelBody(modelProvider, modelName, { message })), + body: JSON.stringify(_buildModelBody(modelProvider, modelName, { message, deep_think: deepThink })), } if (signal) opts.signal = signal return fetch(`/api/chat/${sessionId}/stream`, opts) } + +// ── 笔记模块 ───────────────────────────────────────────── + +/** + * 上传笔记图片 → OCR → LLM 整理 → 保存 + * @param {FormData} formData - 包含 files、model_provider、model_name + * @param {Object} callbacks - { onProgress, onSuccess, onError } + * @returns {XMLHttpRequest} xhr 对象,可用于取消 + */ +export function createNote(formData, { onProgress, onSuccess, onError } = {}) { + const xhr = new XMLHttpRequest() + xhr.open('POST', '/api/notes/') + xhr.withCredentials = true + + if (onProgress) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress(e.loaded / e.total) + } + } + xhr.onload = () => { + try { + const data = JSON.parse(xhr.responseText) + if (xhr.status >= 200 && xhr.status < 300 && data.success) { + onSuccess?.(data) + } else { + onError?.(data.error || '笔记创建失败') + } + } catch { + onError?.('解析响应失败') + } + } + xhr.onerror = () => onError?.('网络错误') + xhr.send(formData) + return xhr +} + +/** + * 分页查询笔记列表 + */ +export async function fetchNotes(params = {}) { + const query = new URLSearchParams() + if (params.page) query.set('page', params.page) + if (params.limit) query.set('limit', params.limit) + if (params.subject) query.set('subject', params.subject) + if (params.knowledge_tag) query.set('knowledge_tag', params.knowledge_tag) + if (params.keyword) query.set('keyword', params.keyword) + + const resp = await fetch(`/api/notes/?${query}`) + const data = await resp.json() + if (data.success) return data + throw new Error(data.error || '获取笔记列表失败') +} + +/** + * 获取单条笔记详情 + */ +export async function fetchNote(noteId) { + const resp = await fetch(`/api/notes/${noteId}`) + const data = await resp.json() + if (data.success) return data.note + throw new Error(data.error || '获取笔记详情失败') +} + +/** + * 更新笔记 + */ +export async function updateNote(noteId, payload) { + const resp = await fetch(`/api/notes/${noteId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + const data = await resp.json() + if (data.success) return data.note + throw new Error(data.error || '更新笔记失败') +} + +/** + * 删除笔记 + */ +export async function deleteNote(noteId) { + const resp = await fetch(`/api/notes/${noteId}`, { method: 'DELETE' }) + const data = await resp.json() + if (data.success) return true + throw new Error(data.error || '删除笔记失败') +} + +// ── 独立对话 ───────────────────────────────────────────── + +/** 创建独立对话 */ +export async function createIndependentChat(title = '新对话') { + const resp = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }) + const data = await resp.json() + if (data.success) return data.session + throw new Error(data.error || '创建对话失败') +} + +/** 获取用户的独立对话列表 */ +export async function fetchMyChatSessions(params = {}) { + const query = new URLSearchParams() + if (params.page) query.set('page', params.page) + if (params.limit) query.set('limit', params.limit) + const resp = await fetch(`/api/chat/my-sessions?${query}`) + const data = await resp.json() + if (data.success) return data + throw new Error(data.error || '获取对话列表失败') +} + +/** 更新对话标题 */ +export async function updateChatTitle(sessionId, title) { + const resp = await fetch(`/api/chat/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }) + const data = await resp.json() + if (data.success) return data.session + throw new Error(data.error || '更新标题失败') +} + +/** 删除对话 */ +export async function deleteChat(sessionId) { + const resp = await fetch(`/api/chat/${sessionId}`, { method: 'DELETE' }) + const data = await resp.json() + if (data.success) return true + throw new Error(data.error || '删除对话失败') +} diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg deleted file mode 100644 index 770e9d33..00000000 --- a/frontend/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/AuthLayout.vue b/frontend/src/components/AuthLayout.vue deleted file mode 100644 index c0045e43..00000000 --- a/frontend/src/components/AuthLayout.vue +++ /dev/null @@ -1,177 +0,0 @@ - - -
- - - - - - -
- - - - - 返回主页 - - - -
-
-
-
- logo -
-
-

智卷错题本

-
- -
- -
-

- {{ route.path === '/auth/login' ? '欢迎回来' : '创建账户' }} -

-

- {{ route.path === '/auth/login' ? '登录以继续使用你的错题本' : '免费注册,开始智能错题整理' }} -

-
- - -
- 登录 - 注册 -
- - - - - - - - - -

- © {{ new Date().getFullYear() }} 智卷错题本 -

-
-
- -
- - - - - diff --git a/frontend/src/components/AuthView.vue b/frontend/src/components/AuthView.vue deleted file mode 100644 index be632995..00000000 --- a/frontend/src/components/AuthView.vue +++ /dev/null @@ -1,253 +0,0 @@ - - - diff --git a/frontend/src/components/CalendarPicker.vue b/frontend/src/components/CalendarPicker.vue deleted file mode 100644 index ea159fe6..00000000 --- a/frontend/src/components/CalendarPicker.vue +++ /dev/null @@ -1,143 +0,0 @@ - - - - - diff --git a/frontend/src/components/CustomSelect.vue b/frontend/src/components/CustomSelect.vue deleted file mode 100644 index 3abd0aa4..00000000 --- a/frontend/src/components/CustomSelect.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - diff --git a/frontend/src/components/DateRangePicker.vue b/frontend/src/components/DateRangePicker.vue deleted file mode 100644 index 535d0ba2..00000000 --- a/frontend/src/components/DateRangePicker.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - - diff --git a/frontend/src/components/FileList.vue b/frontend/src/components/FileList.vue deleted file mode 100644 index d7d1dc76..00000000 --- a/frontend/src/components/FileList.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - diff --git a/frontend/src/components/FileUploader.vue b/frontend/src/components/FileUploader.vue deleted file mode 100644 index 329b9ae0..00000000 --- a/frontend/src/components/FileUploader.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/GlassCard.vue b/frontend/src/components/GlassCard.vue deleted file mode 100644 index cf5022e3..00000000 --- a/frontend/src/components/GlassCard.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/frontend/src/components/LoginView.vue b/frontend/src/components/LoginView.vue deleted file mode 100644 index bceb1d9a..00000000 --- a/frontend/src/components/LoginView.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue deleted file mode 100644 index 89b71d2e..00000000 --- a/frontend/src/components/PageHeader.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/frontend/src/components/RegisterView.vue b/frontend/src/components/RegisterView.vue deleted file mode 100644 index 55288fb1..00000000 --- a/frontend/src/components/RegisterView.vue +++ /dev/null @@ -1,125 +0,0 @@ - - - diff --git a/frontend/src/components/SearchInput.vue b/frontend/src/components/SearchInput.vue deleted file mode 100644 index 6903f127..00000000 --- a/frontend/src/components/SearchInput.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/frontend/src/components/StepIndicator.vue b/frontend/src/components/StepIndicator.vue deleted file mode 100644 index c3030917..00000000 --- a/frontend/src/components/StepIndicator.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/components/auth/ForgotPasswordModal.vue b/frontend/src/components/auth/ForgotPasswordModal.vue new file mode 100644 index 00000000..d08b4cf1 --- /dev/null +++ b/frontend/src/components/auth/ForgotPasswordModal.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/frontend/src/components/base/BaseButton.vue b/frontend/src/components/base/BaseButton.vue new file mode 100644 index 00000000..90bd5e2a --- /dev/null +++ b/frontend/src/components/base/BaseButton.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/components/base/BaseCard.vue b/frontend/src/components/base/BaseCard.vue new file mode 100644 index 00000000..e17df68e --- /dev/null +++ b/frontend/src/components/base/BaseCard.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/GlassButton.vue b/frontend/src/components/base/BaseGhostButton.vue similarity index 60% rename from frontend/src/components/GlassButton.vue rename to frontend/src/components/base/BaseGhostButton.vue index 935333ec..2c2a5e9a 100644 --- a/frontend/src/components/GlassButton.vue +++ b/frontend/src/components/base/BaseGhostButton.vue @@ -1,4 +1,8 @@ + + + + diff --git a/frontend/src/components/base/BaseSelect.vue b/frontend/src/components/base/BaseSelect.vue new file mode 100644 index 00000000..8362fc1d --- /dev/null +++ b/frontend/src/components/base/BaseSelect.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/base/EmptyState.vue b/frontend/src/components/base/EmptyState.vue new file mode 100644 index 00000000..c1019282 --- /dev/null +++ b/frontend/src/components/base/EmptyState.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/ImageModal.vue b/frontend/src/components/base/ImageModal.vue similarity index 88% rename from frontend/src/components/ImageModal.vue rename to frontend/src/components/base/ImageModal.vue index 74881f4d..edfb44d9 100644 --- a/frontend/src/components/ImageModal.vue +++ b/frontend/src/components/base/ImageModal.vue @@ -1,5 +1,9 @@ + + diff --git a/frontend/src/components/ToastContainer.vue b/frontend/src/components/base/ToastContainer.vue similarity index 95% rename from frontend/src/components/ToastContainer.vue rename to frontend/src/components/base/ToastContainer.vue index af6c12a9..95f2f733 100644 --- a/frontend/src/components/ToastContainer.vue +++ b/frontend/src/components/base/ToastContainer.vue @@ -1,4 +1,8 @@ diff --git a/frontend/src/components/home/HomeFooter.vue b/frontend/src/components/home/HomeFooter.vue new file mode 100644 index 00000000..d7de20e7 --- /dev/null +++ b/frontend/src/components/home/HomeFooter.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/home/HomeHeader.vue b/frontend/src/components/home/HomeHeader.vue new file mode 100644 index 00000000..8de6c411 --- /dev/null +++ b/frontend/src/components/home/HomeHeader.vue @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/components/home/HomePill.vue b/frontend/src/components/home/HomePill.vue new file mode 100644 index 00000000..d0547aa5 --- /dev/null +++ b/frontend/src/components/home/HomePill.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/frontend/src/components/home/HomeSideNav.vue b/frontend/src/components/home/HomeSideNav.vue new file mode 100644 index 00000000..394dc53b --- /dev/null +++ b/frontend/src/components/home/HomeSideNav.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/landing/LandingBackToTop.vue b/frontend/src/components/landing/LandingBackToTop.vue deleted file mode 100644 index 99e110da..00000000 --- a/frontend/src/components/landing/LandingBackToTop.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/frontend/src/components/landing/LandingCta.vue b/frontend/src/components/landing/LandingCta.vue deleted file mode 100644 index 853e69b5..00000000 --- a/frontend/src/components/landing/LandingCta.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/frontend/src/components/landing/LandingDemo.vue b/frontend/src/components/landing/LandingDemo.vue deleted file mode 100644 index 7cf49fb9..00000000 --- a/frontend/src/components/landing/LandingDemo.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - diff --git a/frontend/src/components/landing/LandingFeatures.vue b/frontend/src/components/landing/LandingFeatures.vue deleted file mode 100644 index 8c60dbb0..00000000 --- a/frontend/src/components/landing/LandingFeatures.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/frontend/src/components/landing/LandingHero.vue b/frontend/src/components/landing/LandingHero.vue deleted file mode 100644 index 5467ae47..00000000 --- a/frontend/src/components/landing/LandingHero.vue +++ /dev/null @@ -1,347 +0,0 @@ - - - diff --git a/frontend/src/components/landing/LandingNav.vue b/frontend/src/components/landing/LandingNav.vue deleted file mode 100644 index 8f3dff27..00000000 --- a/frontend/src/components/landing/LandingNav.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - diff --git a/frontend/src/components/landing/LandingWorkflow.vue b/frontend/src/components/landing/LandingWorkflow.vue deleted file mode 100644 index 0ce7bf3d..00000000 --- a/frontend/src/components/landing/LandingWorkflow.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - diff --git a/frontend/src/components/EditNoteDialog.vue b/frontend/src/components/question/EditNoteDialog.vue similarity index 97% rename from frontend/src/components/EditNoteDialog.vue rename to frontend/src/components/question/EditNoteDialog.vue index 19572433..6ba3e0fb 100644 --- a/frontend/src/components/EditNoteDialog.vue +++ b/frontend/src/components/question/EditNoteDialog.vue @@ -1,6 +1,10 @@ + + diff --git a/frontend/src/components/workspace/ContentPanel.vue b/frontend/src/components/workspace/ContentPanel.vue new file mode 100644 index 00000000..8ab557f1 --- /dev/null +++ b/frontend/src/components/workspace/ContentPanel.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/components/workspace/ErasePreview.vue b/frontend/src/components/workspace/ErasePreview.vue new file mode 100644 index 00000000..bec1f4bc --- /dev/null +++ b/frontend/src/components/workspace/ErasePreview.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/components/workspace/ErrorBankFilterPanel.vue b/frontend/src/components/workspace/ErrorBankFilterPanel.vue new file mode 100644 index 00000000..4685abae --- /dev/null +++ b/frontend/src/components/workspace/ErrorBankFilterPanel.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/components/workspace/FileList.vue b/frontend/src/components/workspace/FileList.vue new file mode 100644 index 00000000..73713168 --- /dev/null +++ b/frontend/src/components/workspace/FileList.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/components/workspace/FileUploader.vue b/frontend/src/components/workspace/FileUploader.vue new file mode 100644 index 00000000..cd0fc30b --- /dev/null +++ b/frontend/src/components/workspace/FileUploader.vue @@ -0,0 +1,89 @@ + + + diff --git a/frontend/src/components/workspace/OcrPreview.vue b/frontend/src/components/workspace/OcrPreview.vue new file mode 100644 index 00000000..91b3a208 --- /dev/null +++ b/frontend/src/components/workspace/OcrPreview.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/src/components/workspace/ReviewStage.vue b/frontend/src/components/workspace/ReviewStage.vue new file mode 100644 index 00000000..ab4369f0 --- /dev/null +++ b/frontend/src/components/workspace/ReviewStage.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/components/SelectionPanel.vue b/frontend/src/components/workspace/SelectionPanel.vue similarity index 95% rename from frontend/src/components/SelectionPanel.vue rename to frontend/src/components/workspace/SelectionPanel.vue index 83ab9037..f512e616 100644 --- a/frontend/src/components/SelectionPanel.vue +++ b/frontend/src/components/workspace/SelectionPanel.vue @@ -1,4 +1,8 @@ + + diff --git a/frontend/src/components/SplitLoading.vue b/frontend/src/components/workspace/SplitLoading.vue similarity index 76% rename from frontend/src/components/SplitLoading.vue rename to frontend/src/components/workspace/SplitLoading.vue index aa1d58a3..c3720b04 100644 --- a/frontend/src/components/SplitLoading.vue +++ b/frontend/src/components/workspace/SplitLoading.vue @@ -1,5 +1,14 @@