From 009070eb4146b471b4f75fd6fd879b8d83a8bfb9 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 12 Aug 2026 13:02:00 +0800 Subject: [PATCH 01/12] docs: specify online demo guided learning experience --- .../2026-08-12-demo-guided-learning-design.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-demo-guided-learning-design.md diff --git a/docs/superpowers/specs/2026-08-12-demo-guided-learning-design.md b/docs/superpowers/specs/2026-08-12-demo-guided-learning-design.md new file mode 100644 index 0000000..f3d4606 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-demo-guided-learning-design.md @@ -0,0 +1,98 @@ +# 在线演示账号与引导式学习体验设计 + +## 背景 + +当前演示账号由开发环境中的 `/classes/seed-demo-data` 手动创建,免密入口 `/sandbox-login/` 也只允许调试或测试模式访问。在线环境因此没有稳定的演示账号入口,学生无法直接体验三阶段引导式学习,教师也无法看到与学生对应的班级、作业和学习数据。 + +## 目标 + +在登录页提供两个固定的体验入口: + +- 学生体验:进入共享演示班级中的引导式作业,能完整体验三个学习阶段,也能使用快捷跳转。 +- 教师体验:进入同一演示班级的教师工作台,能查看作业、学生提交、学习统计和教师建议。 + +演示数据由代码按需补齐,不依赖手动导入或数据库迁移。普通登录和原有开发沙箱入口保持不变。 + +## 固定演示关系 + +演示数据使用保留 ID,入口只允许选择角色,不接受外部传入的任意用户 ID。 + +- 教师:`student_id=demo_t_001`,用户名 `teacher_demo`,类型为 `教师`。 +- 学生:`student_id=demo_s_001`,用户名 `student_demo_good`,类型为 `学生`。 +- 共享班级:`软件工程24-演示班`,教师为 `demo_t_001`。 +- 共享引导作业:循环与斐波那契数列,创建者为 `demo_t_001`,布置给共享班级。 + +为便于教师端展示,保留现有演示班级中的其他学生、提交记录、知识点分数和教师建议。学生 `demo_s_001` 是可交互的主体验账号,其他演示学生只用于丰富教师端的班级数据。 + +## 数据初始化 + +新增 `services/demo_experience.py`,集中保存演示常量和初始化逻辑。公开入口调用 `ensure_demo_experience()`,在一次数据库事务中按以下顺序补齐数据: + +1. 创建或更新保留的演示教师和演示学生,并保证学生关联共享班级。 +2. 创建或更新共享班级和引导作业,保证教师是作业创建者、班级是作业目标。 +3. 补齐知识点、示例提交、学生能力数据和教师 `TeacherAISuggestion`,使教师工作台有可查看内容。 +4. 为共享引导作业写入 `AssignmentThinkingPreset`,包括标准答案、算法简述、阶段一问题、阶段二积木和题目、阶段三难度配置,并将状态设为 `ready`。 +5. 提交事务;任一步骤失败时回滚并返回登录页提示。 + +初始化必须幂等:重复点击学生或教师入口不能重复创建账号、班级、作业或预设,也不能删除学生在演示过程中新产生的会话、提交和日志。已有演示记录应保留,缺失记录才补齐。实现只使用现有数据表,不新增表和迁移。 + +## 登录入口 + +在 `routes/auth.py` 增加角色白名单入口,例如 `/demo-login/`,只接受 `student` 和 `teacher`: + +- `student`:调用初始化器,登录 `demo_s_001`,跳转到共享作业的 `thinking.arena` 页面。 +- `teacher`:调用初始化器,登录 `demo_t_001`,跳转到 `main.home`,由现有角色路由进入教师工作台。 +- 角色非法或初始化失败时,不登录、不暴露数据库错误,回到登录页显示简短提示。 + +登录页在普通登录表单下方增加两个明确的体验按钮,并说明这是公开演示账号。按钮不显示或要求用户填写密码,点击即完成对应角色登录。现有账号密码登录、注册入口和登录失败提示不变。 + +## 学生体验与快捷跳转 + +学生登录后直接打开共享引导作业。预置 `AssignmentThinkingPreset` 后,进入页面不再等待异步生成,现有三阶段页面继续负责: + +- 阶段一:自然语言描述和提示。 +- 阶段二:积木编程和逐步验证。 +- 阶段三:费曼教学和代码修复。 + +在 `thinking/arena.html` 和 `static/js/thinking.js` 中增加演示体验标记。演示学生可看到类似本地调试面板的快捷操作:跳到阶段一、阶段二、阶段三,以及一键完成。按钮调用现有 `thinking.api.debug_jump_stage`,不复制阶段状态逻辑。 + +服务端同时收紧跳转权限:本地调试请求继续允许本地开发者使用;在线请求只有当当前用户是 `demo_s_001` 且会话属于共享引导作业时才允许。普通学生、其他作业和未登录请求均返回 403。跳转后保留现有会话记录和完成状态,学生回到首页后可继续打开同一作业。 + +## 教师体验 + +教师登录后使用现有教师工作台、班级详情、作业管理和提交详情页面。初始化器提供同一班级的作业、多个学生的样例提交、知识点数据和静态教师建议,因此教师可以直接查看: + +- 共享班级和学生名单; +- 引导作业及其布置状态; +- 学生提交和班级统计; +- 教师 AI 建议; +- 学生引导会话产生的状态和日志数据。 + +不另建一套教师数据模型。现有 `thinking` 学习日志接口继续提供教师查看会话摘要和明细所需的数据。 + +## 安全与失败处理 + +- 公开入口只允许两个固定角色,不开放任意用户 ID 免密登录。 +- `/sandbox-login/` 继续保留开发/测试模式限制。 +- 演示入口明确标注为体验账号,演示数据只使用保留 ID。 +- 初始化失败时回滚事务,日志记录服务端错误,用户只看到可理解的登录提示。 +- 预置引导数据避免在线环境首次进入页面时依赖 AI 生成;已有 AI 对话、评判和不可用时的现有提示逻辑不改。 + +## 实现文件范围 + +- 新增:`services/demo_experience.py` +- 修改:`routes/auth.py`、`routes/classes.py`、`templates/login.html` +- 修改:`routes/thinking.py`、`templates/thinking/arena.html`、`static/js/thinking.js` +- 新增或修改:演示入口、初始化、共享关系和跳转权限测试 + +不修改生产数据库结构,不移除原有开发沙箱,不改普通用户的登录和学习流程。 + +## 验收标准 + +1. 在生产配置下打开登录页可以看到学生和教师两个体验入口。 +2. 学生入口成功登录 `demo_s_001` 并直接打开共享引导作业,预设状态为 `ready`。 +3. 教师入口成功登录 `demo_t_001` 并进入教师工作台,能看到共享班级和作业数据。 +4. 两个入口重复使用不会产生重复演示账号、班级、作业或预设。 +5. 演示学生可以使用四个快捷跳转操作,普通学生不能调用同一接口。 +6. 学生完成或跳过体验后,教师端仍能看到对应的作业、提交、统计和学习记录。 +7. 原有普通登录、开发沙箱登录和现有测试套件不回归。 From cf50298b2da30c59e9782bd9b95202eb166636bc Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 19 Aug 2026 01:19:15 +0800 Subject: [PATCH 02/12] docs: specify defense GIF capture workflow --- ...19-codesense-defense-gif-capture-design.md | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-codesense-defense-gif-capture-design.md diff --git a/docs/superpowers/specs/2026-08-19-codesense-defense-gif-capture-design.md b/docs/superpowers/specs/2026-08-19-codesense-defense-gif-capture-design.md new file mode 100644 index 0000000..6008056 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-codesense-defense-gif-capture-design.md @@ -0,0 +1,247 @@ +# CodeSense 答辩核心功能 GIF 自动录制方案 + +## 1. 目标 + +为 CodeSense 的项目答辩准备一套可重复生成的界面演示素材。录制过程在本地完成,使用固定账号和固定演示数据,通过 Playwright 自动操作页面,再用 FFmpeg 输出 GIF、MP4、首帧 PNG 和录制报告。 + +这套方案需要覆盖学生端、教师端、能力评测、风险提示和长页面可视化巡览。页面或测试数据发生变化后,可以按编号重新录制单条素材。 + +## 2. 范围 + +### 2.1 包含内容 + +- 学生端和教师端的核心答辩流程。 +- 学生能力评测中的知识点雷达图、分析进度和个性化分析。 +- 教师端的重点关注项、AI 教学建议、班级图表和学生风险标签。 +- 个人统计页、教师首页、班级详情页的纵向巡览 GIF。 +- 本地隔离数据库、固定测试账号和可重复的演示数据。 +- GIF、MP4、PNG、JSON 元数据和录制报告。 + +### 2.2 不包含内容 + +- 不录制生产环境或真实用户数据。 +- 不把所有普通页面都制作成 GIF。 +- 不改变 CodeSense 的业务规则和正式用户流程。 +- 不把 GIF 录制脚本当作完整的端到端测试套件。 + +## 3. 答辩素材清单 + +完整素材包生成 14 条 GIF。答辩 PPT 可以从中选择 6–8 条作为主线,其余作为备用或补充页。 + +| 编号 | 角色 | 页面或入口 | 演示内容 | 类型 | +|---|---|---|---|---| +| S01 | 学生 | `/login` | 登录并进入学生首页 | 操作型 | +| S02 | 学生 | `/home`、`/student_assignments` | 查看课程和作业,进入作业详情 | 操作型 | +| S03 | 学生 | `/submit/` | 编辑代码、提交作业、显示评测状态 | 操作型 | +| S04 | 学生 | `/view_submission/` | 查看运行结果、错误信息和 AI 反馈 | 操作型 | +| S05 | 学生 | `/thinking/` | 第一阶段:填写算法思路并提交 | 操作型 | +| S06 | 学生 | `/thinking/` | 第二阶段:选择代码步骤并验证顺序 | 操作型 | +| S07 | 学生 | `/thinking/` | 第三阶段:AI 对话、解释代码并修复问题 | 操作型 | +| S08 | 学生 | `/home` | 分析进度、知识点雷达图和 AI 个性化分析 | 操作型 | +| S09 | 学生 | `/profile#statistics` | 从个人数据滑到能力柱状图和提交趋势图 | 纵向巡览 | +| T01 | 教师 | `/teacher_dashboard` | 查看“今日需要关注”,打开重点学生详情 | 操作型 | +| T02 | 教师 | `/teacher_dashboard` | 从页首滑到页尾,浏览关注项、AI 建议和班级图表 | 纵向巡览 | +| T03 | 教师 | `/teacher/ai_suggestions` | 生成或查看 AI 教学建议 | 操作型 | +| T04 | 教师 | `/classes/` | 从班级概况滑到学生名单、分数和风险标签 | 纵向巡览 | +| T05 | 教师 | `/view_submission/` | 查看学生代码、评测结果和反馈 | 操作型 | + +S08 使用学生首页现有的知识点画像和 AI 分析区域。T01 使用教师首页现有的未提交、低分、长期未活跃和未注册名单提示。T04 使用班级详情页已有的状态、分数、提交次数和风险标签表格。 + +## 4. 总体架构 + +```mermaid +flowchart LR + A["录制脚本"] --> B["本地 Flask 服务"] + B --> C["隔离 SQLite 数据库"] + A --> D["Playwright 浏览器"] + D --> E["原始 WebM"] + E --> F["FFmpeg 编码"] + F --> G["GIF / PNG / MP4"] + G --> H["文件与内容校验"] + H --> I["录制报告"] +``` + +实现语言使用 Python,以便和当前 Flask 项目共用配置、脚本和测试工具。Playwright 负责登录、点击、输入、提交、滚动和录屏;FFmpeg 只负责转码和压缩,不参与页面操作。 + +## 5. 两种录制模式 + +### 5.1 操作型 GIF + +每条流程从干净的浏览器上下文开始,按固定步骤完成一个功能。动作之间使用页面状态等待,不依赖长时间固定睡眠。 + +示例:S08 的流程为: + +1. 以学生账号进入 `/home`。 +2. 等待知识点雷达图的 `canvas` 完成绘制。 +3. 等待能力分析进度条出现并完成。 +4. 等待 AI 分析区域出现可读文本。 +5. 依次停留在雷达图、分析进度和建议区。 +6. 结束录制并输出素材。 + +### 5.2 纵向巡览 GIF + +用于替代静态长截图。浏览器视口保持固定,页面从顶部开始,每次向下滚动半个到一个屏幕,滚动后停留一小段时间。 + +统一节奏为: + +```text +顶部停留约 0.8 秒 +平滑滚动一个区块 +区块停留约 0.8 秒 +重复到页尾 +页尾停留约 1 秒 +``` + +巡览脚本必须检查最终 `scrollTop` 已接近页面总高度,不能因为页面尚未加载完成而提前结束。 + +## 6. 本地数据和环境隔离 + +录制使用 `CAPTURE_MODE=1` 或等效的测试配置,数据库指向一次性创建的 SQLite 文件。每次完整录制前先清空并重新写入演示数据,避免上一次录制产生的提交或状态影响下一次结果。 + +固定数据至少包括: + +- 一个学生账号和一个教师账号。 +- 一个教师管理的示例班级。 +- 一条可提交的 C 语言作业。 +- 若干学生、提交记录和评测结果。 +- 学生能力评分、班级平均值和提交趋势。 +- 未提交、低分、长期未活跃、未注册等教师端风险状态。 +- 已完成的 AI 分析和教学建议示例。 + +S08 的 SSE 分析、T03 的 AI 建议和其他需要异步结果的页面使用本地固定响应。录制不依赖外部大模型接口、真实数据库或网络速度。 + +当前模板使用 Chart.js、marked 等前端依赖。录制环境必须使用本地文件或预先缓存的依赖;如果依赖无法加载,脚本直接失败,不允许静默录出空白图表。 + +## 7. 录制规格 + +- 浏览器:Chromium。 +- 视口:`1280×720`,16:9。 +- 原始录制:Playwright 输出的 30 FPS WebM。 +- GIF 主规格:12 FPS、`1120×630`,使用调色板压缩。 +- 如果 GIF 超过 8 MB,自动降为 `960×540` 并在报告中标记压缩降级。 +- 操作型 GIF:5–8 秒。 +- 纵向巡览 GIF:8–12 秒。 +- 页面顶部和底部各保留约 1 秒静止画面。 +- 保留鼠标指针和右侧滚动条,不录制浏览器地址栏和系统窗口。 +- 不录制声音。 + +输出文件使用稳定名称,不把日期写进主文件名: + +```text +S08-ability-analysis.gif +S08-ability-analysis.mp4 +S08-ability-analysis.png +S08-ability-analysis.json +``` + +PNG 是 GIF 无法播放时的静态兜底图,JSON 记录页面、角色、演示数据版本、录制参数、脚本版本和生成时间。 + +## 8. 项目文件组织 + +录制脚本放在源码仓库,生成素材放在工作区输出目录: + +```text +E:\CodeSense\源代码\scripts\demo_capture\ +├─ manifest.yaml +├─ runner.py +├─ flows\ +│ ├─ student_flows.py +│ └─ teacher_flows.py +├─ fixtures\ +│ ├─ student_demo.json +│ └─ teacher_demo.json +├─ encoder.py +├─ validator.py +└─ README.md + +E:\CodeSense\outputs\codesense-defense-gifs\ +├─ gif\ +│ ├─ student\ +│ └─ teacher\ +├─ mp4\ +├─ poster\ +├─ raw\ +└─ reports\ +``` + +`manifest.yaml` 是单条素材的入口,至少记录以下字段: + +- `id`:例如 `S08`、`T04`。 +- `role`:`student` 或 `teacher`。 +- `route`:起始页面。 +- `mode`:`interaction` 或 `page_tour`。 +- `fixture`:使用的演示数据名称。 +- `actions`:点击、输入、提交或滚动步骤。 +- `ready`:页面完成条件。 +- `output`:输出文件名。 + +建议支持以下命令: + +```bash +python scripts/demo_capture/runner.py --all +python scripts/demo_capture/runner.py --id S08 +python scripts/demo_capture/runner.py --id T01,T02,T04 +python scripts/demo_capture/runner.py --role student +``` + +## 9. 页面等待和定位 + +优先使用页面已有的稳定 ID、语义角色、按钮文本和链接目标。能力评测可使用 `#knowledgeRadarChart`、`#analysis-progress`、`#analysis-status-badge` 等已有元素;班级风险表可使用风险标签容器和“查看详情”按钮定位。 + +如果现有选择器不足,只增加少量 `data-capture` 标记,不改变页面展示和业务逻辑。每个流程都要定义明确的完成条件,例如: + +- URL 已切换到目标页面。 +- 主标题已出现。 +- 图表 `canvas` 已存在且宽高大于 0。 +- 分析状态已从加载中变为完成。 +- 风险表至少出现一条演示数据。 +- 页面滚动位置已到达页尾。 + +## 10. 失败处理与校验 + +以下情况不生成成品 GIF,并写入失败报告: + +- 登录失败或角色错误。 +- 演示数据为空。 +- 图表没有绘制。 +- AI 分析或教学建议仍处于加载状态。 +- 教师风险列表为空。 +- 页面没有滚动到页尾。 +- 页面控制台出现未处理异常。 +- 输出尺寸、时长或文件大小超出限制。 + +失败时保留页面截图、HTML、控制台日志、原始视频和当前素材编号,方便定位问题。 + +校验器至少检查: + +- 文件是否存在且可以被 FFmpeg 读取。 +- 画幅是否为 16:9。 +- 时长是否符合对应模式。 +- GIF 是否至少包含多个有效帧。 +- GIF 是否超过答辩素材的文件大小上限。 +- S08 是否包含雷达图和分析结果。 +- T01 和 T04 是否包含风险或重点关注信息。 +- 纵向巡览是否同时覆盖页面顶部和底部。 + +## 11. 验收标准 + +执行以下命令后,14 条素材全部生成并通过校验: + +```bash +python scripts/demo_capture/runner.py --all +``` + +验收结果需要满足: + +1. 本地无外部 API 依赖时可以完成录制。 +2. 学生端和教师端账号不会串用。 +3. 学生能力评测能看到雷达图、进度和 AI 分析结果。 +4. 教师端能看到未提交、低分或长期未活跃等重点关注项。 +5. 个人统计页、教师首页和班级详情页能从页首滑到页尾。 +6. 每条素材都生成 GIF、MP4、PNG 和 JSON 元数据。 +7. 失败素材不会被当成成功结果写入输出目录。 +8. 使用相同固定数据重复录制时,主要文案、图表和状态保持一致。 + +## 12. 实施边界 + +实现阶段只增加录制脚本、测试数据、测试配置、必要的稳定定位标记和转码校验工具。正式页面只做保证演示可重放所需的最小改动,不借机重构业务模块或调整现有视觉设计。 From 9a97c2d1743f42fb106a5c846f513db41f23a050 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 19 Aug 2026 01:35:36 +0800 Subject: [PATCH 03/12] docs: add defense GIF capture implementation plan --- ...ense-defense-gif-capture-implementation.md | 706 ++++++++++++++++++ 1 file changed, 706 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-19-codesense-defense-gif-capture-implementation.md diff --git a/docs/superpowers/plans/2026-08-19-codesense-defense-gif-capture-implementation.md b/docs/superpowers/plans/2026-08-19-codesense-defense-gif-capture-implementation.md new file mode 100644 index 0000000..d498e8d --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-codesense-defense-gif-capture-implementation.md @@ -0,0 +1,706 @@ +# CodeSense 答辩核心 GIF 录制实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在本地 Flask 环境中构建一套可重复运行的录制工具,自动生成学生端、教师端、能力评测、风险提示和长页面巡览的 14 条答辩 GIF,并输出 MP4、PNG、元数据和校验报告。 + +**Architecture:** 使用 Python Playwright 驱动 Chromium,使用隔离 SQLite 数据库和固定演示数据,使用浏览器路由拦截异步 AI/SSE 接口。Playwright 先保存 30 FPS WebM,FFmpeg 再转成 1120×630 的 12 FPS GIF 和 MP4,校验器检查媒体文件及关键页面状态。 + +**Tech Stack:** Flask 2.2、SQLAlchemy、Python Playwright、PyYAML、Chromium、FFmpeg、pytest/unittest。 + +## Global Constraints + +- 录制只连接本地 Flask 服务,不读取生产数据或真实用户账号。 +- 完整素材包必须覆盖 14 个编号:S01–S09、T01–T05。 +- 浏览器视口固定为 `1280×720`,原始录制为 30 FPS WebM。 +- GIF 主规格为 12 FPS、`1120×630`;超过 8 MB 时降为 `960×540`,并在报告中标记。 +- 输出目录固定为 `E:\CodeSense\outputs\codesense-defense-gifs\`。 +- 固定学生、教师、班级、作业、提交、能力评分和风险状态由独立 SQLite 数据库提供。 +- S08、T03 以及其他异步 AI 页面使用本地固定响应,不调用外部大模型 API。 +- Chart.js、marked、Bootstrap 等页面依赖在录制前进入本地缓存;缓存缺失时直接失败。 +- 正式页面只允许增加测试所需的最小稳定定位标记,不做无关重构或视觉改版。 +- 每个任务先写可独立运行的测试,再实现最小代码并提交一次。 + +--- + +### Task 1: 建立录制包、类型和 14 条素材清单 + +**Files:** +- Create: `requirements-capture.txt` +- Create: `scripts/demo_capture/__init__.py` +- Create: `scripts/demo_capture/types.py` +- Create: `scripts/demo_capture/manifest.py` +- Create: `scripts/demo_capture/manifest.yaml` +- Create: `tests/test_demo_capture_manifest.py` + +**Interfaces:** +- `FlowSpec`: 不可变数据类,字段为 `id: str`、`role: str`、`route: str`、`mode: str`、`fixture: str`、`actions: tuple[dict, ...]`、`ready: tuple[dict, ...]`、`output: str`。 +- `load_manifest(path: Path) -> dict[str, FlowSpec]`:读取 YAML,按素材编号返回流程。 +- `validate_manifest(flows: Mapping[str, FlowSpec]) -> None`:发现重复编号、非法角色、非法模式、空路由或重复输出名时抛出 `ValueError`。 +- `select_flows(flows: Mapping[str, FlowSpec], ids: Sequence[str] | None, role: str | None) -> list[FlowSpec]`:按编号或角色筛选,保持清单顺序。 + +**Implementation details:** +- `requirements-capture.txt` 添加 `playwright>=1.40,<2` 和 `PyYAML>=6.0,<7`;FFmpeg 作为外部命令由预检器检查,不放入 Python 依赖。 +- `manifest.yaml` 写入 S01–S09、T01–T05,使用设计文档中的路由、模式和输出名。 +- S05–S07 共用 `/thinking/`,通过 `stage` 字段区分第一、第二、第三阶段。 +- S09、T02、T04 的 `mode` 为 `page_tour`,其余为 `interaction`。 + +- [ ] **Step 1: 写清单解析的失败测试** + +```python +from pathlib import Path + +from scripts.demo_capture.manifest import load_manifest, select_flows + + +MANIFEST = Path("scripts/demo_capture/manifest.yaml") + + +def test_manifest_contains_all_defense_flows(): + flows = load_manifest(MANIFEST) + assert list(flows) == [ + "S01", "S02", "S03", "S04", "S05", "S06", "S07", "S08", "S09", + "T01", "T02", "T03", "T04", "T05", + ] + assert flows["S08"].mode == "interaction" + assert flows["T04"].mode == "page_tour" + + +def test_select_flows_filters_by_role_and_id(): + flows = load_manifest(MANIFEST) + assert [flow.id for flow in select_flows(flows, None, "student")] == [ + "S01", "S02", "S03", "S04", "S05", "S06", "S07", "S08", "S09" + ] + assert [flow.id for flow in select_flows(flows, ["T04", "S08"], None)] == ["S08", "T04"] +``` + +- [ ] **Step 2: 运行测试,确认当前模块缺失** + +Run: `python -m pytest tests/test_demo_capture_manifest.py -q` + +Expected: FAIL with `ModuleNotFoundError` or missing manifest implementation. + +- [ ] **Step 3: 实现 `FlowSpec`、YAML 读取、校验和筛选** + +使用 `yaml.safe_load`,把 `actions` 和 `ready` 转成 tuple,按 YAML 中的顺序写入有序字典。对清单中的 14 个编号运行 `validate_manifest`,让启动阶段即可发现配置错误。 + +- [ ] **Step 4: 运行单元测试** + +Run: `python -m pytest tests/test_demo_capture_manifest.py -q` + +Expected: PASS,至少 2 个测试通过。 + +- [ ] **Step 5: 提交** + +```bash +git add requirements-capture.txt scripts/demo_capture tests/test_demo_capture_manifest.py +git commit -m "feat: add defense capture manifest" +``` + +### Task 2: 增加录制模式和隔离演示数据 + +**Files:** +- Modify: `app.py`,在 `create_app()` 的会话初始化和异步任务初始化处增加 `CAPTURE_MODE` 分支。 +- Create: `scripts/demo_capture/fixtures/__init__.py` +- Create: `scripts/demo_capture/fixtures/seed_demo.py` +- Create: `scripts/demo_capture/fixtures/data.py` +- Create: `tests/demo_capture_test_utils.py` +- Create: `tests/test_demo_capture_fixtures.py` +- Create: `tests/test_capture_submit_route.py` +- Create: `tests/test_capture_mode.py` + +**Interfaces:** +- `DemoIds`: 不可变数据类,字段为 `student_username`、`teacher_username`、`student_password`、`teacher_password`、`class_id`、`assignment_id`、`submission_id`、`risk_student_ids`。 +- `seed_demo_data() -> DemoIds`:在当前 Flask 应用上下文中清空并写入演示数据,返回后续流程所需的数据库 ID。 +- `seed_database(db_path: Path) -> DemoIds`:命令行入口,设置 `TEST_DATABASE_URL` 后创建测试应用、建表、播种数据并输出 JSON ID 文件。 +- `capture_mode_enabled() -> bool`:只从 `CAPTURE_MODE` 读取 `1/true/yes`。 +- `build_capture_test_app(db_path: Path) -> tuple[Flask, DemoIds]`:测试辅助函数,使用指定 SQLite 文件创建测试应用并播种数据,不读取开发数据库。 + +**Implementation details:** +- 录制进程使用 `FLASK_CONFIG=testing`、`TEST_DATABASE_URL`、`SECRET_KEY=capture-test-key`、`LOAD_LOCAL_MODEL=False`、`CAPTURE_MODE=1` 和 `FLASK_DEBUG=False`。 +- `CAPTURE_MODE=1` 时,`app.py` 使用文件系统会话,不连接 Redis;不启动 `utils.async_tasks.init_async_tasks`,异步页面由浏览器路由桩提供响应。 +- 数据只写入运行目录中的 SQLite 文件,不修改 `instance/` 中的开发数据库。 +- 演示数据至少包含:学生 `demo_student`、教师 `demo_teacher`、一个示例班级、一个 C 语言作业、一个已提交记录、一个待关注学生、低分和未提交状态、五维能力评分、知识点评分,以及一个 `status="ready"` 的 `AssignmentThinkingPreset`,其中写入 `key_steps`、`code_blocks`、`noise_blocks`、`quiz_steps` 和 `algorithm_summary`。 +- 密码使用 `werkzeug.security.generate_password_hash`,不在模板或日志中输出明文密码。 +- `seed_demo.py` 必须在导入 `app` 前解析 `--db` 参数并设置环境变量,避免 `TestingConfig` 提前读取错误的数据库路径。 +- `routes/assignments.py` 在 `CAPTURE_MODE=1` 且提交记录已写入后,跳过真实异步评测,直接跳转到 `evaluating_submission`;这样 S03 可以展示评测等待状态,S04 使用预置的已完成提交详情。 + +- [ ] **Step 1: 写演示数据契约测试** + +```python +def test_seed_demo_data_contains_both_roles_and_risk_states(app): + with app.app_context(): + ids = seed_demo_data() + assert ids.student_username == "demo_student" + assert ids.teacher_username == "demo_teacher" + assert ids.assignment_id > 0 + assert ids.submission_id > 0 + assert ids.risk_student_ids + + student = User.query.filter_by(username="demo_student").one() + teacher = User.query.filter_by(username="demo_teacher").one() + assert student.usertype == "学生" + assert teacher.usertype == "教师" + assert KnowledgePointScore.query.count() >= 5 + assert AssignmentThinkingPreset.query.filter_by( + assignment_id=ids.assignment_id, status="ready" + ).count() == 1 +``` + +- [ ] **Step 2: 运行测试,确认播种函数尚未存在** + +Run: `python -m pytest tests/test_demo_capture_fixtures.py -q` + +Expected: FAIL because `seed_demo_data` is not implemented. + +- [ ] **Step 3: 写录制模式配置测试** + +```python +def test_capture_mode_uses_filesystem_session(monkeypatch, tmp_path): + monkeypatch.setenv("CAPTURE_MODE", "1") + monkeypatch.setenv("TEST_DATABASE_URL", f"sqlite:///{tmp_path / 'capture.db'}") + from config import TestingConfig + monkeypatch.setattr(TestingConfig, "SQLALCHEMY_DATABASE_URI", f"sqlite:///{tmp_path / 'capture.db'}") + from app import create_app + app = create_app("testing") + assert app.config["CAPTURE_MODE"] is True + assert app.config["SESSION_TYPE"] == "filesystem" +``` + +- [ ] **Step 4: 实现配置分支和固定数据播种** + +在 `create_app()` 中保留默认 Redis 路径,仅在 `CAPTURE_MODE` 下跳过 Redis 探测并关闭后台异步任务。`seed_demo_data()` 只使用 `models.py` 中已有的 `User`、`Class`、`Assignment`、`TestCase`、`Submission`、`KnowledgePointScore`、`AbilityTrend` 和 `AssignmentThinkingPreset` 模型,不修改模型字段。 + +- [ ] **Step 5: 验证录制模式下提交不启动真实评测** + +```python +def test_capture_submit_redirects_to_evaluating_without_async_worker(tmp_path): + app, ids = build_capture_test_app(tmp_path / "capture.db") + client = app.test_client() + client.post("/login", data={"username": "demo_student", "password": "student123"}) + response = client.post( + f"/submit/{ids.assignment_id}", + data={"code": "int main(){return 0;}", "language": "cpp"}, + follow_redirects=False, + ) + assert response.status_code == 302 + assert "/submission/" in response.headers["Location"] + assert response.headers["Location"].endswith("/evaluating") +``` + +- [ ] **Step 6: 运行测试** + +Run: `python -m pytest tests/test_demo_capture_fixtures.py tests/test_capture_submit_route.py tests/test_capture_mode.py -q` + +Expected: PASS,数据库中存在两个角色、一个作业、一个提交和风险数据。 + +- [ ] **Step 7: 提交** + +```bash +git add app.py routes/assignments.py scripts/demo_capture/fixtures tests/test_demo_capture_fixtures.py tests/test_capture_submit_route.py tests/test_capture_mode.py +git commit -m "feat: add isolated capture demo data" +``` + +### Task 3: 实现静态资源缓存和浏览器 API 桩 + +**Files:** +- Create: `scripts/demo_capture/assets.py` +- Create: `scripts/demo_capture/stubs.py` +- Create: `tests/test_demo_capture_assets.py` +- Create: `tests/test_demo_capture_stubs.py` + +**Interfaces:** +- `AssetEntry`: 数据类,字段为 `url: str`、`path: Path`、`content_type: str`、`sha256: str`。 +- `AssetManifest`: 数据类,字段为 `entries: tuple[AssetEntry, ...]` 和 `created_at: str`。 +- `discover_external_assets(template_root: Path) -> tuple[str, ...]`:扫描答辩涉及模板中的 CDN URL,去重并保持出现顺序。 +- `prepare_asset_cache(template_root: Path, cache_dir: Path, refresh: bool) -> AssetManifest`:下载或读取缓存文件,生成带 SHA-256 的清单;缺失且 `refresh=False` 时抛出 `AssetCacheError`。 +- `install_asset_routes(context: BrowserContext, manifest: AssetManifest) -> None`:把 CDN URL 映射到本地缓存文件。 +- `install_api_stubs(context: BrowserContext, ids: DemoIds) -> None`:安装评测、能力分析、三阶段学习和教师建议的固定 API 响应。 +- `sse_body(events: Sequence[dict[str, object]]) -> str`:把事件编码成合法的 `text/event-stream` 内容,每个事件以空行分隔。 + +**Implementation details:** +- 缓存目录使用 `E:\CodeSense\outputs\codesense-defense-gifs\asset-cache\`,清单记录 URL、文件路径、内容类型和 SHA-256。 +- 只缓存模板实际引用的 Chart.js、marked、Bootstrap、Bootstrap Icons、Markdown CSS 和其他答辩页面依赖;不把接口响应当作静态资源缓存。 +- API 桩只拦截 `/api/` 和 `/thinking/api/` 端点,不拦截页面路由。对异步结果返回固定进度、雷达图数据、AI 分析文本、阶段验证结果和教师风险建议。 +- `/api/stream/ability-analysis` 返回 `progress`、`knowledge_profile`、`analysis_section` 和 `complete` 事件。 +- `/api/teacher/stream_suggestions` 返回重点关注学生、薄弱知识点、补练作业和诊断报告。 +- 提交和三阶段接口返回与 `DemoIds` 对应的固定提交 ID,确保 S03–S08 可以从同一套演示数据继续播放。 + +- [ ] **Step 1: 写资源扫描和 SSE 编码测试** + +```python +def test_discover_external_assets_deduplicates_urls(tmp_path): + template = tmp_path / "page.html" + template.write_text( + '' + '', + encoding="utf-8", + ) + assert discover_external_assets(tmp_path) == ("https://cdn.example/chart.js",) + + +def test_sse_body_separates_events_with_blank_lines(): + body = sse_body([{"type": "progress", "percent": 50}, {"type": "complete"}]) + assert 'data: {"type": "progress", "percent": 50}' in body + assert body.endswith("\n\n") +``` + +- [ ] **Step 2: 运行测试,确认资源模块尚未实现** + +Run: `python -m pytest tests/test_demo_capture_assets.py tests/test_demo_capture_stubs.py -q` + +Expected: FAIL with missing module or missing function. + +- [ ] **Step 3: 实现缓存清单、路由映射和 API 桩** + +所有缓存文件写入临时文件后再替换目标文件,避免中断时留下半个资源。API 桩统一使用 `route.fulfill`,响应头明确设置 `application/json` 或 `text/event-stream`。 + +- [ ] **Step 4: 运行测试** + +Run: `python -m pytest tests/test_demo_capture_assets.py tests/test_demo_capture_stubs.py -q` + +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add scripts/demo_capture/assets.py scripts/demo_capture/stubs.py tests/test_demo_capture_assets.py tests/test_demo_capture_stubs.py +git commit -m "feat: add deterministic capture assets and API stubs" +``` + +### Task 4: 实现本地 Flask 服务生命周期 + +**Files:** +- Create: `scripts/demo_capture/server.py` +- Create: `tests/test_demo_capture_server.py` + +**Interfaces:** +- `ServerHandle`: 数据类,字段为 `base_url: str`、`process: subprocess.Popen`、`db_path: Path`、`run_dir: Path`,提供 `stop() -> None`。 +- `start_local_server(run_dir: Path, python_executable: str = sys.executable) -> ServerHandle`:创建数据库、播种数据、启动 Flask 子进程并等待 `/login` 返回 200。 +- `wait_until_ready(base_url: str, timeout_seconds: float = 30.0) -> None`:每 0.25 秒轮询健康页面,超时抛出 `ServerStartError` 并附带最近 80 行进程输出。 + +**Implementation details:** +- 运行目录形如 `E:\CodeSense\outputs\codesense-defense-gifs\runs\-\`,其中保存 SQLite 文件、服务日志和原始 WebM。 +- 使用空闲 TCP 端口,环境变量设置为 `HOST=127.0.0.1`、`PORT=`、`FLASK_CONFIG=testing`、`TEST_DATABASE_URL=sqlite:///...`、`CAPTURE_MODE=1` 和 `FLASK_DEBUG=False`。 +- 先调用 `seed_database`,再启动 `python app.py`,避免页面启动后先显示空数据库。 +- `stop()` 必须先 `terminate()`,等待 5 秒,仍未退出时调用 `kill()`,并关闭 stdout/stderr 文件句柄。 +- 启动失败时不删除运行目录,保留日志供报告引用。 + +- [ ] **Step 1: 写服务句柄和超时行为测试** + +```python +def test_wait_until_ready_raises_after_timeout(monkeypatch): + from types import SimpleNamespace + monkeypatch.setattr( + "scripts.demo_capture.server.requests.get", + lambda *a, **k: SimpleNamespace(status_code=503), + ) + with pytest.raises(ServerStartError, match="timed out"): + wait_until_ready("http://127.0.0.1:9", timeout_seconds=0.01) +``` + +- [ ] **Step 2: 运行测试,确认服务模块尚未实现** + +Run: `python -m pytest tests/test_demo_capture_server.py -q` + +Expected: FAIL with missing module or missing exception. + +- [ ] **Step 3: 实现启动、健康检查和清理** + +使用 `subprocess.Popen(..., cwd=source_root, env=env, stdout=log_file, stderr=subprocess.STDOUT)`,不要调用 `shell=True`。健康检查使用已存在的 `/login` 页面,不能依赖需要登录的路由。 + +- [ ] **Step 4: 运行测试** + +Run: `python -m pytest tests/test_demo_capture_server.py -q` + +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add scripts/demo_capture/server.py tests/test_demo_capture_server.py +git commit -m "feat: manage local capture server" +``` + +### Task 5: 实现媒体编码、压缩降级和校验器 + +**Files:** +- Create: `scripts/demo_capture/media.py` +- Create: `scripts/demo_capture/validator.py` +- Create: `tests/test_demo_capture_media.py` +- Create: `tests/test_demo_capture_validator.py` + +**Interfaces:** +- `CaptureProfile`: 数据类,字段为 `width: int`、`height: int`、`fps: int`、`max_bytes: int`。 +- `MediaInfo`: 数据类,字段为 `source: Path`、`mp4: Path`、`gif: Path`、`poster: Path`、`width: int`、`height: int`、`fps: float`、`duration_seconds: float`、`frame_count: int`、`compression_fallback: bool`。 +- `PRIMARY_GIF_PROFILE = CaptureProfile(1120, 630, 12, 8_000_000)`。 +- `FALLBACK_GIF_PROFILE = CaptureProfile(960, 540, 12, 8_000_000)`。 +- `encode_capture(source: Path, mp4: Path, gif: Path, poster: Path, ffmpeg_bin: str = "ffmpeg") -> MediaInfo`。 +- `validate_media(flow: FlowSpec, media: MediaInfo) -> list[str]`:返回错误列表,空列表表示通过。 +- `should_use_fallback(gif_path: Path) -> bool`:GIF 大于 8 MB 时返回 True。 + +**Implementation details:** +- MP4 使用 H.264 编码,GIF 使用 12 FPS、Lanzcos 缩放和 128 色调色板。 +- 主 GIF 过滤链固定为: + +```text +fps=12,scale=1120:630:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=sierra2_4a +``` + +- 超过 8 MB 时用 `960:540` 重新编码,并将 `compression_fallback=true` 写入 JSON 元数据。 +- 首帧 PNG 使用 FFmpeg 的 `-frames:v 1` 生成,不依赖 Pillow。 +- 校验器调用 `ffprobe` 检查宽高、时长和帧数;如果本机没有 FFmpeg,预检阶段返回明确安装提示。 + +- [ ] **Step 1: 写纯函数测试** + +```python +def test_primary_profile_is_16_by_9(): + assert PRIMARY_GIF_PROFILE.width == 1120 + assert PRIMARY_GIF_PROFILE.height == 630 + assert PRIMARY_GIF_PROFILE.fps == 12 + + +def test_large_gif_requests_fallback(tmp_path): + gif = tmp_path / "large.gif" + gif.write_bytes(b"0" * 8_000_001) + assert should_use_fallback(gif) is True +``` + +- [ ] **Step 2: 运行测试,确认媒体模块尚未实现** + +Run: `python -m pytest tests/test_demo_capture_media.py tests/test_demo_capture_validator.py -q` + +Expected: FAIL with missing module or missing profile. + +- [ ] **Step 3: 实现 FFmpeg 命令、降级重编码和 ffprobe 校验** + +所有外部命令使用参数列表调用 `subprocess.run(..., check=True)`,捕获 stderr,并在异常中附带完整命令和 stderr 最后 40 行。 + +- [ ] **Step 4: 运行测试** + +Run: `python -m pytest tests/test_demo_capture_media.py tests/test_demo_capture_validator.py -q` + +Expected: PASS;若机器没有 FFmpeg,媒体集成测试标记为 skip,纯函数测试仍必须通过。 + +- [ ] **Step 5: 提交** + +```bash +git add scripts/demo_capture/media.py scripts/demo_capture/validator.py tests/test_demo_capture_media.py tests/test_demo_capture_validator.py +git commit -m "feat: encode and validate defense media" +``` + +### Task 6: 实现动作执行器、等待条件和录制报告 + +**Files:** +- Create: `scripts/demo_capture/actions.py` +- Create: `scripts/demo_capture/report.py` +- Create: `tests/test_demo_capture_actions.py` +- Create: `tests/test_demo_capture_report.py` + +**Interfaces:** +- `execute_action(page: Page, action: Mapping[str, object], ids: DemoIds) -> None`:支持 `goto`、`fill`、`click`、`wait_for_url`、`wait_for_selector`、`wait_for_text`、`pause`、`set_editor_content`、`scroll_section`、`scroll_to_bottom`。 +- `wait_until_ready(page: Page, checks: Sequence[Mapping[str, object]]) -> None`:支持 selector、text、URL、canvas-size 和 scroll-bottom 检查。 +- `run_action_list(page: Page, actions: Sequence[Mapping[str, object]], ids: DemoIds) -> None`:按清单顺序执行并在失败时附加动作编号。 +- `CaptureResult`: 数据类,字段为 `flow_id`、`status`、`files`、`error`、`duration_seconds`。 +- `CaptureActionError`: `RuntimeError` 子类,消息必须包含从 1 开始的动作编号和原始异常。 +- `write_report(results: Sequence[CaptureResult], path: Path) -> None`:输出 JSON 报告和一份 Markdown 汇总。 + +**Implementation details:** +- 所有 selector 支持 `{assignment_id}`、`{submission_id}` 和 `{class_id}` 占位符,执行前使用 `DemoIds` 替换。 +- `set_editor_content` 先点击 `.monaco-editor textarea`,执行 `Control+A`,再输入固定 C 代码;如果 Monaco 不可用,抛出明确错误,不直接写隐藏表单。 +- `scroll_section` 使用 `locator.scroll_into_view_if_needed()`,再等待 0.8 秒;`scroll_to_bottom` 使用 `page.mouse.wheel` 分段滚动并最终检查 `scrollY + innerHeight >= scrollHeight - 4`。 +- `run_action_list` 不吞异常;runner 捕获异常后保存页面截图、HTML 和控制台日志。 +- 报告同时记录缓存版本、演示数据版本、Git commit、浏览器版本和 FFmpeg 版本。 + +- [ ] **Step 1: 写动作分派和滚动完成条件测试** + +```python +def test_scroll_bottom_check_uses_four_pixel_tolerance(): + page = MagicMock() + page.evaluate.return_value = True + wait_until_ready(page, [{"kind": "scroll_bottom"}]) + page.evaluate.assert_called_once() + + +def test_unknown_action_reports_action_index(): + page = MagicMock() + with pytest.raises(CaptureActionError, match="action 2"): + run_action_list(page, [{"kind": "pause", "seconds": 0}, {"kind": "bad"}], object()) +``` + +- [ ] **Step 2: 运行测试,确认动作模块尚未实现** + +Run: `python -m pytest tests/test_demo_capture_actions.py tests/test_demo_capture_report.py -q` + +Expected: FAIL with missing action implementation. + +- [ ] **Step 3: 实现动作和等待条件** + +对页面操作使用 Playwright 的 locator API;固定等待只允许用于 GIF 节奏,页面完成状态必须使用 selector、文本、URL 或页面计算值判断。 + +- [ ] **Step 4: 实现报告写入和失败附件路径** + +报告使用 UTF-8 JSON,`files` 保存绝对路径,`error` 保存异常类型和消息;Markdown 汇总按通过、失败分组。 + +- [ ] **Step 5: 运行测试** + +Run: `python -m pytest tests/test_demo_capture_actions.py tests/test_demo_capture_report.py -q` + +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add scripts/demo_capture/actions.py scripts/demo_capture/report.py tests/test_demo_capture_actions.py tests/test_demo_capture_report.py +git commit -m "feat: execute capture actions and write reports" +``` + +### Task 7: 实现浏览器上下文和学生端 S01–S09 + +**Files:** +- Create: `scripts/demo_capture/browser.py` +- Create: `scripts/demo_capture/flows/student_flows.py` +- Create: `tests/test_demo_capture_student_flows.py` +- Modify only if required for stable selectors: `templates/components/code_editor_new.html`、`templates/student_home.html`、`templates/profile.html`、`templates/thinking/arena.html`。 + +**Interfaces:** +- `create_recording_context(browser: Browser, run_dir: Path) -> BrowserContext`:固定 `1280×720`、`device_scale_factor=1`、`locale="zh-CN"`、`timezone_id="Asia/Shanghai"`,设置 `record_video_dir`。 +- `login_as(page: Page, username: str, password: str, expected_path: str) -> None`。 +- `run_student_flow(flow: FlowSpec, page: Page, ids: DemoIds) -> None`。 +- `STUDENT_FLOW_IDS = ("S01", "S02", "S03", "S04", "S05", "S06", "S07", "S08", "S09")`。 + +**Flow details:** +- S01 使用 `#username`、`#password` 和登录按钮,等待跳转到学生首页。 +- S02 从学生首页进入作业列表,再打开固定作业详情。 +- S03 使用 Monaco 编辑器输入固定 C 代码,点击 `#submit-code-btn`,等待提交状态。 +- S04 打开固定提交详情,等待运行结果、评分和 AI 反馈区域。 +- S05 使用 `#description-input` 和 `#stage1-submit`,等待 `#score-result`。 +- S06 等待 `#stage2-quiz-container`,选择固定步骤并调用验证按钮,等待代码预览。 +- S07 使用 `#teacher-chat-input` 或 `#student-chat-input`,发送固定解释,等待对话消息出现。 +- S08 等待 `#knowledgeRadarChart`、`#analysis-progress` 和 `#analysis-status-badge` 完成,并停留在雷达图与分析输出。 +- S09 点击 `a[href="#statistics"]`,依次巡览统计卡片、`#abilityChart` 和 `#submissionTrendChart`,最后到达页尾。 + +- [ ] **Step 1: 写学生流程契约测试** + +```python +def test_student_flow_ids_match_manifest(): + flows = load_manifest(Path("scripts/demo_capture/manifest.yaml")) + assert tuple(flow_id for flow_id in flows if flow_id.startswith("S")) == STUDENT_FLOW_IDS + + +def test_student_manifest_has_required_ready_selectors(): + flows = load_manifest(Path("scripts/demo_capture/manifest.yaml")) + assert "#knowledgeRadarChart" in repr(flows["S08"].ready) + assert "#abilityChart" in repr(flows["S09"].ready) +``` + +- [ ] **Step 2: 运行测试,确认学生流程未注册** + +Run: `python -m pytest tests/test_demo_capture_student_flows.py -q` + +Expected: FAIL because browser/flow registration is missing. + +- [ ] **Step 3: 实现浏览器上下文、学生登录和 S01–S09 动作** + +每条流程使用新的 context;S03 的编辑器输入、S06 的步骤选择和 S07 的对话发送使用专用 hook,普通点击和滚动继续由 `actions.py` 处理。 + +- [ ] **Step 4: 仅在 selector 缺失时增加 `data-capture` 标记** + +标记只放在编辑器根节点、阶段容器和统计图表容器,不改变 CSS、文案和业务行为。新增标记必须在模板测试中断言一次。 + +- [ ] **Step 5: 运行学生单元测试** + +Run: `python -m pytest tests/test_demo_capture_student_flows.py -q` + +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add scripts/demo_capture/browser.py scripts/demo_capture/flows/student_flows.py templates/components/code_editor_new.html templates/student_home.html templates/profile.html templates/thinking/arena.html tests/test_demo_capture_student_flows.py +git commit -m "feat: add student defense capture flows" +``` + +### Task 8: 实现教师端 T01–T05 + +**Files:** +- Create: `scripts/demo_capture/flows/teacher_flows.py` +- Create: `tests/test_demo_capture_teacher_flows.py` +- Modify only if required for stable selectors: `templates/teacher_home.html`、`templates/classes/class_detail.html`、`templates/teacher_ai_suggestions.html`。 + +**Interfaces:** +- `run_teacher_flow(flow: FlowSpec, page: Page, ids: DemoIds) -> None`。 +- `TEACHER_FLOW_IDS = ("T01", "T02", "T03", "T04", "T05")`。 + +**Flow details:** +- T01 以教师账号进入 `/teacher_dashboard`,先停留在“今日需要关注”,点击低分或未提交学生 chip,再等待学生详情页。 +- T02 从教师首页顶部开始,依次经过关注项、AI 个性化建议、近 14 天提交趋势、班级人数和平均分图表,最终到页尾。 +- T03 打开 `/teacher/ai_suggestions`,点击 `.btn-refresh-sug`,等待重点关注学生、薄弱知识点和诊断报告出现。 +- T04 打开固定班级详情,巡览作业进度、注册进度和学生表格,确认风险标签列可见并到达页尾。 +- T05 以教师身份打开固定提交详情,停留在代码、评测结果和反馈区。 + +- [ ] **Step 1: 写教师流程契约测试** + +```python +def test_teacher_flow_ids_match_manifest(): + flows = load_manifest(Path("scripts/demo_capture/manifest.yaml")) + assert tuple(flow_id for flow_id in flows if flow_id.startswith("T")) == TEACHER_FLOW_IDS + + +def test_teacher_risk_tour_requires_risk_tags(): + flows = load_manifest(Path("scripts/demo_capture/manifest.yaml")) + assert "risk" in repr(flows["T04"].ready).lower() +``` + +- [ ] **Step 2: 运行测试,确认教师流程未实现** + +Run: `python -m pytest tests/test_demo_capture_teacher_flows.py -q` + +Expected: FAIL because teacher flow registration is missing. + +- [ ] **Step 3: 实现 T01–T05 和教师巡览停留点** + +风险页使用现有“未提交、低分、长期未活跃、未注册”文案和风险标签;AI 建议页使用浏览器 API 桩,不触发真实 API。 + +- [ ] **Step 4: 仅在 selector 缺失时增加捕获标记** + +优先复用 `.dashboard-section`、`.attention-card`、`.risk-tags`、`.btn-refresh-sug` 和现有链接,不为录制引入新的可见组件。 + +- [ ] **Step 5: 运行教师单元测试** + +Run: `python -m pytest tests/test_demo_capture_teacher_flows.py -q` + +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add scripts/demo_capture/flows/teacher_flows.py templates/teacher_home.html templates/classes/class_detail.html templates/teacher_ai_suggestions.html tests/test_demo_capture_teacher_flows.py +git commit -m "feat: add teacher risk and analytics capture flows" +``` + +### Task 9: 组装 CLI、单条重录和使用文档 + +**Files:** +- Create: `scripts/demo_capture/runner.py` +- Create: `scripts/demo_capture/README.md` +- Create: `tests/test_demo_capture_cli.py` + +**Interfaces:** +- `main(argv: Sequence[str] | None = None) -> int`:支持 `--all`、`--id S08`、`--id T01,T02,T04`、`--role student|teacher`、`--output-dir`、`--base-url`、`--no-server`、`--refresh-assets`。 +- `CaptureSelection`: 数据类,字段为 `ids: tuple[str, ...] | None`、`role: str | None`、`output_dir: Path | None`、`base_url: str | None`、`start_server: bool`、`refresh_assets: bool`。 +- `run_selected_flows(selection: CaptureSelection) -> list[CaptureResult]`:启动服务、加载缓存、运行浏览器流程、编码媒体并写报告。 + +**Implementation details:** +- 默认从 `Path(__file__).resolve().parents[3] / "outputs" / "codesense-defense-gifs"` 计算输出目录,不依赖当前 shell 工作目录。 +- `--id` 和 `--role` 互斥时返回 exit code 2;不存在的编号显示可用编号。 +- 默认执行完整流程并在一个 runner 运行目录中保存 raw、media 和 report;`--no-server` 用于连接用户已启动的本地 Flask 服务,但仍使用固定的 `--base-url`。 +- 任意一条失败都写入报告;`--all` 最终返回 1,单条成功返回 0。 +- README 写明 Python 依赖安装、`python -m playwright install chromium`、FFmpeg 检查、资源缓存、完整录制、单条重录和常见失败原因。 + +- [ ] **Step 1: 写 CLI 参数测试** + +```python +def test_cli_rejects_unknown_flow_id(capsys): + assert main(["--id", "S99"]) == 2 + assert "S99" in capsys.readouterr().err + + +def test_cli_help_is_available(capsys): + assert main(["--help"]) == 0 + assert "--all" in capsys.readouterr().out +``` + +- [ ] **Step 2: 运行测试,确认 CLI 尚未实现** + +Run: `python -m pytest tests/test_demo_capture_cli.py -q` + +Expected: FAIL because `runner.py` is missing. + +- [ ] **Step 3: 实现 CLI 和主流程编排** + +编排顺序固定为:预检依赖 → 解析清单 → 创建运行目录 → 启动/连接服务 → 准备资源缓存 → 安装 API 桩 → 运行流程 → 编码媒体 → 校验 → 写报告 → 清理服务。 + +- [ ] **Step 4: 编写 README 并运行 CLI 单元测试** + +Run: `python -m pytest tests/test_demo_capture_cli.py -q` + +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add scripts/demo_capture/runner.py scripts/demo_capture/README.md tests/test_demo_capture_cli.py +git commit -m "feat: add defense capture CLI" +``` + +### Task 10: 完成端到端烟测和全量验收 + +**Files:** +- Create: `tests/test_demo_capture_e2e.py` +- Modify: `scripts/demo_capture/README.md`,补充实际运行结果和故障定位示例。 + +**Interfaces:** +- `run_capture_e2e(flow_ids: Sequence[str]) -> list[CaptureResult]`:在临时输出目录运行真实 Chromium 录制,供测试和人工验收复用。 + +**Implementation details:** +- 端到端测试默认 skip,只有设置 `RUN_CAPTURE_E2E=1` 且本机存在 Chromium、FFmpeg 时才运行,避免普通单元测试启动 Flask 和浏览器。 +- 第一轮只跑 S08、T01、T04,覆盖学生能力评测、教师风险提示和长页面滚动。 +- 第二轮执行全部 14 条并检查 `reports/summary.json` 中全部状态为 `passed`。 +- 打开生成的 contact sheet 或逐条查看 PNG 首帧,确认没有空白图表、异常弹窗、敏感真实数据和浏览器地址栏。 +- 对 GIF 使用 `ffprobe` 检查 16:9、时长和帧数;对超过 8 MB 的条目确认报告包含 `compression_fallback=true`。 + +- [ ] **Step 1: 写默认跳过的 E2E 测试入口** + +```python +@pytest.mark.skipif(os.getenv("RUN_CAPTURE_E2E") != "1", reason="set RUN_CAPTURE_E2E=1") +def test_capture_visual_smoke_for_student_ability_and_teacher_risk(): + results = run_selected_flows(CaptureSelection(ids=("S08", "T01", "T04"))) + assert [result.status for result in results] == ["passed", "passed", "passed"] +``` + +- [ ] **Step 2: 运行普通测试套件** + +Run: `python -m pytest tests/test_demo_capture_manifest.py tests/test_demo_capture_fixtures.py tests/test_capture_mode.py tests/test_demo_capture_assets.py tests/test_demo_capture_stubs.py tests/test_demo_capture_server.py tests/test_demo_capture_media.py tests/test_demo_capture_validator.py tests/test_demo_capture_actions.py tests/test_demo_capture_report.py tests/test_demo_capture_student_flows.py tests/test_demo_capture_teacher_flows.py tests/test_demo_capture_cli.py -q` + +Expected: PASS;没有浏览器或 FFmpeg 的机器只跳过明确标记的媒体集成检查。 + +- [ ] **Step 3: 安装录制依赖并执行三条烟测** + +```bash +pip install -r requirements-capture.txt +python -m playwright install chromium +ffmpeg -version +python scripts/demo_capture/runner.py --id S08,T01,T04 +``` + +Expected: 输出 3 条 GIF、3 条 MP4、3 张 PNG 和一份通过报告。 + +- [ ] **Step 4: 执行全量录制** + +```bash +python scripts/demo_capture/runner.py --all +``` + +Expected: S01–S09、T01–T05 全部通过,报告显示 14/14,输出目录含 GIF、MP4、PNG、JSON 元数据和日志。 + +- [ ] **Step 5: 运行完整项目测试** + +Run: `python -m pytest -q` + +Expected: 现有测试和录制相关测试全部通过;若既有测试因环境缺少外部服务失败,记录原始失败信息,不修改与录制无关的业务代码。 + +- [ ] **Step 6: 提交最终验收记录** + +```bash +git add tests/test_demo_capture_e2e.py scripts/demo_capture/README.md +git commit -m "test: verify defense GIF capture pipeline" +``` + +## 完成定义 + +- `docs/superpowers/specs/2026-08-19-codesense-defense-gif-capture-design.md` 中的 14 条素材均有对应 manifest、动作和 ready 条件。 +- `python scripts/demo_capture/runner.py --id S08,T01,T04` 能在本地生成并校验三条代表性素材。 +- `python scripts/demo_capture/runner.py --all` 能生成 14 条素材并返回成功状态。 +- 失败时有截图、HTML、控制台日志、原始 WebM 和报告,不会留下无声无内容的“成功” GIF。 +- 当前工作区中原有的 `.claude/settings.local.json`、`static/images/generated/` 和 `static/uploads/` 变更不被暂存或覆盖。 From 380e10f5aba59e13bc8cb366b8bef3a871819a6e Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 26 Aug 2026 11:38:19 +0800 Subject: [PATCH 04/12] feat: complete public guided-learning demo experience --- README.md | 11 + routes/auth.py | 54 +++ routes/classes.py | 170 +------- routes/thinking.py | 29 +- services/demo_experience.py | 597 ++++++++++++++++++++++++++ static/js/thinking.js | 29 +- templates/login.html | 69 +++ templates/thinking/arena.html | 2 +- tests/demo_test_utils.py | 35 ++ tests/test_demo_experience.py | 87 ++++ tests/test_demo_guided_learning.py | 176 ++++++++ tests/test_demo_login.py | 66 +++ tests/test_demo_teacher_experience.py | 44 ++ 13 files changed, 1190 insertions(+), 179 deletions(-) create mode 100644 services/demo_experience.py create mode 100644 tests/demo_test_utils.py create mode 100644 tests/test_demo_experience.py create mode 100644 tests/test_demo_guided_learning.py create mode 100644 tests/test_demo_login.py create mode 100644 tests/test_demo_teacher_experience.py diff --git a/README.md b/README.md index caafd6a..0d3ae99 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,17 @@ CodeSense 酷森思是专为高校编程实训设计的**智能化评测与教 --- +## 公开体验入口 + +启动服务后打开 `/login`,登录页会提供两个无需注册的体验入口: + +* **学生体验**:直接进入“演示作业一:循环与斐波那契数列”,可以查看思路描述、积木编程和费曼教学三个阶段。页面右下角的体验进度入口可以跳到任意阶段或查看完成效果。 +* **教师体验**:进入教师首页,查看演示班级、学生学习状态、作业完成矩阵和 AI 学情建议,再进入班级详情查看具体记录。 + +演示数据由公开入口自动准备,重复进入不会删除已有的学习会话、提交记录或日志。旧的 `/sandbox-login/` 和 `/classes/seed-demo-data` 仍只用于开发/测试环境,不作为对外入口。 + +--- + ## 系统架构 ```mermaid diff --git a/routes/auth.py b/routes/auth.py index 65ca151..7eb1c04 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -7,11 +7,39 @@ from flask_login import login_user, logout_user, current_user from models import db, Class, StudentRoster, User, SystemLog, SystemConfig from forms import LoginForm, RegistrationForm +from services.demo_experience import ( + DEMO_STUDENT_ID, + DEMO_TEACHER_ID, + ensure_demo_experience, +) from utils.auth import redirect_if_logged_in auth = Blueprint('auth', __name__) +def _establish_login_session(user, source=None): + """建立单点登录会话,并同步 Flask-Login 与旧版 session 字段。""" + new_session_id = uuid.uuid4().hex + user.current_session_id = new_session_id + db.session.commit() + + login_user(user) + session['current_session_id'] = new_session_id + session['student_id'] = user.student_id + session['username'] = user.username + session['full_name'] = user.full_name or user.username + session['usertype'] = user.usertype + session['login'] = True + + prefix = f'[{source}] ' if source else '' + SystemLog.add_log( + log_type='用户登录', + content=f'{prefix}用户 {user.username} ({user.full_name}) 登录了系统', + user_id=user.student_id, + ) + return new_session_id + + @auth.route('/') def index(): """首页,重定向到登录页面""" @@ -71,6 +99,32 @@ def login(): return render_template('login.html', form=form, login_message=login_message, site_name=site_name) +@auth.route('/demo-login/') +@redirect_if_logged_in +def demo_login(role): + """公开演示入口:准备演示数据后,按角色进入对应体验首页。""" + if role not in ('student', 'teacher'): + flash('该演示入口不可用,请返回登录页重试。', 'warning') + return redirect(url_for('auth.login')) + + try: + demo = ensure_demo_experience() + user_id = DEMO_STUDENT_ID if role == 'student' else DEMO_TEACHER_ID + user = User.query.get(user_id) + if not user: + raise RuntimeError('演示账号初始化后不存在') + + _establish_login_session(user, source='公开演示') + if role == 'student': + return redirect(url_for('thinking.arena', assignment_id=demo.assignment_id)) + return redirect(url_for('main.home')) + except Exception: + db.session.rollback() + current_app.logger.exception('公开演示入口初始化失败') + flash('演示入口暂时不可用,请稍后重试。', 'warning') + return redirect(url_for('auth.login')) + + @auth.route('/register', methods=['GET', 'POST']) @redirect_if_logged_in def register(): diff --git a/routes/classes.py b/routes/classes.py index 5829063..e26b3b4 100644 --- a/routes/classes.py +++ b/routes/classes.py @@ -6,6 +6,7 @@ from sqlalchemy import func, desc import pandas as pd from models import db, Class, StudentRoster, User, Assignment, Submission +from services.demo_experience import ensure_demo_experience from services.teacher_analytics import build_assignment_completion_matrix, build_class_learning_rows from utils.auth import admin_required, admin_or_teacher_required @@ -761,175 +762,18 @@ def import_classes(): @classes.route('/seed-demo-data', methods=['POST']) def seed_demo_data(): - """一键装载演示环境沙箱数据(仅在测试或开发模式下允许)""" + """兼容旧的演示数据装载入口(仅在测试或开发模式下允许)。""" from flask import current_app if not (current_app.config.get('DEBUG') or current_app.config.get('TESTING')): current_app.logger.warning(f"拒绝数据装载尝试:非开发或测试模式。IP: {request.remote_addr}") return "Forbidden", 403 + # 兼容旧的开发入口,但统一使用不会删除体验记录的幂等服务。 try: - from models import ( - User, Class, StudentRoster, Assignment, Submission, - KnowledgePointScore, AssignmentKnowledgePoint, ThinkingSession, - TeacherAISuggestion, db - ) - - # 1. 彻底清除之前的 demo 账号和数据,保证重新导入时的幂等性与干净环境 - student_demo_ids = ['demo_s_001', 'demo_s_002', 'demo_s_003', 'demo_s_004'] - User.query.filter(User.student_id.in_(student_demo_ids + ['demo_t_001'])).delete(synchronize_session=False) - StudentRoster.query.filter(StudentRoster.student_id.in_(student_demo_ids)).delete(synchronize_session=False) - KnowledgePointScore.query.filter(KnowledgePointScore.student_id.in_(student_demo_ids)).delete(synchronize_session=False) - Submission.query.filter(Submission.student_id.in_(student_demo_ids)).delete(synchronize_session=False) - ThinkingSession.query.filter(ThinkingSession.student_id.in_(student_demo_ids)).delete(synchronize_session=False) - - old_class = Class.query.filter_by(name='软件工程24-演示班').first() - if old_class: - TeacherAISuggestion.query.filter_by(class_id=old_class.id).delete(synchronize_session=False) - db.session.delete(old_class) - - old_assignments = Assignment.query.filter(Assignment.creator_id == 'demo_t_001').all() - for oa in old_assignments: - AssignmentKnowledgePoint.query.filter_by(assignment_id=oa.id).delete(synchronize_session=False) - db.session.delete(oa) - - db.session.commit() - - # 2. 创建演示教师 - demo_teacher = User( - student_id='demo_t_001', - username='teacher_demo', - usertype='教师', - full_name='李老师(演示)', - email='teacher_demo@codesense.edu' - ) - demo_teacher.password = '123456' - db.session.add(demo_teacher) - - # 3. 创建演示班级 - demo_class = Class( - name='软件工程24-演示班', - school='酷森思大学', - college='计算机学院', - major='软件工程', - grade='2024', - teacher_id='demo_t_001' - ) - db.session.add(demo_class) - db.session.flush() # 获得 class ID - - # 4. 创建学生账号 - s1 = User( - student_id='demo_s_001', - username='student_demo_good', - usertype='学生', - full_name='赵一(优秀)', - class_id=demo_class.id, - class_name=demo_class.name, - user_ascore=4.8, - submit_count=12 - ) - s1.password = '123456' - - s2 = User( - student_id='demo_s_002', - username='student_demo_mid', - usertype='学生', - full_name='钱二(中等)', - class_id=demo_class.id, - class_name=demo_class.name, - user_ascore=3.5, - submit_count=7 - ) - s2.password = '123456' - - s3 = User( - student_id='demo_s_003', - username='student_demo_risk', - usertype='学生', - full_name='孙三(风险)', - class_id=demo_class.id, - class_name=demo_class.name, - user_ascore=1.8, - submit_count=2 - ) - s3.password = '123456' - db.session.add_all([s1, s2, s3]) - - # 5. 创建花名册(含未注册的李四) - r1 = StudentRoster(student_id='demo_s_001', full_name='赵一(优秀)', class_id=demo_class.id, class_name_snapshot=demo_class.name, is_registered=True, registered_user_id='demo_s_001') - r2 = StudentRoster(student_id='demo_s_002', full_name='钱二(中等)', class_id=demo_class.id, class_name_snapshot=demo_class.name, is_registered=True, registered_user_id='demo_s_002') - r3 = StudentRoster(student_id='demo_s_003', full_name='孙三(风险)', class_id=demo_class.id, class_name_snapshot=demo_class.name, is_registered=True, registered_user_id='demo_s_003') - r4 = StudentRoster(student_id='demo_s_004', full_name='李四(未注册)', class_id=demo_class.id, class_name_snapshot=demo_class.name, is_registered=False) - db.session.add_all([r1, r2, r3, r4]) - - # 6. 创建测试作业 - a1 = Assignment( - title='演示作业一:循环与斐波那契数列', - description='使用循环计算斐波那契数列的前 N 项,并进行复杂度分析。', - target_classes='软件工程24-演示班', - difficulty_level=2, - creator_id='demo_t_001' - ) - a2 = Assignment( - title='演示作业二:二叉树遍历与归并算法', - description='实现二叉树的中序和后续遍历算法,并将其归并输出。', - target_classes='软件工程24-演示班', - difficulty_level=4, - creator_id='demo_t_001' - ) - db.session.add_all([a1, a2]) - db.session.flush() # 获得 assignment ID - - # 7. 绑定作业与知识点关系 - kp1 = AssignmentKnowledgePoint(assignment_id=a1.id, knowledge_point='循环控制', weight=1.0, difficulty=2) - kp2 = AssignmentKnowledgePoint(assignment_id=a2.id, knowledge_point='二叉树', weight=1.0, difficulty=4) - db.session.add_all([kp1, kp2]) - - # 8. 填充学生各知识点得分状况以供图表渲染 - # 赵一(优秀) - kps_s1_1 = KnowledgePointScore(student_id='demo_s_001', knowledge_point='循环控制', score=95.0, total_attempts=5, correct_attempts=5, average_difficulty=2.0) - kps_s1_2 = KnowledgePointScore(student_id='demo_s_001', knowledge_point='二叉树', score=88.0, total_attempts=4, correct_attempts=3, average_difficulty=4.0) - # 钱二(中等) - kps_s2_1 = KnowledgePointScore(student_id='demo_s_002', knowledge_point='循环控制', score=78.0, total_attempts=6, correct_attempts=4, average_difficulty=2.0) - kps_s2_2 = KnowledgePointScore(student_id='demo_s_002', knowledge_point='二叉树', score=65.0, total_attempts=5, correct_attempts=2, average_difficulty=4.0) - # 孙三(风险) - kps_s3_1 = KnowledgePointScore(student_id='demo_s_003', knowledge_point='循环控制', score=42.0, total_attempts=3, correct_attempts=1, average_difficulty=2.0) - kps_s3_2 = KnowledgePointScore(student_id='demo_s_003', knowledge_point='二叉树', score=20.0, total_attempts=4, correct_attempts=0, average_difficulty=4.0) - db.session.add_all([kps_s1_1, kps_s1_2, kps_s2_1, kps_s2_2, kps_s3_1, kps_s3_2]) - - # 9. 创建答题提交记录 - sub1 = Submission(student_id='demo_s_001', assignment_id=a1.id, code='// Fibonacci solution\nint main() {}', score=100.0, status='accepted') - sub2 = Submission(student_id='demo_s_002', assignment_id=a1.id, code='// Mid solution\nint main() {}', score=80.0, status='accepted') - sub3 = Submission(student_id='demo_s_003', assignment_id=a1.id, code='// WA solution\nint main() {}', score=40.0, status='wrong_answer') - db.session.add_all([sub1, sub2, sub3]) - - # 10. 创建 Feynman 学习进度会话 - ts1 = ThinkingSession(student_id='demo_s_003', assignment_id=a1.id, current_stage=1, stage1_score=40.0, stage2_completed=False, stage3_completed=False, status='in_progress') - db.session.add(ts1) - - # 11. 预生成静态教师 AI 个性化建议以避免调大模型时网络延迟或没有 API Key 的尴尬 - sug = TeacherAISuggestion.get_or_create(class_id=demo_class.id, teacher_id='demo_t_001') - sug.status = 'completed' - sug.suggestion_markdown = """# 软件工程24-演示班 学情建议报告 - -## 📌 本周重点关注学生 -1. **孙三 (风险生)**:平均能力分仅 1.8,且最近提交由于“循环控制”逻辑错误得分仅为 40 分,急需点对点约谈。 -2. **李四 (未注册)**:花名册中的同学尚未注册系统,建议提醒其尽快注册账号。 - -## 📖 建议课堂讲解知识点 -1. **循环控制 (中等严重度)**:全班平均分 71.6,孙三在此概念上理解极其薄弱,建议课堂进行基础代码脚手架填空演示。 -2. **二叉树 (高难度)**:全班通过率偏低,属于普遍概念难点。 - -## 📝 建议补练作业 -- 针对知识点 **循环控制** 推荐:`演示作业一:循环与斐波那契数列` -""" - db.session.add(sug) - - db.session.commit() - flash('演示数据导入成功!预设了李老师(教师)、赵一(优秀生)、孙三(风险生)等账号。', 'success') + ensure_demo_experience() + flash('演示数据已准备好:学生可直接体验三阶段学习,教师可查看完整班级数据。', 'success') except Exception as e: db.session.rollback() - current_app.logger.error(f"装载演示数据失败: {str(e)}", exc_info=True) - flash(f'装载演示数据失败: {str(e)}', 'danger') - + current_app.logger.error(f'装载演示数据失败: {str(e)}', exc_info=True) + flash('演示数据准备失败,请稍后重试。', 'danger') return redirect(url_for('main.home')) diff --git a/routes/thinking.py b/routes/thinking.py index f08f68c..0444f41 100644 --- a/routes/thinking.py +++ b/routes/thinking.py @@ -18,6 +18,11 @@ student_agent_chat, student_agent_write_code, evaluate_feynman_code_fix, sanitize_response ) +from services.demo_experience import ( + DEMO_STUDENT_ID, + is_demo_guided_assignment, + is_demo_guided_session, +) thinking = Blueprint('thinking', __name__, url_prefix='/thinking') @@ -122,7 +127,11 @@ def arena(assignment_id): return render_template('thinking/arena.html', assignment=assignment, preset_status=preset_status, - existing_session=existing_session) + existing_session=existing_session, + is_demo_experience=( + current_user.student_id == DEMO_STUDENT_ID + and is_demo_guided_assignment(assignment) + )) # ============================================================ @@ -1295,16 +1304,13 @@ def _lazy_backfill_summary(preset: AssignmentThinkingPreset): @thinking.route('/api/debug/jump_stage', methods=['POST']) -@login_required def debug_jump_stage(): - """开发者调试模式:快速跳过或跳转阶段""" - # 限制仅在开发环境(本地运行或 Flask DEBUG 模式)允许访问 - is_local = request.host.startswith('localhost') or request.host.startswith('127.0.0.1') - if not (current_app.debug or is_local): - return jsonify({'error': '非开发环境,拒绝访问该调试接口'}), 403 - + """开发者调试模式及公开演示体验的阶段快捷入口。""" try: - data = request.get_json() + if not current_user.is_authenticated: + return jsonify({'error': '请先登录'}), 403 + + data = request.get_json(silent=True) or {} session_id = data.get('session_id') target_stage = data.get('stage') @@ -1312,6 +1318,11 @@ def debug_jump_stage(): if not ts or ts.student_id != current_user.student_id: return jsonify({'error': '会话不存在'}), 403 + is_local = request.host.startswith('localhost') or request.host.startswith('127.0.0.1') + demo_allowed = is_demo_guided_session(ts) + if not (current_app.debug or is_local or demo_allowed): + return jsonify({'error': '非开发环境,拒绝访问该调试接口'}), 403 + if target_stage == 1: ts.current_stage = 1 ts.stage1_score = None diff --git a/services/demo_experience.py b/services/demo_experience.py new file mode 100644 index 0000000..6d9d7bf --- /dev/null +++ b/services/demo_experience.py @@ -0,0 +1,597 @@ +"""面向公开体验入口的稳定演示数据。 + +演示数据不是一次性测试夹具:公开体验入口、教师看板和学生思维竞技场都依赖同一组 +记录。因此这里采用“按业务唯一键补齐”的方式,重复调用只会补缺,不会删除学生在 +体验过程中产生的会话、提交或日志。 +""" + +import json +from dataclasses import dataclass +from datetime import datetime as dt + +from models import ( + AbilityTrend, + Assignment, + AssignmentKnowledgePoint, + AssignmentThinkingPreset, + Class, + KnowledgePointScore, + StudentRoster, + Submission, + TeacherAISuggestion, + TestCase, + User, + db, +) + + +DEMO_TEACHER_ID = 'demo_t_001' +DEMO_TEACHER_USERNAME = 'teacher_demo' +DEMO_TEACHER_PASSWORD = '123456' +DEMO_STUDENT_ID = 'demo_s_001' +DEMO_STUDENT_USERNAME = 'student_demo_good' +DEMO_STUDENT_PASSWORD = '123456' +DEMO_CLASS_NAME = '软件工程24-演示班' +DEMO_ASSIGNMENT_TITLE = '演示作业一:循环与斐波那契数列' +DEMO_SECOND_ASSIGNMENT_TITLE = '演示作业二:二叉树遍历与归并算法' + + +@dataclass(frozen=True) +class DemoExperience: + """初始化后的演示体验关键记录。""" + + teacher_id: str + student_id: str + class_id: int + assignment_id: int + + +def _json(value): + return json.dumps(value, ensure_ascii=False) + + +def _ensure_user(student_id, username, usertype, full_name, password, **attrs): + user = User.query.filter_by(student_id=student_id).first() + is_new = user is None + if not user: + user = User( + student_id=student_id, + username=username, + usertype=usertype, + full_name=full_name, + ) + db.session.add(user) + + # 演示账号的登录凭据需要始终可用,但不触碰提交、能力分析等体验状态。 + user.username = username + user.usertype = usertype + user.full_name = full_name + user.password = password + for key, value in attrs.items(): + if value is not None and (is_new or getattr(user, key, None) in (None, '')): + setattr(user, key, value) + return user + + +def _set_unique_email(user, email): + if not email: + return + owner = User.query.filter(User.email == email, User.student_id != user.student_id).first() + if not owner: + user.email = email + + +def _ensure_class(teacher): + demo_class = Class.query.filter_by(name=DEMO_CLASS_NAME).first() + if not demo_class: + demo_class = Class(name=DEMO_CLASS_NAME) + db.session.add(demo_class) + demo_class.school = '酷森思大学' + demo_class.college = '计算机学院' + demo_class.major = '软件工程' + demo_class.grade = '2024' + demo_class.teacher_id = teacher.student_id + db.session.flush() + return demo_class + + +def _ensure_roster(student_id, full_name, demo_class, registered_user_id=None): + roster = StudentRoster.query.filter_by(student_id=student_id).first() + if not roster: + roster = StudentRoster(student_id=student_id, full_name=full_name) + db.session.add(roster) + roster.full_name = full_name + roster.class_id = demo_class.id + roster.class_name_snapshot = demo_class.name + roster.imported_by = DEMO_TEACHER_ID + roster.is_registered = registered_user_id is not None + roster.registered_user_id = registered_user_id + return roster + + +def _ensure_assignment(title, description, difficulty, demo_class): + assignment = Assignment.query.filter_by( + title=title, + creator_id=DEMO_TEACHER_ID, + ).first() + if not assignment: + assignment = Assignment(title=title, creator_id=DEMO_TEACHER_ID) + db.session.add(assignment) + assignment.description = description + assignment.target_classes = demo_class.name + assignment.difficulty_level = difficulty + return assignment + + +def _ensure_assignment_knowledge(assignment, knowledge_point, weight, difficulty): + record = AssignmentKnowledgePoint.query.filter_by( + assignment_id=assignment.id, + knowledge_point=knowledge_point, + ).first() + if not record: + record = AssignmentKnowledgePoint( + assignment_id=assignment.id, + knowledge_point=knowledge_point, + ) + db.session.add(record) + record.weight = weight + record.difficulty = difficulty + record.auto_detected = False + + +def _ensure_test_case(assignment, input_data, expected_output, is_public, order_index): + test_case = TestCase.query.filter_by( + assignment_id=assignment.id, + input_data=input_data, + expected_output=expected_output, + ).first() + if not test_case: + test_case = TestCase( + assignment_id=assignment.id, + input_data=input_data, + expected_output=expected_output, + ) + db.session.add(test_case) + test_case.is_public = is_public + test_case.order_index = order_index + + +def _ensure_submission(student_id, assignment_id, code, score, status, feedback, ai_feedback, + sandbox_status, sandbox_passed, sandbox_total, submitted_at=None): + submission = Submission.query.filter_by( + student_id=student_id, + assignment_id=assignment_id, + ).order_by(Submission.id.asc()).first() + if not submission: + submission = Submission( + student_id=student_id, + assignment_id=assignment_id, + code=code, + ) + db.session.add(submission) + # 只维护演示样例提交的内容;后续用户新增提交会获得自己的新记录。 + submission.code = code + submission.score = score + submission.status = status + submission.feedback = feedback + submission.ai_feedback = ai_feedback + submission.sandbox_status = sandbox_status + submission.sandbox_passed = sandbox_passed + submission.sandbox_total = sandbox_total + if submitted_at and not submission.submitted_at: + submission.submitted_at = submitted_at + return submission + + +def _ensure_knowledge_score(student_id, knowledge_point, score, attempts, correct, difficulty): + record = KnowledgePointScore.query.filter_by( + student_id=student_id, + knowledge_point=knowledge_point, + ).first() + if not record: + record = KnowledgePointScore( + student_id=student_id, + knowledge_point=knowledge_point, + ) + db.session.add(record) + record.score = score + record.total_attempts = attempts + record.correct_attempts = correct + record.average_difficulty = difficulty + if not record.last_updated: + record.last_updated = dt.utcnow() + return record + + +def _ensure_preset(assignment): + key_steps = [ + '先读入 N,并明确需要输出前 N 项斐波那契数列。', + '用两个变量保存相邻的两个数,循环中根据前两项得到下一项。', + '每次得到新项后更新两个变量并输出,注意 N 为 0 或 1 的边界。', + ] + code_blocks = [ + { + 'id': 'fib-include', + 'code': '#include \n#include ', + 'label': '引入输入输出与动态数组', + 'indent': 0, + 'phase': 1, + 'part_name': '主程序', + 'part_header': '', + 'part_footer': '', + }, + { + 'id': 'fib-main', + 'code': 'int main() {', + 'label': '定义主函数', + 'indent': 0, + 'phase': 1, + 'part_name': '主程序', + 'part_header': '', + 'part_footer': ' return 0;\n}', + }, + { + 'id': 'fib-input', + 'code': 'int n; std::cin >> n;\nstd::vector fib(n);', + 'label': '读入 N 并准备存储空间', + 'indent': 1, + 'phase': 1, + 'part_name': '主程序', + 'part_header': 'int main() {', + 'part_footer': ' return 0;\n}', + }, + { + 'id': 'fib-base', + 'code': 'if (n > 0) fib[0] = 0;\nif (n > 1) fib[1] = 1;', + 'label': '处理前两项和边界情况', + 'indent': 1, + 'phase': 1, + 'part_name': '主程序', + 'part_header': 'int main() {', + 'part_footer': ' return 0;\n}', + }, + { + 'id': 'fib-loop', + 'code': 'for (int i = 2; i < n; ++i) {\n fib[i] = fib[i - 1] + fib[i - 2];\n}', + 'label': '循环计算当前项:前两项相加', + 'indent': 1, + 'phase': 2, + 'part_name': '主程序', + 'part_header': 'int main() {', + 'part_footer': ' return 0;\n}', + }, + { + 'id': 'fib-output', + 'code': 'for (int i = 0; i < n; ++i) {\n if (i) std::cout << " ";\n std::cout << fib[i];\n}', + 'label': '按顺序输出结果', + 'indent': 1, + 'phase': 2, + 'part_name': '主程序', + 'part_header': 'int main() {', + 'part_footer': ' return 0;\n}', + }, + ] + noise_blocks = [ + { + 'id': 'noise-fib-sort', + 'code': 'std::sort(fib.begin(), fib.end());', + 'label': '先排序再输出(干扰项)', + 'indent': 1, + 'phase': 2, + 'part_name': '主程序', + 'part_header': 'int main() {', + 'part_footer': ' return 0;\n}', + }, + ] + quiz_steps = [ + { + 'step_id': 1, + 'part_name': '主程序', + 'type': 'choice', + 'question': '当 N 大于 1 时,第 i 项应由哪两项计算得到?', + 'options': ['fib[i - 1] + fib[i - 2]', 'fib[i] + fib[i + 1]', 'fib[i - 1] * 2'], + 'correct_answer': 'fib[i - 1] + fib[i - 2]', + 'explanation': '斐波那契数列的当前项等于前两项之和。', + }, + { + 'step_id': 2, + 'part_name': '主程序', + 'type': 'fill', + 'question': '补全循环条件,确保从第三项计算到第 N 项。', + 'context_before': 'for (int i = 2; i <', + 'context_after': '; ++i) { ... }', + 'blank_hint': '输入循环上界', + 'correct_answer': 'n', + 'code_line': 'for (int i = 2; i < n; ++i) {', + 'indent': 1, + 'explanation': 'i 从 2 开始,直到 i 小于 n,正好覆盖剩余项。', + }, + { + 'step_id': 3, + 'part_name': '主程序', + 'type': 'choice', + 'question': '为什么要先判断 N 是否大于 0 和 1?', + 'options': ['避免访问不存在的数组位置', '为了让排序更快', '因为循环不能使用整数'], + 'correct_answer': '避免访问不存在的数组位置', + 'explanation': 'N 为 0 或 1 时,数组中可用的位置不同,需要先处理边界。', + }, + ] + difficulty_config = { + 'feynman_rounds': 2, + 'student_persona': 'curious', + 'guided_questions': [ + '如果 N 等于 0,程序应该输出什么?', + '循环从第几项开始,为什么?', + '如何保证输出顺序与数列顺序一致?', + ], + } + reference_code = """#include +#include + +int main() { + int n; + std::cin >> n; + std::vector fib(n); + if (n > 0) fib[0] = 0; + if (n > 1) fib[1] = 1; + for (int i = 2; i < n; ++i) { + fib[i] = fib[i - 1] + fib[i - 2]; + } + for (int i = 0; i < n; ++i) { + if (i) std::cout << ' '; + std::cout << fib[i]; + } + return 0; +}""" + + preset = AssignmentThinkingPreset.query.filter_by(assignment_id=assignment.id).first() + if not preset: + preset = AssignmentThinkingPreset(assignment_id=assignment.id) + db.session.add(preset) + preset.reference_code = reference_code + preset.key_steps = _json(key_steps) + preset.code_blocks = _json(code_blocks) + preset.noise_blocks = _json(noise_blocks) + preset.quiz_steps = _json(quiz_steps) + preset.difficulty_config = _json(difficulty_config) + preset.algorithm_summary = ( + '算法流程:先读取 N 并处理 N 为 0 或 1 的边界;然后从第三项开始,' + '用前两项之和计算当前项;最后按下标顺序输出全部结果。' + ) + preset.status = 'ready' + preset.error_message = None + return preset + + +def _ensure_trend(student_id): + trend_data = { + 'trend': '基础算法理解较稳定,循环控制与边界处理表现较好。', + 'improvement': '下一步可尝试减少额外存储,并解释空间复杂度。', + 'suggestions': [ + '继续练习循环不变量和边界条件。', + '尝试用 O(1) 额外空间保存相邻两项。', + '完成演示作业二,巩固树遍历思路。', + ], + } + trend = AbilityTrend.query.filter_by(student_id=student_id).first() + if not trend: + trend = AbilityTrend(student_id=student_id) + db.session.add(trend) + if not trend.analysis_markdown: + trend.analysis_markdown = ( + '## 能力概览\n\n' + '你已经能够把题目拆成“输入、循环计算、输出”三个模块。\n\n' + '## 下一步建议\n\n' + '- 继续关注 `N=0`、`N=1` 等边界情况。\n' + '- 尝试比较数组方案与滚动变量方案的空间复杂度。' + ) + if not trend.trend_data: + trend.trend_data = _json(trend_data) + trend.submissions_count = max(trend.submissions_count or 0, 12) + if trend.status in (None, 'pending', 'processing', 'outdated', 'failed'): + trend.status = 'completed' + if not trend.last_updated: + trend.last_updated = dt.utcnow() + return trend + + +def _ensure_teacher_suggestion(demo_class): + suggestion = TeacherAISuggestion.query.filter_by(class_id=demo_class.id).first() + if not suggestion: + suggestion = TeacherAISuggestion( + class_id=demo_class.id, + teacher_id=DEMO_TEACHER_ID, + ) + db.session.add(suggestion) + suggestion.teacher_id = DEMO_TEACHER_ID + suggestion.status = 'completed' + suggestion.suggestion_markdown = """# 软件工程24-演示班学情建议 + +## 需要优先关注 + +- **孙三(风险)**:循环控制与二叉树知识点得分偏低,最近一次提交未通过全部测试。 +- **李四(未注册)**:已在花名册中,但还没有登录系统,建议提醒完成注册。 + +## 课堂建议 + +1. 用斐波那契作业演示边界条件和循环不变量。 +2. 让学生比较数组方案与滚动变量方案的空间复杂度。 +3. 下一次课安排一次二叉树遍历的分步练习。 +""" + suggestion.suggestion_json = _json({ + 'focus_students': [ + {'student_id': 'demo_s_003', 'name': '孙三(风险)', 'reason': '循环控制与二叉树得分偏低'}, + {'student_id': 'demo_s_004', 'name': '李四(未注册)', 'reason': '花名册账号尚未注册'}, + ], + 'weak_knowledge_points': ['循环控制', '二叉树'], + 'recommended_assignments': [DEMO_ASSIGNMENT_TITLE], + }) + suggestion.last_updated = suggestion.last_updated or dt.utcnow() + return suggestion + + +def _refresh_assignment_stats(assignment): + submissions = Submission.query.filter_by(assignment_id=assignment.id).all() + scores = [submission.score for submission in submissions if submission.score is not None] + assignment.count = len(submissions) + assignment.total_score = 100 + assignment.average_score = round(sum(scores) / len(scores), 1) if scores else 0.0 + + +def ensure_demo_experience(): + """补齐公开体验所需数据,并返回固定入口信息。 + + 所有写入发生在当前 SQLAlchemy 会话中,最后只提交一次;异常由调用方处理并回滚。 + 既有体验会话、提交、日志不会被删除或重置。 + """ + teacher = _ensure_user( + DEMO_TEACHER_ID, + DEMO_TEACHER_USERNAME, + '教师', + '李老师(演示)', + DEMO_TEACHER_PASSWORD, + ) + _set_unique_email(teacher, 'teacher_demo@codesense.edu') + demo_class = _ensure_class(teacher) + + student = _ensure_user( + DEMO_STUDENT_ID, + DEMO_STUDENT_USERNAME, + '学生', + '赵一(优秀)', + DEMO_STUDENT_PASSWORD, + class_id=demo_class.id, + class_name=demo_class.name, + user_ascore=4.8, + submit_count=12, + ) + _set_unique_email(student, 'student_demo_good@codesense.edu') + + middle_student = _ensure_user( + 'demo_s_002', + 'student_demo_mid', + '学生', + '钱二(中等)', + DEMO_STUDENT_PASSWORD, + class_id=demo_class.id, + class_name=demo_class.name, + user_ascore=3.5, + submit_count=7, + ) + risk_student = _ensure_user( + 'demo_s_003', + 'student_demo_risk', + '学生', + '孙三(风险)', + DEMO_STUDENT_PASSWORD, + class_id=demo_class.id, + class_name=demo_class.name, + user_ascore=1.8, + submit_count=2, + ) + db.session.flush() + + _ensure_roster(DEMO_STUDENT_ID, student.full_name, demo_class, DEMO_STUDENT_ID) + _ensure_roster('demo_s_002', middle_student.full_name, demo_class, 'demo_s_002') + _ensure_roster('demo_s_003', risk_student.full_name, demo_class, 'demo_s_003') + _ensure_roster('demo_s_004', '李四(未注册)', demo_class) + + guided_assignment = _ensure_assignment( + DEMO_ASSIGNMENT_TITLE, + '使用循环计算斐波那契数列的前 N 项,并说明边界条件与空间复杂度。', + 2, + demo_class, + ) + second_assignment = _ensure_assignment( + DEMO_SECOND_ASSIGNMENT_TITLE, + '实现二叉树的中序遍历与归并输出,比较递归和迭代写法的差异。', + 4, + demo_class, + ) + db.session.flush() + + _ensure_assignment_knowledge(guided_assignment, '循环控制', 1.0, 2.0) + _ensure_assignment_knowledge(guided_assignment, '边界条件', 0.8, 2.0) + _ensure_assignment_knowledge(second_assignment, '二叉树', 1.0, 4.0) + _ensure_assignment_knowledge(second_assignment, '递归', 0.8, 4.0) + + _ensure_test_case(guided_assignment, '5', '0 1 1 2 3', True, 1) + _ensure_test_case(guided_assignment, '8', '0 1 1 2 3 5 8 13', False, 2) + _ensure_test_case(guided_assignment, '0', '', True, 3) + + for student_id, scores in { + DEMO_STUDENT_ID: (95.0, 88.0), + 'demo_s_002': (78.0, 65.0), + 'demo_s_003': (42.0, 20.0), + }.items(): + _ensure_knowledge_score(student_id, '循环控制', scores[0], 5, 5 if scores[0] > 90 else 3, 2.0) + _ensure_knowledge_score(student_id, '二叉树', scores[1], 4, 3 if scores[1] > 80 else 1, 4.0) + + _ensure_submission( + DEMO_STUDENT_ID, + guided_assignment.id, + '// 演示学生:完整的 Fibonacci 解法\nint main() { return 0; }', + 100, + 'accepted', + '已通过全部样例测试,边界条件处理清晰。', + '思路完整,建议继续关注空间复杂度优化。', + 'passed', + 3, + 3, + ) + _ensure_submission( + 'demo_s_002', + guided_assignment.id, + '// 演示学生:基本循环解法\nint main() { return 0; }', + 80, + 'accepted', + '主要逻辑正确,边界处理仍可加强。', + '建议检查 N 为 0 和 1 时的行为。', + 'partial', + 2, + 3, + ) + _ensure_submission( + 'demo_s_003', + guided_assignment.id, + '// 演示学生:尚未完成边界处理\nint main() { return 0; }', + 40, + 'wrong_answer', + '循环主体方向正确,但没有覆盖全部边界情况。', + '建议先画出 N=0、N=1、N=2 的执行过程。', + 'failed', + 1, + 3, + ) + _refresh_assignment_stats(guided_assignment) + _refresh_assignment_stats(second_assignment) + + _ensure_trend(DEMO_STUDENT_ID) + _ensure_teacher_suggestion(demo_class) + _ensure_preset(guided_assignment) + + db.session.commit() + return DemoExperience( + teacher_id=teacher.student_id, + student_id=student.student_id, + class_id=demo_class.id, + assignment_id=guided_assignment.id, + ) + + +def is_demo_guided_session(thinking_session): + """判断会话是否属于公开演示学生的共享引导作业。""" + if not thinking_session or thinking_session.student_id != DEMO_STUDENT_ID: + return False + assignment = Assignment.query.get(thinking_session.assignment_id) + return is_demo_guided_assignment(assignment) + + +def is_demo_guided_assignment(assignment): + """判断作业是否属于公开演示的三阶段引导作业。""" + return bool( + assignment + and assignment.title == DEMO_ASSIGNMENT_TITLE + and assignment.creator_id == DEMO_TEACHER_ID + and DEMO_CLASS_NAME in assignment.get_target_class_list() + ) diff --git a/static/js/thinking.js b/static/js/thinking.js index 26610c6..8dc6927 100644 --- a/static/js/thinking.js +++ b/static/js/thinking.js @@ -1328,13 +1328,20 @@ // ============================================================ function initDevDebugConsole() { const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; - if (!isLocal) return; + const container = document.getElementById('arena-container'); + const isDemo = container && container.dataset.demoExperience === '1'; + if (!isLocal && !isDemo) return; + + const panelTitle = isDemo ? '体验进度快捷入口' : '开发者调试面板 (Dev Only)'; + const panelDescription = isDemo + ? '按需查看三个学习阶段的页面效果,完成体验后可重新回到任意阶段。' + : '快速进行阶段流转及自动化测试'; const panel = document.createElement('div'); panel.className = 'dev-debug-panel'; panel.innerHTML = ` -

开发者调试面板 (Dev Only)

-
快速进行阶段流转及自动化测试
+

${panelTitle}

+
${panelDescription}
@@ -1342,10 +1349,13 @@
- - + +
`; + if (isDemo) { + panel.querySelectorAll('.dev-debug-auto').forEach(button => button.remove()); + } document.body.appendChild(panel); } @@ -1364,7 +1374,14 @@ }).then(data => { if (data.success) { showNotification(`已切换到阶段 ${stage === 4 ? '已完成' : stage}`, 'success'); - setTimeout(() => location.reload(), 1000); + const arena = document.getElementById('arena-container'); + const isDemo = arena && arena.dataset.demoExperience === '1'; + if (stage === 4 && isDemo) { + state.currentStage = 3; + showCelebration(); + } else { + setTimeout(() => location.reload(), 1000); + } } else { showNotification(data.error || '跳转失败', 'warning'); } diff --git a/templates/login.html b/templates/login.html index 91e16a5..16ecf5d 100644 --- a/templates/login.html +++ b/templates/login.html @@ -446,6 +446,62 @@ text-decoration: underline; } + .demo-entry { + margin-top: 1.75rem; + padding: 1rem; + border: 1px solid #dbeafe; + border-radius: 12px; + background: #f8fbff; + } + + .demo-entry-title { + margin-bottom: 0.35rem; + color: #0f172a; + font-size: 0.95rem; + font-weight: 700; + } + + .demo-entry-copy { + margin-bottom: 0.8rem; + color: #64748b; + font-size: 0.82rem; + line-height: 1.5; + } + + .demo-entry-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem; + } + + .demo-entry-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 2.4rem; + padding: 0.45rem 0.6rem; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #fff; + color: #1d4ed8; + font-size: 0.82rem; + font-weight: 600; + text-decoration: none; + transition: background 0.2s ease, border-color 0.2s ease; + } + + .demo-entry-link:hover { + border-color: #60a5fa; + background: #eff6ff; + } + + @media (max-width: 400px) { + .demo-entry-actions { + grid-template-columns: 1fr; + } + } + /* 底部 */ .footer { display: flex; @@ -583,6 +639,19 @@ +
+
先体验一下
+

无需注册,也不用填写账号。选择一个身份,直接查看完整的学习或教学流程。

+
+ + 学生体验 + + + 教师体验 + +
+
+
还没有账号? 立即注册
diff --git a/templates/thinking/arena.html b/templates/thinking/arena.html index 6082ef7..dc80c1a 100644 --- a/templates/thinking/arena.html +++ b/templates/thinking/arena.html @@ -20,7 +20,7 @@ {% endblock %} {% block content %} -
+
diff --git a/tests/demo_test_utils.py b/tests/demo_test_utils.py new file mode 100644 index 0000000..f3da04f --- /dev/null +++ b/tests/demo_test_utils.py @@ -0,0 +1,35 @@ +"""测试用的 Flask 应用与临时 SQLite 数据库工具。""" + +import os +import tempfile + +from app import create_app +from models import db + + +def create_test_app(): + """创建一个隔离的测试应用,并初始化全部数据库表。""" + db_fd, db_path = tempfile.mkstemp() + app = create_app('testing') + app.config.update( + SQLALCHEMY_DATABASE_URI=f'sqlite:///{db_path}', + TESTING=True, + WTF_CSRF_ENABLED=False, + ) + + with app.app_context(): + db.drop_all() + db.create_all() + + app._demo_test_db_fd = db_fd + app._demo_test_db_path = db_path + return app + + +def destroy_test_app(app): + """释放测试应用占用的临时数据库文件。""" + with app.app_context(): + db.session.remove() + db.drop_all() + os.close(app._demo_test_db_fd) + os.unlink(app._demo_test_db_path) diff --git a/tests/test_demo_experience.py b/tests/test_demo_experience.py new file mode 100644 index 0000000..9afd8a4 --- /dev/null +++ b/tests/test_demo_experience.py @@ -0,0 +1,87 @@ +import json +import unittest + +from models import ( + AbilityTrend, + Assignment, + AssignmentThinkingPreset, + Class, + Submission, + ThinkingSession, + TestCase as AssignmentTestCase, + TeacherAISuggestion, + User, + db, +) +from services.demo_experience import ( + DEMO_ASSIGNMENT_TITLE, + DEMO_CLASS_NAME, + DEMO_STUDENT_ID, + DEMO_TEACHER_ID, + ensure_demo_experience, +) +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoExperienceTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + + def tearDown(self): + destroy_test_app(self.app) + + def test_seed_is_complete_and_idempotent(self): + with self.app.app_context(): + first = ensure_demo_experience() + first_session = ThinkingSession( + student_id=DEMO_STUDENT_ID, + assignment_id=first.assignment_id, + current_stage=2, + stage1_description='我已经完成了循环分析。', + ) + first_submission = Submission( + student_id=DEMO_STUDENT_ID, + assignment_id=first.assignment_id, + code='int main() { return 0; }', + score=88, + status='evaluated', + ) + db.session.add_all([first_session, first_submission]) + db.session.commit() + session_id = first_session.id + submission_id = first_submission.id + + second = ensure_demo_experience() + + self.assertEqual(first.teacher_id, DEMO_TEACHER_ID) + self.assertEqual(first.student_id, DEMO_STUDENT_ID) + self.assertEqual(first.class_id, second.class_id) + self.assertEqual(first.assignment_id, second.assignment_id) + self.assertEqual(User.query.filter_by(student_id=DEMO_TEACHER_ID).count(), 1) + self.assertEqual(User.query.filter_by(student_id=DEMO_STUDENT_ID).count(), 1) + self.assertEqual(Class.query.filter_by(name=DEMO_CLASS_NAME).count(), 1) + self.assertEqual(Assignment.query.filter_by(title=DEMO_ASSIGNMENT_TITLE).count(), 1) + self.assertIsNotNone(ThinkingSession.query.get(session_id)) + self.assertIsNotNone(Submission.query.get(submission_id)) + + assignment = Assignment.query.get(first.assignment_id) + self.assertGreaterEqual(AssignmentTestCase.query.filter_by(assignment_id=assignment.id).count(), 2) + preset = AssignmentThinkingPreset.query.filter_by(assignment_id=assignment.id).one() + self.assertEqual(preset.status, 'ready') + self.assertTrue(preset.reference_code) + self.assertTrue(json.loads(preset.key_steps)) + self.assertTrue(json.loads(preset.code_blocks)) + self.assertTrue(json.loads(preset.quiz_steps)) + + trend = AbilityTrend.query.filter_by(student_id=DEMO_STUDENT_ID).one() + self.assertEqual(trend.status, 'completed') + self.assertTrue(trend.analysis_markdown) + self.assertTrue(json.loads(trend.trend_data)) + + suggestion = TeacherAISuggestion.query.filter_by(class_id=first.class_id).one() + self.assertEqual(suggestion.status, 'completed') + self.assertIn('演示', suggestion.suggestion_markdown) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_demo_guided_learning.py b/tests/test_demo_guided_learning.py new file mode 100644 index 0000000..702203a --- /dev/null +++ b/tests/test_demo_guided_learning.py @@ -0,0 +1,176 @@ +import json +import unittest +from pathlib import Path + +from models import Assignment, AssignmentThinkingPreset, ThinkingSession, User, db +from services.demo_experience import ensure_demo_experience +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoGuidedLearningTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + self.client = self.app.test_client() + with self.app.app_context(): + self.demo = ensure_demo_experience() + teacher = User( + student_id='regular_teacher', + username='regular_teacher', + usertype='教师', + full_name='普通教师', + ) + teacher.password = 'password' + student = User( + student_id='regular_student', + username='regular_student', + usertype='学生', + full_name='普通学生', + class_name='普通班级', + ) + student.password = 'password' + regular_assignment = Assignment( + title='普通作业', + description='普通作业描述', + creator_id='regular_teacher', + target_classes='普通班级', + ) + db.session.add_all([teacher, student, regular_assignment]) + db.session.flush() + db.session.add(AssignmentThinkingPreset( + assignment_id=regular_assignment.id, + reference_code='int main() { return 0; }', + key_steps=json.dumps(['完成输入、处理和输出'], ensure_ascii=False), + code_blocks=json.dumps([{'id': 'regular-1', 'code': 'return 0;'}]), + noise_blocks='[]', + quiz_steps=json.dumps([{ + 'step_id': 1, + 'type': 'fill', + 'question': '填写返回值', + 'correct_answer': '0', + }]), + difficulty_config=json.dumps({'feynman_rounds': 2}), + status='ready', + )) + db.session.commit() + self.regular_assignment_id = regular_assignment.id + + def tearDown(self): + destroy_test_app(self.app) + + def test_demo_student_arena_has_demo_marker(self): + login_response = self.client.get('/demo-login/student') + arena_response = self.client.get(login_response.headers['Location']) + + self.assertEqual(arena_response.status_code, 200) + self.assertIn('data-demo-experience="1"'.encode('utf-8'), arena_response.data) + self.assertIn('演示作业一:循环与斐波那契数列'.encode('utf-8'), arena_response.data) + + def test_demo_start_session_returns_all_three_stage_preset_data(self): + self.client.get('/demo-login/student') + response = self.client.post('/thinking/api/start_session', json={ + 'assignment_id': self.demo.assignment_id, + }) + + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertTrue(payload['success']) + self.assertEqual(payload['preset']['status'], 'ready') + self.assertGreaterEqual(len(payload['preset']['key_steps']), 3) + self.assertGreaterEqual(len(payload['preset']['blocks']), 6) + self.assertGreaterEqual(len(payload['preset']['quiz_steps']), 3) + self.assertTrue(payload['preset']['algorithm_summary']) + self.assertEqual(payload['preset']['difficulty']['feynman_rounds'], 2) + + def test_regular_student_arena_has_no_demo_marker(self): + self.client.post('/login', data={ + 'username': 'regular_student', + 'password': 'password', + }) + response = self.client.get(f'/thinking/{self.regular_assignment_id}') + + self.assertEqual(response.status_code, 200) + self.assertIn('data-demo-experience="0"'.encode('utf-8'), response.data) + + def test_frontend_exposes_four_demo_stage_shortcuts_but_keeps_auto_actions_local(self): + source = Path('static/js/thinking.js').read_text(encoding='utf-8') + + self.assertIn("dataset.demoExperience === '1'", source) + self.assertIn('if (!isLocal && !isDemo) return;', source) + for stage in (1, 2, 3, 4): + self.assertIn(f'window.ThinkingArena.debugJumpStage({stage})', source) + self.assertIn('isDemo ?', source) + self.assertIn('stage === 4 && isDemo', source) + + def test_public_demo_shortcuts_can_move_shared_session_through_all_stages(self): + base_url = 'https://experience.codesense.test' + self.client.get('/demo-login/student', base_url=base_url) + with self.app.app_context(): + shared_session = ThinkingSession( + student_id='demo_s_001', + assignment_id=self.demo.assignment_id, + ) + db.session.add(shared_session) + db.session.commit() + session_id = shared_session.id + + for stage in (1, 2, 3, 4): + response = self.client.post( + '/thinking/api/debug/jump_stage', + base_url=base_url, + json={'session_id': session_id, 'stage': stage}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.get_json()['success']) + + with self.app.app_context(): + updated = ThinkingSession.query.get(session_id) + self.assertEqual(updated.status, 'completed') + self.assertTrue(updated.stage3_completed) + + def test_public_shortcut_rejects_regular_student_other_assignment_and_anonymous(self): + base_url = 'https://experience.codesense.test' + with self.app.app_context(): + regular_session = ThinkingSession( + student_id='regular_student', + assignment_id=self.demo.assignment_id, + ) + other_assignment_session = ThinkingSession( + student_id='demo_s_001', + assignment_id=self.regular_assignment_id, + ) + db.session.add_all([regular_session, other_assignment_session]) + db.session.commit() + regular_session_id = regular_session.id + other_assignment_session_id = other_assignment_session.id + + self.client.post('/login', base_url=base_url, data={ + 'username': 'regular_student', + 'password': 'password', + }) + regular_response = self.client.post( + '/thinking/api/debug/jump_stage', + base_url=base_url, + json={'session_id': regular_session_id, 'stage': 2}, + ) + self.assertEqual(regular_response.status_code, 403) + + self.client.get('/logout', base_url=base_url) + self.client.get('/demo-login/student', base_url=base_url) + other_assignment_response = self.client.post( + '/thinking/api/debug/jump_stage', + base_url=base_url, + json={'session_id': other_assignment_session_id, 'stage': 2}, + ) + self.assertEqual(other_assignment_response.status_code, 403) + + self.client.get('/logout', base_url=base_url) + anonymous_response = self.client.post( + '/thinking/api/debug/jump_stage', + base_url=base_url, + json={'session_id': regular_session_id, 'stage': 2}, + ) + self.assertEqual(anonymous_response.status_code, 403) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_demo_login.py b/tests/test_demo_login.py new file mode 100644 index 0000000..0a0a887 --- /dev/null +++ b/tests/test_demo_login.py @@ -0,0 +1,66 @@ +import unittest + +from services.demo_experience import ( + DEMO_STUDENT_ID, + DEMO_TEACHER_ID, + ensure_demo_experience, +) +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoLoginTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + with self.app.app_context(): + ensure_demo_experience() + self.client = self.app.test_client() + + def tearDown(self): + destroy_test_app(self.app) + + def test_student_demo_login_goes_to_guided_assignment(self): + response = self.client.get('/demo-login/student') + + self.assertEqual(response.status_code, 302) + self.assertIn('/thinking/', response.headers['Location']) + with self.client.session_transaction() as session: + self.assertEqual(session.get('student_id'), DEMO_STUDENT_ID) + self.assertEqual(session.get('usertype'), '学生') + + def test_teacher_demo_login_goes_to_home(self): + response = self.client.get('/demo-login/teacher') + + self.assertEqual(response.status_code, 302) + self.assertTrue(response.headers['Location'].endswith('/home')) + with self.client.session_transaction() as session: + self.assertEqual(session.get('student_id'), DEMO_TEACHER_ID) + self.assertEqual(session.get('usertype'), '教师') + + def test_public_demo_login_stays_available_outside_debug_mode(self): + self.app.config.update(DEBUG=False, TESTING=False) + + response = self.client.get('/demo-login/student') + + self.assertEqual(response.status_code, 302) + self.assertIn('/thinking/', response.headers['Location']) + + def test_invalid_demo_role_does_not_login(self): + self.client.get('/logout') + response = self.client.get('/demo-login/admin', follow_redirects=True) + + self.assertEqual(response.status_code, 200) + self.assertIn('该演示入口不可用'.encode('utf-8'), response.data) + with self.client.session_transaction() as session: + self.assertIsNone(session.get('student_id')) + + def test_login_page_exposes_public_experience_links_without_credentials(self): + response = self.client.get('/login') + + self.assertEqual(response.status_code, 200) + self.assertIn('/demo-login/student'.encode('utf-8'), response.data) + self.assertIn('/demo-login/teacher'.encode('utf-8'), response.data) + self.assertNotIn('123456'.encode('utf-8'), response.data) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_demo_teacher_experience.py b/tests/test_demo_teacher_experience.py new file mode 100644 index 0000000..8581726 --- /dev/null +++ b/tests/test_demo_teacher_experience.py @@ -0,0 +1,44 @@ +import unittest + +from models import Class +from services.demo_experience import DEMO_CLASS_NAME, ensure_demo_experience +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoTeacherExperienceTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + self.client = self.app.test_client() + with self.app.app_context(): + self.demo = ensure_demo_experience() + self.class_id = Class.query.filter_by(name=DEMO_CLASS_NAME).one().id + + def tearDown(self): + destroy_test_app(self.app) + + def test_teacher_can_browse_dashboard_class_detail_and_ai_suggestions(self): + login_response = self.client.get('/demo-login/teacher') + self.assertEqual(login_response.status_code, 302) + + dashboard = self.client.get('/home', follow_redirects=True) + class_detail = self.client.get(f'/classes/{self.class_id}') + suggestions = self.client.get('/teacher/ai_suggestions') + + self.assertEqual(dashboard.status_code, 200) + self.assertIn('软件工程24-演示班'.encode('utf-8'), dashboard.data) + self.assertIn('孙三(风险)'.encode('utf-8'), dashboard.data) + self.assertIn('演示作业一:循环与斐波那契数列'.encode('utf-8'), dashboard.data) + + self.assertEqual(class_detail.status_code, 200) + self.assertIn('钱二(中等)'.encode('utf-8'), class_detail.data) + self.assertIn('李四(未注册)'.encode('utf-8'), class_detail.data) + self.assertIn('演示作业一:循环与斐波那契数列'.encode('utf-8'), class_detail.data) + + self.assertEqual(suggestions.status_code, 200) + self.assertIn('学情建议'.encode('utf-8'), suggestions.data) + self.assertIn('边界条件'.encode('utf-8'), suggestions.data) + self.assertIn('孙三'.encode('utf-8'), suggestions.data) + + +if __name__ == '__main__': + unittest.main() From aa171fa1ec1ffe851411073a9f96dd5a708858c3 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 26 Aug 2026 12:52:23 +0800 Subject: [PATCH 05/12] feat: add per-session demo database lifecycle --- .../plans/2026-08-26-demo-session-database.md | 299 ++++++++++++++++++ ...2026-08-26-demo-session-database-design.md | 58 ++++ services/demo_database.py | 244 ++++++++++++++ tests/test_demo_database.py | 74 +++++ 4 files changed, 675 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-demo-session-database.md create mode 100644 docs/superpowers/specs/2026-08-26-demo-session-database-design.md create mode 100644 services/demo_database.py create mode 100644 tests/test_demo_database.py diff --git a/docs/superpowers/plans/2026-08-26-demo-session-database.md b/docs/superpowers/plans/2026-08-26-demo-session-database.md new file mode 100644 index 0000000..fd510a2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-demo-session-database.md @@ -0,0 +1,299 @@ +# Per-Session Demo Database Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为公开学生/教师体验创建每会话独立的临时 SQLite 数据库,支持真实 AI 分析和完整交互,同时保证正式数据库零写入。 + +**Architecture:** 使用随机 `demo_run_id` 关联专用临时 SQLite 文件。请求进入时在当前 Flask-SQLAlchemy scoped session 上绑定该临时引擎,因此既有模型查询、关系和大部分路由继续工作;后台线程显式携带 run id 并重新绑定。公开入口只创建临时库,退出、超时和启动清理负责释放并删除临时库。 + +**Tech Stack:** Flask, Flask-Login, Flask-SQLAlchemy, SQLAlchemy SQLite engine/session, Flask-Session, existing `AIEvaluator`/`SharedLLMClient`, pytest/unittest, Chart.js. + +**Spec:** `docs/superpowers/specs/2026-08-26-demo-session-database-design.md` + +## Global Constraints + +- 体验会话数据只能写入当前会话的临时 SQLite 数据库,正式数据库零写入。 +- 真实 AI 个性化分析必须调用现有 AI 客户端;失败时显示失败状态,不伪造成功结果。 +- 提交分数使用 0–5;知识点画像、五维能力和贝叶斯评估使用 0–100。 +- 正常账号、正常提交、正常后台任务和 RBAC 行为保持兼容。 +- 临时库必须在主动退出、空闲/最长生命周期清理和服务器启动清理后可删除。 +- 不提交 `static/uploads/` 中与本任务无关的既有未跟踪文件。 + +--- + +### Task 1: 建立临时库生命周期服务 + +**Files:** +- Create: `services/demo_database.py` +- Modify: `models.py` only if an engine/session metadata helper is required +- Test: `tests/test_demo_database.py` + +**Interfaces:** +- `create_demo_run(role: str) -> DemoRun` +- `activate_demo_run(run_id: str) -> bool` +- `current_demo_run_id() -> str | None` +- `destroy_demo_run(run_id: str) -> bool` +- `cleanup_expired_demo_runs(now: datetime | None = None) -> int` +- `is_demo_login_id(user_id: str) -> bool` +- `DemoRun` exposes `run_id`, `role`, `student_id`, `teacher_id`, `db_path`, `created_at`. + +- [ ] **Step 1: Write the failing lifecycle tests.** + + Assert that `create_demo_run('student')` creates a unique path below the dedicated temporary directory, creates all model tables, and leaves the application database unchanged. Assert that two runs have different paths and that `destroy_demo_run` removes a run file without removing the other run. + +- [ ] **Step 2: Run the lifecycle tests and confirm they fail because the service does not exist.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_database.py -q` + + Expected: collection/import failure for `services.demo_database` or missing lifecycle functions. + +- [ ] **Step 3: Implement the isolated engine/session lifecycle.** + + Use `secrets.token_hex(24)` for run ids, create `codesense-demo-runs` below the OS temporary directory, validate resolved paths before opening/deleting, call `db.metadata.create_all(bind=engine)`, and keep only the signed run id/role in Flask session. Track last access and dispose engines before deletion. Never call `db.create_all()` or `db.session.commit()` against the application’s configured engine from the manager. + +- [ ] **Step 4: Run the lifecycle tests and confirm they pass.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_database.py -q` + +- [ ] **Step 5: Commit the isolated lifecycle service.** + + Run: `git add services/demo_database.py tests/test_demo_database.py docs/superpowers/specs/2026-08-26-demo-session-database-design.md docs/superpowers/plans/2026-08-26-demo-session-database.md; git commit -m "feat: add per-session demo database lifecycle"` + +### Task 2: Bind Flask requests and Flask-Login to the temporary database + +**Files:** +- Modify: `app.py:163-305` +- Modify: `routes/auth.py:20-125,284-305` +- Modify: `services/demo_database.py` +- Test: `tests/test_demo_session_binding.py` +- Modify: `tests/demo_test_utils.py` for cleanup of demo runs + +**Interfaces:** +- `activate_demo_request_database() -> None` runs before any `current_user` access. +- `DemoPrincipal` wraps the temporary `User` row and returns `demo:` from `get_id()` while delegating role/profile attributes. +- `login_demo_run(run: DemoRun) -> DemoPrincipal` creates the Flask-Login session without a formal user row. + +- [ ] **Step 1: Write failing tests for request binding and authentication.** + + Login two test clients through `/demo-login/student`, assert their `demo_run_id` values differ, assert the loaded principals are students, and assert a query made during the demo request reads from the temporary database. Snapshot row counts and a representative record in the application database before and after login/navigation. + +- [ ] **Step 2: Run the binding tests and confirm the expected failure.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_session_binding.py -q` + + Expected: the current fixed demo login either writes the application database or does not provide `demo_run_id`. + +- [ ] **Step 3: Implement request-scoped binding and temporary principal loading.** + + At the start of `check_single_session`, activate the demo engine before reading `current_user`; obtain the scoped SQLAlchemy session and set its bind to the run engine. Extend the Flask-Login loader to resolve `demo:` from the temporary database. Skip formal single-session DB invalidation for demo principals. On normal requests retain the configured bind and normal user loader unchanged. + +- [ ] **Step 4: Replace public demo login/logout mutations.** + + Make `/demo-login/` create a fresh run, seed it, log in the temporary principal, and redirect to the temporary assignment/home. Make logout destroy only the current demo run and avoid writing a formal `SystemLog`. If initialization fails, dispose/delete the new run and return to login without touching the application database. + +- [ ] **Step 5: Run binding, login, and prior demo tests.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_session_binding.py tests/test_demo_login.py -q` + + Expected: both isolation and login suites pass; update old tests that assumed fixed rows in the formal database. + +- [ ] **Step 6: Commit the authentication boundary.** + + Run: `git add app.py routes/auth.py services/demo_database.py tests/test_demo_session_binding.py tests/demo_test_utils.py tests/test_demo_login.py; git commit -m "feat: bind demo sessions to isolated database"` + +### Task 3: Move fixture seeding into each temporary database + +**Files:** +- Rewrite: `services/demo_experience.py` +- Modify: `routes/auth.py` to call the temporary seeder +- Test: `tests/test_demo_experience.py` +- Test: `tests/test_demo_database_isolation.py` + +**Interfaces:** +- `seed_demo_experience(run: DemoRun) -> DemoExperience` +- `get_demo_assignment_id(run_id: str, key: str = 'guided_fibonacci') -> int` +- `is_demo_guided_assignment(assignment: Assignment) -> bool` +- `is_demo_guided_session(thinking_session: ThinkingSession) -> bool` + +- [ ] **Step 1: Write failing fixture and cross-client isolation tests.** + + Assert that a new temporary database contains the two assignments, 13 knowledge points for the student, at least 10 historical submissions scored between 0 and 5, structured five-dimensional feedback, multiple temporary teacher students, and ready presets for both guided assignments. Assert that the application database has no rows with the old `demo_*` identifiers created by public login. Submit or update a record in client A and assert client B’s temporary database and the application database remain unchanged. + +- [ ] **Step 2: Run the tests and observe failure from the fixed-ID formal seeder.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_experience.py tests/test_demo_database_isolation.py -q` + +- [ ] **Step 3: Implement a transaction-safe temporary seeder.** + + Reuse the existing fixture content but replace `ensure_demo_experience()` formal-database upserts with inserts into the active temporary session. Use synthetic IDs only inside the temporary database. Seed score values such as `2.2`, `2.9`, `3.4`, `3.8`, `4.1`, and `4.6`; keep assignment totals/display metadata separate from submission scores. Populate all 13 C-language points and valid JSON feedback keys for algorithm/style/functionality/efficiency/readability. + +- [ ] **Step 4: Add realistic teacher data in the temporary database.** + + Seed 10–12 student rows, six assignments, 25+ submissions spread over recent dates, class roster records, knowledge-point scores, a 14-day trend, and a teacher suggestion row. Keep all relationships inside the same temporary database and set the temporary teacher as the only manager. + +- [ ] **Step 5: Run the fixture and isolation tests green.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_experience.py tests/test_demo_database_isolation.py -q` + +- [ ] **Step 6: Commit the temporary fixture migration.** + + Run: `git add services/demo_experience.py routes/auth.py tests/test_demo_experience.py tests/test_demo_database_isolation.py; git commit -m "feat: seed demo fixtures per temporary session"` + +### Task 4: Make asynchronous evaluation and real AI analysis demo-aware + +**Files:** +- Modify: `tasks/submission_tasks.py:10-171` +- Modify: `tasks/ability_analysis.py:10-137` +- Modify: `utils/async_tasks.py` only where a demo run id must be forwarded +- Modify: `routes/assignments.py:484-577` +- Modify: `routes/api.py:230-325,997-1123` +- Modify: `routes/main.py:617-697` and teacher suggestion trigger paths +- Test: `tests/test_demo_ai_refresh.py` +- Test: `tests/test_demo_submission_isolation.py` + +**Interfaces:** +- `evaluate_submission_async(app, submission_id, assignment_title, demo_run_id=None)` +- `generate_ability_analysis_async(app, student_id, demo_run_id=None)` +- `trigger_analysis_if_needed(student_id, force=False, demo_run_id=None)` +- Teacher suggestion async entry points accept `demo_run_id=None` and rebind before database work. + +- [ ] **Step 1: Write failing tests for real-AI storage and refresh.** + + Use a test AI evaluator/client that records the submission payload and returns a distinct Markdown result for each call. Assert that the first demo analysis is generated from seeded history and stored in the temporary `AbilityTrend`, that a successful new submission marks it outdated and causes a second AI call, and that the result is not present in the application database. Assert that a failed AI call produces `failed` status in the temporary database without crashing the request. + +- [ ] **Step 2: Run the AI tests and confirm the missing run-id propagation failure.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_ai_refresh.py tests/test_demo_submission_isolation.py -q` + +- [ ] **Step 3: Forward run ids into background threads.** + + Capture the current demo run id at the request boundary, pass it from form/API submission and analysis/suggestion triggers, and call `activate_demo_run(run_id)` inside each worker’s own `app.app_context()`. When the run no longer exists, exit without querying or writing the formal database. Remove the existing demo-path fallback that returns a successful fake AI analysis; expose unavailable/failed status instead. + +- [ ] **Step 4: Ensure every successful demo submission updates all temporary aggregates.** + + Keep the evaluator’s 0–5 score, update temporary user/assignment counts, knowledge-point scores, structured feedback and `AbilityTrend`, then launch the real AI analysis. Cover both the HTML form path and `/api/submit`; do not regress normal-account behavior. + +- [ ] **Step 5: Run AI refresh and submission isolation tests green.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_ai_refresh.py tests/test_demo_submission_isolation.py -q` + +- [ ] **Step 6: Commit demo-aware asynchronous work.** + + Run: `git add tasks/submission_tasks.py tasks/ability_analysis.py utils/async_tasks.py routes/assignments.py routes/api.py routes/main.py tests/test_demo_ai_refresh.py tests/test_demo_submission_isolation.py; git commit -m "feat: isolate demo evaluation and real AI analysis"` + +### Task 5: Stabilize guided assignment two and keep quick jumps temporary + +**Files:** +- Modify: `services/demo_experience.py` +- Modify: `routes/thinking.py:54-135,141-269,276-1362` +- Modify: `utils/thinking_ai.py` only for explicit demo preset regeneration behavior +- Modify: `static/js/thinking.js:38-69,1326-1390` +- Test: `tests/test_demo_guided_learning.py` + +**Interfaces:** +- Demo arena and all guided APIs continue using their existing URLs, with their model operations transparently bound to the current temporary database. +- Demo preset generation returns `ready` from the temporary database for assignment one and two without queueing a formal-database task. + +- [ ] **Step 1: Write failing tests for assignment two, stage persistence, and isolation.** + + Enter the second demo assignment with AI unavailable and assert the arena reports `ready`, starts a session, and accepts stage operations. Use the quick jump endpoint and assert only the current run’s `ThinkingSession` changes; a second client remains at the initial stage. Assert a completed guided run creates the expected temporary submission/analysis trigger without any formal rows. + +- [ ] **Step 2: Run the guided tests and confirm assignment two currently fails or writes formal rows.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_guided_learning.py -q` + +- [ ] **Step 3: Seed both guided presets in the temporary database and guard regeneration.** + + Put the assignment-two teaching preset into the temporary database with `ready` status. In demo requests, use the temporary preset and avoid `add_generate_preset_task` against the formal task manager. Keep the existing real AI generation path for normal assignments; if a demo user explicitly regenerates, run it synchronously in the temporary database. + +- [ ] **Step 4: Complete guided learning in the temporary database.** + + Keep stage-one through stage-three logs, hints, code writes, fixes, completion state and the final 0–5 demonstration submission in the temporary session. Pass the run id into any post-completion AI refresh. + +- [ ] **Step 5: Preserve the demo quick jump and remove the old sandbox assistant.** + + Keep four arena shortcuts for demo users, ensure local developer auto-actions remain local-only, and delete the layout-level right-bottom “沙箱体验助手” panel/form/role switcher. Add a visible temporary-data notice near the demo navigation. + +- [ ] **Step 6: Run guided tests green and commit.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_guided_learning.py -q` + + Then: `git add services/demo_experience.py routes/thinking.py utils/thinking_ai.py static/js/thinking.js templates/layout.html tests/test_demo_guided_learning.py; git commit -m "fix: keep guided demo state temporary and stable"` + +### Task 6: Correct student profile, score presentation, and teacher demo views + +**Files:** +- Modify: `routes/main.py:46-290,554-697,813-885` +- Modify: `routes/users.py:159-263` where demo history/refresh needs explicit support +- Modify: `templates/student_home.html:413-520,796-844,846-1105` +- Modify: `templates/sprofile.html` and `templates/teacher_home.html` +- Modify: `templates/classes/class_list.html`, `templates/classes/class_detail.html`, `templates/teacher_ai_suggestions.html` +- Test: `tests/test_demo_profile_views.py` + +**Interfaces:** +- Student recent submissions render `score / 5` and use thresholds 4.0/3.0. +- Knowledge profile renders all 13 points with score, attempts, accuracy and status. +- AI card shows `processing`, `completed`, `failed`, last updated time and refresh action. +- Teacher pages render temporary roster, assignment progress, trend and suggestion data without changing normal templates’ role checks. + +- [ ] **Step 1: Write failing HTML/API tests for the requested presentation.** + + Login as a demo student and assert the home/profile pages contain 13 C-language knowledge points, `/5` recent scores that are all within 0–5, four Bayesian metrics and an AI status/result. Login as a demo teacher and assert the dashboard/class/assignment/suggestion pages contain multiple students, assignments, trend points and recommendations. Assert the old sandbox assistant text is absent while the arena quick-jump text remains. + +- [ ] **Step 2: Run the view tests and confirm current output is incomplete/wrong.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_profile_views.py -q` + +- [ ] **Step 3: Make student data-driven views render the complete profile.** + + Use the temporary model queries to provide all knowledge-point rows, valid dimension data and maturity components. Change recent submission badges and labels from 80/60 thresholds to 4.0/3.0 and display one decimal plus `/5`. Show the distinct 0–100 Bayesian metrics separately from submission score. + +- [ ] **Step 4: Make the AI card refresh from real temporary trend state.** + + Keep the SSE contract, include a temporary analysis version/last-updated marker, and expose a refresh action that marks only the current temporary trend outdated and starts the real AI task. Do not cache demo analysis in the formal database. + +- [ ] **Step 5: Fill teacher pages with temporary realistic data and safe mutations.** + + Ensure class list/detail, teacher assignment list/detail, trend and suggestion pages use the temporary session’s expanded fixture. Demo mutations such as editing/creating assignments or changing class data must commit to the active temporary session; ordinary users continue to use formal RBAC and formal database queries. + +- [ ] **Step 6: Run profile view tests and commit.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_profile_views.py -q` + + Then: `git add routes/main.py routes/users.py templates/student_home.html templates/sprofile.html templates/teacher_home.html templates/classes templates/teacher_ai_suggestions.html tests/test_demo_profile_views.py; git commit -m "feat: complete isolated demo analytics views"` + +### Task 7: Full regression, cleanup, and final verification + +**Files:** +- Modify: `tests/demo_test_utils.py` and any focused tests needed for deterministic cleanup +- Modify: `README.md` with the temporary-database behavior and score-scale note + +- [ ] **Step 1: Add the final formal-database snapshot test.** + + Capture counts and hashes for users, classes, rosters, assignments, submissions, knowledge scores, trends, suggestions, thinking sessions/logs and system logs. Run a complete student and teacher demo flow, logout both clients, and assert all formal snapshots match and all run files are gone. + +- [ ] **Step 2: Run focused suites.** + + Run: `E:\anaconda\python.exe -m pytest tests/test_demo_database.py tests/test_demo_session_binding.py tests/test_demo_database_isolation.py tests/test_demo_ai_refresh.py tests/test_demo_submission_isolation.py tests/test_demo_guided_learning.py tests/test_demo_profile_views.py -q` + +- [ ] **Step 3: Run the full test suite and static checks.** + + Run: `E:\anaconda\python.exe -m pytest tests -q` + + Run: `E:\anaconda\python.exe -m compileall -q services routes tasks tests` + + Run: `git diff --check` + +- [ ] **Step 4: Inspect the final diff for formal-database writes in demo paths.** + + Search: `Select-String -Path app.py,routes\*.py,services\*.py,tasks\*.py -Pattern 'demo_run_id|activate_demo_run|db\.session|SystemLog'` + + Confirm every demo background operation activates the run before accessing models and no public demo path calls the legacy formal seeder. + +- [ ] **Step 5: Commit documentation and final cleanup.** + + Run: `git add README.md tests/demo_test_utils.py tests; git commit -m "test: verify demo sessions never persist to formal database"` + +- [ ] **Step 6: Report only freshly verified results.** + + Include the exact focused/full test counts, compile/diff-check results, temporary database lifecycle behavior, real-AI availability behavior, and note that no push or PR is performed without separate authorization. diff --git a/docs/superpowers/specs/2026-08-26-demo-session-database-design.md b/docs/superpowers/specs/2026-08-26-demo-session-database-design.md new file mode 100644 index 0000000..4ef6def --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-demo-session-database-design.md @@ -0,0 +1,58 @@ +# 体验会话临时数据库设计 + +## 目标 + +将公开学生/教师体验改为“每个浏览器会话一个独立临时 SQLite 数据库”。体验过程中可以完整执行提交、作业修改、引导式学习和真实 AI 分析;体验结束后删除该临时库,正式数据库不接收任何体验数据。 + +## 约束 + +1. 正式账号继续使用现有 Flask-SQLAlchemy 数据库和 RBAC,不改变正常登录、提交和教师权限。 +2. 公开体验只接受 `student`、`teacher` 两种角色,不创建正式用户、班级、花名册、提交、趋势、建议或系统日志。 +3. `demo_run_id` 使用随机不可预测值,只有服务端 session 保存该标识;临时库文件路径必须位于专用目录内,并经过路径校验。 +4. 临时库采用完整现有模型元数据创建,体验请求开始前将当前请求作用域的 SQLAlchemy session 绑定到临时引擎;请求结束后移除 session,不得把临时引擎绑定带到正式请求。 +5. 主库零写入是验收条件,而不是“尽量避免”。公开入口、体验页面、提交、AI 分析、教师建议、退出和清理流程都要有测试覆盖。 +6. 浏览器关闭不能作为唯一清理信号;主动退出、空闲超时、最长生命周期和服务器启动清理共同保证临时文件最终删除。 +7. 提交分数使用 0–5;知识点画像、五维能力和多维度贝叶斯权重评估使用 0–100,页面必须明确区分两个口径。 +8. AI 个性化分析必须调用现有真实 AI 客户端。没有可用密钥或调用失败时展示失败/重试状态,不将预设文案伪装成 AI 结果。 + +## 生命周期 + +### 创建 + +`/demo-login/` 校验角色后创建新的 `demo_run_id` 和临时 SQLite 文件,执行完整 schema 创建及演示夹具初始化。夹具包含: + +- 学生体验:13 个 C 语言知识点、至少 10 条 0–5 分提交历史、有效的结构化五维能力反馈、时间序列数据、两个引导作业和作业二的可用积木预设。 +- 教师体验:一个演示班级、至少 10 名临时学生、至少 6 个作业、覆盖 14/30 天的提交趋势、知识点分布、风险学生和教师 AI 建议所需的基础记录。 + +初始 `AbilityTrend` 不填充伪造 AI 结果;登录后由真实 AI 任务基于夹具提交生成分析,结果写入当前临时库。 + +### 请求绑定 + +Flask 请求开始时,如果服务端 session 包含有效的 `demo_run_id`,先激活对应临时引擎,再让 Flask-Login 加载临时用户。这样现有模型查询、提交和关系方法自然落在临时库中。正常请求不执行切换,始终使用正式库。 + +后台评测和 AI 线程必须显式携带 `demo_run_id`,在线程自己的 Flask/SQLAlchemy 上下文中重新激活临时库。线程发现会话已销毁时直接结束,不得回退到正式库。 + +### 销毁 + +退出时先停止/忽略当前体验任务,移除请求 session,释放临时 SQLAlchemy session 和 engine,再删除临时 SQLite 文件及其 journal 文件。临时库目录定期清理超过空闲阈值或最长生命周期的文件。清理失败只记录服务端日志,不阻塞用户退出。 + +## 真实 AI 行为 + +学生首次打开首页时,流式能力分析接口返回当前知识点画像,并触发/展示真实 AI 分析状态。提交成功后,沿用正常账户的“标记 outdated → 异步生成 → SSE 展示”的流程,但所有 `AbilityTrend` 读写都发生在该体验临时库。 + +教师端的个性化建议同样允许调用现有教师 AI 服务,结果写入临时库;首次加载使用夹具数据生成,刷新只影响当前体验会话。作业二的引导预设是稳定的本地教学夹具,目的是保证学习流程可进入,不替代个性化分析。 + +## 页面与权限 + +公开体验继续复用现有学生首页、学生提交记录、引导式学习竞技场、教师首页、班级管理、作业管理和教师 AI 建议页面。由于请求级数据库绑定,现有页面的查询和写入都使用临时库;体验身份只拥有对应临时库里的角色权限。 + +体验页显示“本次体验数据将在退出后清除”的提示。删除旧的开发环境右下角“沙箱体验助手”,保留引导式学习页面内的阶段快捷跳转;快捷跳转只修改临时库中的 `ThinkingSession`。 + +## 验收重点 + +1. 两个独立客户端登录后获得不同的 `demo_run_id` 和临时库。 +2. 客户端 A 提交代码、完成阶段或刷新 AI 后,客户端 B 的分数、阶段和分析仍保持初始状态。 +3. 正式数据库所有相关表的计数、更新时间和内容在完整体验前后不变。 +4. 退出后对应临时库文件不存在;新一轮体验重新得到初始数据。 +5. AI 调用使用真实客户端,并将分析状态和结果落在临时库;调用失败不会破坏登录或写入正式库。 +6. 普通账号不能访问或切换体验临时库,普通 RBAC 测试继续通过。 diff --git a/services/demo_database.py b/services/demo_database.py new file mode 100644 index 0000000..b95d9dc --- /dev/null +++ b/services/demo_database.py @@ -0,0 +1,244 @@ +"""Per-session temporary database support for the public demo experience.""" + +from __future__ import annotations + +import os +import re +import secrets +import tempfile +import threading +import time +import gc +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path + +from flask import g, has_request_context, session +from sqlalchemy import create_engine + +from models import db + + +DEMO_SESSION_KEY = "demo_run_id" +DEMO_ROLE_SESSION_KEY = "demo_role" +DEMO_ID_PREFIX = "demo:" +DEMO_STUDENT_ID = "demo_s_001" +DEMO_TEACHER_ID = "demo_t_001" +DEMO_IDLE_TIMEOUT = timedelta(hours=1) +DEMO_MAX_LIFETIME = timedelta(hours=2) + +_RUN_ID_PATTERN = re.compile(r"^[0-9a-f]{48}$") +_LOCK = threading.RLock() +_ENGINES = {} +_RUN_CREATED_AT = {} +_RUN_LAST_ACCESS = {} + + +def _demo_root() -> Path: + root = Path(tempfile.gettempdir()) / "codesense-demo-runs" + root.mkdir(mode=0o700, parents=True, exist_ok=True) + return root + + +def _validate_run_id(run_id: str) -> str: + if not isinstance(run_id, str) or not _RUN_ID_PATTERN.fullmatch(run_id): + raise ValueError("无效的体验会话标识") + return run_id + + +def _db_path(run_id: str) -> Path: + run_id = _validate_run_id(run_id) + root = _demo_root().resolve() + path = (root / f"{run_id}.sqlite3").resolve() + if path.parent != root: + raise ValueError("体验数据库路径越界") + return path + + +def _sqlite_uri(path: Path) -> str: + return f"sqlite:///{path.as_posix()}" + + +@dataclass(frozen=True) +class DemoRun: + """A single public-demo database and its stable temporary identities.""" + + run_id: str + role: str + student_id: str + teacher_id: str + db_path: str + created_at: datetime + + +def is_demo_login_id(user_id: str) -> bool: + """Return whether a Flask-Login id belongs to the demo namespace.""" + + return ( + isinstance(user_id, str) + and user_id.startswith(DEMO_ID_PREFIX) + and bool(_RUN_ID_PATTERN.fullmatch(user_id[len(DEMO_ID_PREFIX) :])) + ) + + +def current_demo_run_id() -> str | None: + """Read the current run id from the server-side Flask session.""" + + if not has_request_context(): + return None + run_id = session.get(DEMO_SESSION_KEY) + if not isinstance(run_id, str) or not _RUN_ID_PATTERN.fullmatch(run_id): + return None + return run_id + + +def _engine_for_run(run_id: str): + run_id = _validate_run_id(run_id) + path = _db_path(run_id) + with _LOCK: + engine = _ENGINES.get(run_id) + if engine is not None: + return engine + if not path.exists(): + return None + engine = create_engine( + _sqlite_uri(path), + connect_args={"check_same_thread": False}, + ) + _ENGINES[run_id] = engine + return engine + + +def create_demo_run(role: str) -> DemoRun: + """Create a fresh temporary SQLite database for one browser session.""" + + if role not in {"student", "teacher"}: + raise ValueError("体验角色必须是 student 或 teacher") + + created_at = datetime.utcnow() + run_id = secrets.token_hex(24) + path = _db_path(run_id) + engine = create_engine( + _sqlite_uri(path), + connect_args={"check_same_thread": False}, + ) + + # The model metadata is used only with this newly-created engine. The + # application's configured engine is never passed to create_all here. + db.metadata.create_all(bind=engine) + + with _LOCK: + _ENGINES[run_id] = engine + _RUN_CREATED_AT[run_id] = created_at + _RUN_LAST_ACCESS[run_id] = created_at + + return DemoRun( + run_id=run_id, + role=role, + student_id=DEMO_STUDENT_ID, + teacher_id=DEMO_TEACHER_ID, + db_path=str(path), + created_at=created_at, + ) + + +def activate_demo_run(run_id: str) -> bool: + """Bind the current Flask-SQLAlchemy scoped session to a demo engine.""" + + engine = _engine_for_run(run_id) + if engine is None: + return False + + # A scoped session may have been materialized by Flask-Login or another + # before-request hook. Remove it before assigning the temporary bind so no + # production connection or identity map can leak into the demo request. + db.session.remove() + request_session = db.session() + request_session.bind = engine + + now = datetime.utcnow() + with _LOCK: + _RUN_LAST_ACCESS[run_id] = now + path = _db_path(run_id) + try: + os.utime(path, None) + except OSError: + pass + if has_request_context(): + g.demo_run_id = run_id + return True + + +def activate_demo_request_database() -> bool: + """Activate the database selected by the current request's session.""" + + run_id = current_demo_run_id() + if not run_id: + return False + if activate_demo_run(run_id): + return True + + # A stale browser session must never fall back to the formal database. + session.pop(DEMO_SESSION_KEY, None) + session.pop(DEMO_ROLE_SESSION_KEY, None) + session.pop("_user_id", None) + session.pop("login", None) + return False + + +def destroy_demo_run(run_id: str) -> bool: + """Dispose and delete one temporary database and its SQLite sidecars.""" + + path = _db_path(run_id) + with _LOCK: + engine = _ENGINES.pop(run_id, None) + _RUN_CREATED_AT.pop(run_id, None) + _RUN_LAST_ACCESS.pop(run_id, None) + if engine is not None: + try: + engine.dispose(close=True) + except TypeError: + engine.dispose() + gc.collect() + + existed = False + for candidate in ( + path, + Path(f"{path}-wal"), + Path(f"{path}-shm"), + Path(f"{path}-journal"), + ): + if candidate.exists(): + existed = True + for attempt in range(3): + try: + candidate.unlink() + break + except FileNotFoundError: + break + except PermissionError: + if attempt == 2: + raise + time.sleep(0.02) + return existed or engine is not None + + +def cleanup_expired_demo_runs(now: datetime | None = None) -> int: + """Delete stale demo files, including files left by a crashed worker.""" + + now = now or datetime.utcnow() + removed = 0 + root = _demo_root() + for path in root.glob("*.sqlite3"): + try: + modified_at = datetime.utcfromtimestamp(path.stat().st_mtime) + except FileNotFoundError: + continue + if now - modified_at <= DEMO_IDLE_TIMEOUT: + continue + run_id = path.stem + if not _RUN_ID_PATTERN.fullmatch(run_id): + continue + if destroy_demo_run(run_id): + removed += 1 + return removed diff --git a/tests/test_demo_database.py b/tests/test_demo_database.py new file mode 100644 index 0000000..8a009f1 --- /dev/null +++ b/tests/test_demo_database.py @@ -0,0 +1,74 @@ +import os +import sqlite3 +import unittest + +from services.demo_database import ( + create_demo_run, + destroy_demo_run, + is_demo_login_id, +) +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoDatabaseLifecycleTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + + def tearDown(self): + destroy_test_app(self.app) + + def test_create_demo_run_creates_isolated_schema_without_formal_rows(self): + with self.app.app_context(): + from models import User + + formal_users_before = User.query.count() + + run = create_demo_run('student') + + self.assertTrue(run.run_id) + self.assertEqual(run.role, 'student') + self.assertTrue(os.path.isfile(run.db_path)) + self.assertTrue(is_demo_login_id(f'demo:{run.run_id}')) + + connection = sqlite3.connect(run.db_path) + try: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + } + finally: + connection.close() + + self.assertIn('users', tables) + self.assertIn('assignments', tables) + self.assertIn('submissions', tables) + self.assertIn('ability_trends', tables) + + self.assertEqual(User.query.count(), formal_users_before) + destroy_demo_run(run.run_id) + + def test_destroying_one_run_does_not_remove_another_run(self): + with self.app.app_context(): + first = create_demo_run('student') + second = create_demo_run('teacher') + first_path = first.db_path + second_path = second.db_path + + self.assertNotEqual(first.run_id, second.run_id) + self.assertNotEqual(first_path, second_path) + self.assertTrue(os.path.exists(first_path)) + self.assertTrue(os.path.exists(second_path)) + + self.assertTrue(destroy_demo_run(first.run_id)) + + self.assertFalse(os.path.exists(first_path)) + self.assertTrue(os.path.exists(second_path)) + self.assertFalse(destroy_demo_run(first.run_id)) + + destroy_demo_run(second.run_id) + + +if __name__ == '__main__': + unittest.main() From aed3045a03c4004eabd24d4e46b6cf378fb19e82 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 26 Aug 2026 12:58:02 +0800 Subject: [PATCH 06/12] feat: bind demo sessions to isolated database --- app.py | 10 +++ models.py | 13 +++- routes/auth.py | 36 ++++++++-- services/demo_database.py | 66 +++++++++++++++++- tests/test_demo_session_binding.py | 105 +++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 tests/test_demo_session_binding.py diff --git a/app.py b/app.py index 0bcad91..b078bd0 100644 --- a/app.py +++ b/app.py @@ -166,6 +166,11 @@ def check_single_session(): # 忽略静态文件、静态资源 if not request.endpoint or 'static' in request.endpoint or request.endpoint == 'favicon': return + + # 公开体验必须在任何 Flask-Login/业务查询发生前切换到本次会话的临时库。 + # 若临时库已经失效,服务会清除 demo 身份,绝不回退到正式库。 + from services.demo_database import activate_demo_request_database + activate_demo_request_database() # 忽略测试环境,避免 Session 干扰 if app.config.get('TESTING'): @@ -177,6 +182,8 @@ def check_single_session(): from flask_login import current_user, logout_user if current_user.is_authenticated: + if getattr(current_user, 'is_demo', False): + return # 检查Session中的ID是否与数据库中一致 session_id = session.get('current_session_id') db_session_id = current_user.current_session_id @@ -302,6 +309,9 @@ def inject_now(): def load_user(user_id): # 从models模块导入User模型 from models import User + from services.demo_database import is_demo_login_id, load_demo_principal + if is_demo_login_id(user_id): + return load_demo_principal(user_id) return db.session.get(User, user_id) # 注册蓝图 diff --git a/models.py b/models.py index 60672db..632a72a 100644 --- a/models.py +++ b/models.py @@ -5,6 +5,7 @@ import secrets from datetime import datetime as dt from flask_sqlalchemy import SQLAlchemy +from flask_sqlalchemy.session import Session as FlaskSQLAlchemySession from werkzeug.security import generate_password_hash, check_password_hash from flask_login import UserMixin # 添加UserMixin导入 @@ -12,7 +13,17 @@ DEFAULT_GRADE = '2024' DEFAULT_MAJOR = '计算机相关专业' -db = SQLAlchemy() +class CodeSenseSession(FlaskSQLAlchemySession): + """Allow a request-scoped demo engine without changing app configuration.""" + + def get_bind(self, mapper=None, clause=None, bind=None, **kwargs): + demo_bind = getattr(self, '_codesense_demo_bind', None) + if demo_bind is not None and bind is None: + return demo_bind + return super().get_bind(mapper=mapper, clause=clause, bind=bind, **kwargs) + + +db = SQLAlchemy(session_options={'class_': CodeSenseSession}) class Class(db.Model): diff --git a/routes/auth.py b/routes/auth.py index 7eb1c04..42f4d94 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -12,6 +12,13 @@ DEMO_TEACHER_ID, ensure_demo_experience, ) +from services.demo_database import ( + activate_demo_run, + create_demo_run, + current_demo_run_id, + destroy_demo_run, + login_demo_run, +) from utils.auth import redirect_if_logged_in auth = Blueprint('auth', __name__) @@ -102,24 +109,29 @@ def login(): @auth.route('/demo-login/') @redirect_if_logged_in def demo_login(role): - """公开演示入口:准备演示数据后,按角色进入对应体验首页。""" + """公开演示入口:为本次会话创建独立临时库后进入对应体验首页。""" if role not in ('student', 'teacher'): flash('该演示入口不可用,请返回登录页重试。', 'warning') return redirect(url_for('auth.login')) + run = None try: + run = create_demo_run(role) + if not activate_demo_run(run.run_id): + raise RuntimeError('临时体验数据库无法激活') demo = ensure_demo_experience() - user_id = DEMO_STUDENT_ID if role == 'student' else DEMO_TEACHER_ID - user = User.query.get(user_id) - if not user: - raise RuntimeError('演示账号初始化后不存在') - - _establish_login_session(user, source='公开演示') + login_demo_run(run) if role == 'student': return redirect(url_for('thinking.arena', assignment_id=demo.assignment_id)) return redirect(url_for('main.home')) except Exception: db.session.rollback() + if run is not None: + db.session.remove() + try: + destroy_demo_run(run.run_id) + except Exception: + current_app.logger.exception('清理失败的公开体验临时库失败') current_app.logger.exception('公开演示入口初始化失败') flash('演示入口暂时不可用,请稍后重试。', 'warning') return redirect(url_for('auth.login')) @@ -284,6 +296,7 @@ def register_teacher(token): @auth.route('/logout') def logout(): """登出处理""" + demo_run_id = current_demo_run_id() user_id = session.get('student_id') username = session.get('username') full_name = session.get('full_name', '未知用户') @@ -292,6 +305,15 @@ def logout(): logout_user() session.clear() + + if demo_run_id: + db.session.remove() + try: + destroy_demo_run(demo_run_id) + except Exception: + current_app.logger.exception('公开体验临时库清理失败') + flash('本次体验已结束,体验数据已清除', 'info') + return redirect(url_for('auth.login')) if user_id: SystemLog.add_log( diff --git a/services/demo_database.py b/services/demo_database.py index b95d9dc..4b38d95 100644 --- a/services/demo_database.py +++ b/services/demo_database.py @@ -71,6 +71,28 @@ class DemoRun: created_at: datetime +class DemoPrincipal: + """Flask-Login principal backed by a temporary-database User row.""" + + is_demo = True + is_active = True + is_authenticated = True + is_anonymous = False + + def __init__(self, user, run_id: str): + self._user = user + self.run_id = _validate_run_id(run_id) + + def get_id(self) -> str: + return f"{DEMO_ID_PREFIX}{self.run_id}" + + def __getattr__(self, name): + return getattr(self._user, name) + + def __repr__(self) -> str: + return f"" + + def is_demo_login_id(user_id: str) -> bool: """Return whether a Flask-Login id belongs to the demo namespace.""" @@ -92,6 +114,48 @@ def current_demo_run_id() -> str | None: return run_id +def login_demo_run(run: DemoRun) -> DemoPrincipal: + """Log in the temporary User row without creating a formal user.""" + + from flask_login import login_user + from models import User + + user_id = run.teacher_id if run.role == "teacher" else run.student_id + user = db.session.get(User, user_id) + if user is None: + raise RuntimeError("临时体验用户初始化失败") + + principal = DemoPrincipal(user, run.run_id) + login_user(principal) + session[DEMO_SESSION_KEY] = run.run_id + session[DEMO_ROLE_SESSION_KEY] = run.role + session["current_session_id"] = f"{DEMO_ID_PREFIX}{run.run_id}" + session["student_id"] = user.student_id + session["username"] = user.username + session["full_name"] = user.full_name or user.username + session["usertype"] = user.usertype + session["login"] = True + return principal + + +def load_demo_principal(user_id: str) -> DemoPrincipal | None: + """Load a temporary principal for Flask-Login's user loader.""" + + if not is_demo_login_id(user_id): + return None + run_id = user_id[len(DEMO_ID_PREFIX) :] + if not activate_demo_run(run_id): + return None + + from models import User + + student_id = session.get("student_id") + user = db.session.get(User, student_id) if student_id else None + if user is None: + return None + return DemoPrincipal(user, run_id) + + def _engine_for_run(run_id: str): run_id = _validate_run_id(run_id) path = _db_path(run_id) @@ -154,7 +218,7 @@ def activate_demo_run(run_id: str) -> bool: # production connection or identity map can leak into the demo request. db.session.remove() request_session = db.session() - request_session.bind = engine + request_session._codesense_demo_bind = engine now = datetime.utcnow() with _LOCK: diff --git a/tests/test_demo_session_binding.py b/tests/test_demo_session_binding.py new file mode 100644 index 0000000..cc28952 --- /dev/null +++ b/tests/test_demo_session_binding.py @@ -0,0 +1,105 @@ +import os +import sqlite3 +import unittest + +from models import Assignment, Class, Submission, User +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoSessionBindingTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + self.first_client = self.app.test_client() + self.second_client = self.app.test_client() + + def tearDown(self): + destroy_test_app(self.app) + + def _formal_snapshot(self): + with self.app.app_context(): + return { + 'users': User.query.count(), + 'classes': Class.query.count(), + 'assignments': Assignment.query.count(), + 'submissions': Submission.query.count(), + } + + def test_activating_a_run_binds_writes_to_the_run_file(self): + from services.demo_database import activate_demo_run, create_demo_run, destroy_demo_run + + with self.app.app_context(): + run = create_demo_run('student') + self.assertTrue(activate_demo_run(run.run_id)) + + user = User( + student_id='binding-test-student', + username='binding-test-student', + usertype='学生', + full_name='绑定测试学生', + ) + user.password = 'not-used' + from models import db + + db.session.add(user) + db.session.commit() + db.session.remove() + + connection = sqlite3.connect(run.db_path) + try: + temp_users = connection.execute('SELECT COUNT(*) FROM users').fetchone()[0] + finally: + connection.close() + + self.assertEqual(temp_users, 1) + self.assertEqual(User.query.count(), 0) + destroy_demo_run(run.run_id) + + def test_each_demo_login_gets_a_distinct_run_without_formal_writes(self): + before = self._formal_snapshot() + + first_response = self.first_client.get('/demo-login/student') + second_response = self.second_client.get('/demo-login/student') + + self.assertEqual(first_response.status_code, 302) + self.assertEqual(second_response.status_code, 302) + + with self.first_client.session_transaction() as first_session: + first_run_id = first_session.get('demo_run_id') + self.assertEqual(first_session.get('usertype'), '学生') + with self.second_client.session_transaction() as second_session: + second_run_id = second_session.get('demo_run_id') + self.assertEqual(second_session.get('usertype'), '学生') + + self.assertTrue(first_run_id) + self.assertTrue(second_run_id) + self.assertNotEqual(first_run_id, second_run_id) + self.assertNotEqual(first_response.headers['Location'], '') + self.assertNotEqual(second_response.headers['Location'], '') + + after = self._formal_snapshot() + self.assertEqual(before, after) + + def test_demo_logout_deletes_only_the_current_run(self): + self.first_client.get('/demo-login/student') + self.second_client.get('/demo-login/student') + with self.first_client.session_transaction() as first_session: + first_run_id = first_session['demo_run_id'] + with self.second_client.session_transaction() as second_session: + second_run_id = second_session['demo_run_id'] + + from services.demo_database import _db_path + + first_path = str(_db_path(first_run_id)) + second_path = str(_db_path(second_run_id)) + self.assertTrue(os.path.exists(first_path)) + self.assertTrue(os.path.exists(second_path)) + + response = self.first_client.get('/logout') + + self.assertEqual(response.status_code, 302) + self.assertFalse(os.path.exists(first_path)) + self.assertTrue(os.path.exists(second_path)) + + +if __name__ == '__main__': + unittest.main() From 550fc89badf453f588c8f726b2498eb95a6443d7 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 26 Aug 2026 16:44:53 +0800 Subject: [PATCH 07/12] feat: seed demo fixtures per temporary session --- routes/auth.py | 10 +- services/demo_database.py | 29 ++ services/demo_experience.py | 588 +++++++++++++++++++++++++- tests/demo_test_utils.py | 14 +- tests/test_demo_database_isolation.py | 102 +++++ tests/test_demo_experience.py | 67 +-- tests/test_demo_guided_learning.py | 45 +- tests/test_demo_login.py | 8 +- tests/test_demo_teacher_experience.py | 16 +- 9 files changed, 812 insertions(+), 67 deletions(-) create mode 100644 tests/test_demo_database_isolation.py diff --git a/routes/auth.py b/routes/auth.py index 42f4d94..16b94c6 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -10,9 +10,11 @@ from services.demo_experience import ( DEMO_STUDENT_ID, DEMO_TEACHER_ID, - ensure_demo_experience, + seed_demo_experience, ) from services.demo_database import ( + DEMO_ROLE_SESSION_KEY, + DEMO_SESSION_KEY, activate_demo_run, create_demo_run, current_demo_run_id, @@ -119,7 +121,11 @@ def demo_login(role): run = create_demo_run(role) if not activate_demo_run(run.run_id): raise RuntimeError('临时体验数据库无法激活') - demo = ensure_demo_experience() + # The run marker must be present before any subsequent request or + # Flask-Login user loading can resolve the temporary database. + session[DEMO_SESSION_KEY] = run.run_id + session[DEMO_ROLE_SESSION_KEY] = role + demo = seed_demo_experience(run) login_demo_run(run) if role == 'student': return redirect(url_for('thinking.arena', assignment_id=demo.assignment_id)) diff --git a/services/demo_database.py b/services/demo_database.py index 4b38d95..cc392b4 100644 --- a/services/demo_database.py +++ b/services/demo_database.py @@ -233,6 +233,18 @@ def activate_demo_run(run_id: str) -> bool: return True +def is_active_demo_run(run_id: str) -> bool: + """Return whether the current scoped session is bound to this demo run.""" + + engine = _engine_for_run(run_id) + if engine is None: + return False + try: + return getattr(db.session(), '_codesense_demo_bind', None) is engine + except RuntimeError: + return False + + def activate_demo_request_database() -> bool: """Activate the database selected by the current request's session.""" @@ -306,3 +318,20 @@ def cleanup_expired_demo_runs(now: datetime | None = None) -> int: if destroy_demo_run(run_id): removed += 1 return removed + + +def destroy_all_demo_runs() -> int: + """Remove every valid demo run, primarily for deterministic test cleanup.""" + root = _demo_root() + run_ids = set() + with _LOCK: + run_ids.update(_ENGINES) + for path in root.glob('*.sqlite3'): + if _RUN_ID_PATTERN.fullmatch(path.stem): + run_ids.add(path.stem) + + removed = 0 + for run_id in run_ids: + if destroy_demo_run(run_id): + removed += 1 + return removed diff --git a/services/demo_experience.py b/services/demo_experience.py index 6d9d7bf..910bf97 100644 --- a/services/demo_experience.py +++ b/services/demo_experience.py @@ -1,13 +1,8 @@ -"""面向公开体验入口的稳定演示数据。 - -演示数据不是一次性测试夹具:公开体验入口、教师看板和学生思维竞技场都依赖同一组 -记录。因此这里采用“按业务唯一键补齐”的方式,重复调用只会补缺,不会删除学生在 -体验过程中产生的会话、提交或日志。 -""" +"""Seed a complete demo workspace inside the current temporary database.""" import json from dataclasses import dataclass -from datetime import datetime as dt +from datetime import datetime as dt, timedelta from models import ( AbilityTrend, @@ -23,12 +18,17 @@ User, db, ) +from services.demo_database import ( + DEMO_STUDENT_ID, + DEMO_TEACHER_ID, + DemoRun, + _db_path, + current_demo_run_id, +) -DEMO_TEACHER_ID = 'demo_t_001' DEMO_TEACHER_USERNAME = 'teacher_demo' DEMO_TEACHER_PASSWORD = '123456' -DEMO_STUDENT_ID = 'demo_s_001' DEMO_STUDENT_USERNAME = 'student_demo_good' DEMO_STUDENT_PASSWORD = '123456' DEMO_CLASS_NAME = '软件工程24-演示班' @@ -44,6 +44,7 @@ class DemoExperience: student_id: str class_id: int assignment_id: int + second_assignment_id: int def _json(value): @@ -363,6 +364,167 @@ def _ensure_preset(assignment): return preset +def _ensure_tree_preset(assignment): + """Create a deterministic teaching preset for the second demo assignment.""" + key_steps = [ + '先确认二叉树的根节点,并理解中序遍历的访问顺序。', + '递归处理左子树,再访问当前节点,最后处理右子树。', + '将遍历结果按顺序输出,并比较递归与显式栈的空间开销。', + ] + code_blocks = [ + { + 'id': 'tree-include', + 'code': '#include \n#include \n#include ', + 'label': '引入输入输出、数组与栈', + 'indent': 0, + 'phase': 1, + 'part_name': '遍历函数', + 'part_header': '', + 'part_footer': '', + }, + { + 'id': 'tree-node', + 'code': 'struct Node { int value; Node* left; Node* right; };', + 'label': '定义二叉树节点结构', + 'indent': 0, + 'phase': 1, + 'part_name': '遍历函数', + 'part_header': '', + 'part_footer': '', + }, + { + 'id': 'tree-base', + 'code': 'void inorder(Node* root) {\n if (!root) return;', + 'label': '处理空节点并进入遍历函数', + 'indent': 0, + 'phase': 1, + 'part_name': '遍历函数', + 'part_header': '', + 'part_footer': '\n}', + }, + { + 'id': 'tree-left', + 'code': 'inorder(root->left);', + 'label': '先遍历左子树', + 'indent': 1, + 'phase': 2, + 'part_name': '遍历函数', + 'part_header': 'void inorder(Node* root) {', + 'part_footer': '}', + }, + { + 'id': 'tree-visit', + 'code': 'std::cout << root->value << " ";', + 'label': '访问并输出当前节点', + 'indent': 1, + 'phase': 2, + 'part_name': '遍历函数', + 'part_header': 'void inorder(Node* root) {', + 'part_footer': '}', + }, + { + 'id': 'tree-right', + 'code': 'inorder(root->right);\n}', + 'label': '最后遍历右子树并结束函数', + 'indent': 1, + 'phase': 2, + 'part_name': '遍历函数', + 'part_header': 'void inorder(Node* root) {', + 'part_footer': '}', + }, + ] + noise_blocks = [ + { + 'id': 'noise-tree-root', + 'code': 'std::cout << root->value << " ";', + 'label': '进入函数后立即输出根节点(干扰项)', + 'indent': 1, + 'phase': 1, + 'part_name': '遍历函数', + 'part_header': 'void inorder(Node* root) {', + 'part_footer': '}', + }, + ] + quiz_steps = [ + { + 'step_id': 1, + 'part_name': '遍历函数', + 'type': 'choice', + 'question': '中序遍历访问节点的顺序是什么?', + 'options': ['左子树、当前节点、右子树', '当前节点、左子树、右子树', '右子树、当前节点、左子树'], + 'correct_answer': '左子树、当前节点、右子树', + 'explanation': '中序遍历的核心顺序是 Left-Root-Right。', + }, + { + 'step_id': 2, + 'part_name': '遍历函数', + 'type': 'fill', + 'question': '递归函数的终止条件应检查指针是否为?', + 'context_before': 'if (!root) ', + 'context_after': 'return;', + 'blank_hint': '输入布尔条件', + 'correct_answer': 'return', + 'code_line': 'if (!root) return;', + 'indent': 1, + 'explanation': '遇到空节点时立即返回,避免访问空指针。', + }, + { + 'step_id': 3, + 'part_name': '遍历函数', + 'type': 'choice', + 'question': '递归中序遍历的额外空间主要来自哪里?', + 'options': ['递归调用栈', '输入数组的排序', '输出流缓冲区'], + 'correct_answer': '递归调用栈', + 'explanation': '递归深度与树高相关,显式栈可以把它转换为可见的数据结构。', + }, + ] + difficulty_config = { + 'feynman_rounds': 3, + 'student_persona': 'curious', + 'guided_questions': [ + '为什么中序遍历在二叉搜索树上会得到有序序列?', + '最坏情况下递归深度是多少?', + '如何用显式栈改写递归遍历?', + ], + } + reference_code = """#include + +struct Node { + int value; + Node* left; + Node* right; +}; + +void inorder(Node* root) { + if (!root) return; + inorder(root->left); + std::cout << root->value << ' '; + inorder(root->right); +} + +int main() { + return 0; +}""" + + preset = AssignmentThinkingPreset.query.filter_by(assignment_id=assignment.id).first() + if not preset: + preset = AssignmentThinkingPreset(assignment_id=assignment.id) + db.session.add(preset) + preset.reference_code = reference_code + preset.key_steps = _json(key_steps) + preset.code_blocks = _json(code_blocks) + preset.noise_blocks = _json(noise_blocks) + preset.quiz_steps = _json(quiz_steps) + preset.difficulty_config = _json(difficulty_config) + preset.algorithm_summary = ( + '算法流程:从根节点开始,先递归访问左子树,再输出当前节点,最后访问右子树;' + '遇到空节点立即返回,并比较递归调用栈与显式栈的空间开销。' + ) + preset.status = 'ready' + preset.error_message = None + return preset + + def _ensure_trend(student_id): trend_data = { 'trend': '基础算法理解较稳定,循环控制与边界处理表现较好。', @@ -595,3 +757,411 @@ def is_demo_guided_assignment(assignment): and assignment.creator_id == DEMO_TEACHER_ID and DEMO_CLASS_NAME in assignment.get_target_class_list() ) + + +# --------------------------------------------------------------------------- +# Per-session fixture set +# --------------------------------------------------------------------------- +# +# The original fixture above was kept as a compatibility reference while the +# public demo was migrated away from the formal database. The definitions +# below are the active implementation. They deliberately use only the +# currently-bound SQLAlchemy session; the caller must activate a DemoRun +# before invoking seed_demo_experience(). + +from flask import has_request_context, session as flask_session + +from services.demo_database import ( + DEMO_ROLE_SESSION_KEY, + is_active_demo_run, +) + + +C_LANGUAGE_POINTS = ( + ('basic_syntax', '基础语法'), + ('pointer', '指针'), + ('function', '函数'), + ('array', '数组'), + ('string', '字符串'), + ('struct', '结构体'), + ('file_io', '文件操作'), + ('dynamic_memory', '动态内存'), + ('linked_list', '链表'), + ('tree', '树'), + ('sorting', '排序算法'), + ('searching', '搜索算法'), + ('recursion', '递归'), +) + + +DEMO_STUDENT_SPECS = ( + ('demo_s_001', 'student_demo_good', '赵一(优秀)', 4.6), + ('demo_s_002', 'student_demo_mid', '钱二(中等)', 3.7), + ('demo_s_003', 'student_demo_risk', '孙三(风险)', 2.2), + ('demo_s_004', 'student_demo_04', '周四', 3.1), + ('demo_s_005', 'student_demo_05', '吴五', 4.1), + ('demo_s_006', 'student_demo_06', '郑六', 2.8), + ('demo_s_007', 'student_demo_07', '王七', 3.5), + ('demo_s_008', 'student_demo_08', '冯八', 4.3), + ('demo_s_009', 'student_demo_09', '陈九', 2.6), + ('demo_s_010', 'student_demo_10', '褚十', 3.9), + ('demo_s_011', 'student_demo_11', '卫十一', 4.0), + ('demo_s_012', 'student_demo_12', '蒋十二', 3.3), +) + + +DEMO_ASSIGNMENT_SPECS = ( + { + 'key': 'guided_fibonacci', + 'title': DEMO_ASSIGNMENT_TITLE, + 'description': '使用循环计算斐波那契数列的前 N 项,并说明边界条件与空间复杂度。', + 'difficulty': 2, + 'knowledge': [('basic_syntax', 0.8, 1.5), ('array', 0.7, 1.8), ('recursion', 0.5, 2.0)], + 'cases': [('5', '0 1 1 2 3', True), ('8', '0 1 1 2 3 5 8 13', False), ('0', '', True)], + }, + { + 'key': 'guided_tree', + 'title': DEMO_SECOND_ASSIGNMENT_TITLE, + 'description': '实现二叉树的中序遍历,比较递归写法与显式栈写法的空间开销。', + 'difficulty': 4, + 'knowledge': [('tree', 1.0, 4.0), ('recursion', 0.9, 3.5), ('struct', 0.8, 3.5)], + 'cases': [('1 2 3', '2 1 3', True), ('7 3 9 1 5', '1 3 5 7 9', True)], + }, + { + 'key': 'pointer_array', + 'title': '作业三:指针与数组的边界管理', + 'description': '使用指针遍历整数数组,完成最大值、最小值和平均值计算,并处理空数组。', + 'difficulty': 3, + 'knowledge': [('pointer', 1.0, 3.0), ('array', 1.0, 2.5), ('function', 0.7, 2.0)], + 'cases': [('4\\n3 1 9 2', '9 1 3.75', True), ('0', 'empty', False)], + }, + { + 'key': 'linked_list', + 'title': '作业四:链表节点插入与释放', + 'description': '定义链表节点,完成头插、尾插和遍历操作,说明动态内存释放时机。', + 'difficulty': 4, + 'knowledge': [('linked_list', 1.0, 4.0), ('dynamic_memory', 1.0, 4.0), ('struct', 0.8, 3.5)], + 'cases': [('3\\n1 2 3', '1 2 3', True), ('0', 'empty', True)], + }, + { + 'key': 'file_io', + 'title': '作业五:文本文件统计器', + 'description': '读取文本文件并统计字符、单词和行数,正确处理文件打开失败的情况。', + 'difficulty': 3, + 'knowledge': [('file_io', 1.0, 3.0), ('string', 0.8, 2.5), ('basic_syntax', 0.6, 1.5)], + 'cases': [('hello\\nworld', '2 lines', True), ('', '0 lines', True)], + }, + { + 'key': 'sorting_search', + 'title': '作业六:排序与二分查找综合练习', + 'description': '实现插入排序和二分查找,比较不同数据规模下的时间复杂度。', + 'difficulty': 4, + 'knowledge': [('sorting', 1.0, 3.5), ('searching', 1.0, 3.5), ('function', 0.7, 2.0)], + 'cases': [('5\\n5 2 4 1 3', '1 2 3 4 5', True), ('3\\n2 4 6', 'not found', False)], + }, +) + + +def _structured_demo_feedback(score, seed): + """Return historical evaluator-shaped feedback on the 0–5 scale.""" + offsets = (0.2, -0.1, 0.1, -0.2, 0.0) + dimensions = { + key: round(max(0.0, min(5.0, float(score) + offsets[(seed + index) % len(offsets)])), 1) + for index, key in enumerate(( + 'algorithm_score', + 'style_score', + 'functionality_score', + 'efficiency_score', + 'readability_score', + )) + } + dimensions.update({ + 'overall_score': round(float(score), 1), + 'strengths': ['能够拆分输入、处理和输出流程', '变量命名与函数边界较清楚'], + 'suggestions': ['补充边界条件测试', '尝试解释时间复杂度与空间复杂度'], + }) + return _json(dimensions) + + +def _ensure_history_submission( + student_id, + assignment_id, + score, + attempt_index, + submitted_at, + assignment_title, +): + """Upsert one deterministic historical submission without touching new work.""" + marker = f'/* CodeSense demo history: {student_id}/{assignment_id}/{attempt_index} */' + submission = Submission.query.filter( + Submission.student_id == student_id, + Submission.assignment_id == assignment_id, + Submission.code.like(f'{marker}%'), + ).first() + if not submission: + submission = Submission( + student_id=student_id, + assignment_id=assignment_id, + code=marker, + ) + db.session.add(submission) + + score = max(0, min(5, int(round(score)))) + passed = 3 if score >= 4 else 2 if score >= 3 else 1 if score > 0 else 0 + submission.code = marker + f'\n/* {assignment_title} 示例历史记录 */\nint main(void) {{ return {score}; }}' + submission.score = score + submission.language = 'c' + submission.status = 'evaluated' + submission.feedback = ( + '本次提交已完成基础评测。' if score >= 3 + else '核心思路已经出现,建议继续检查边界条件和指针安全。' + ) + submission.ai_feedback = _structured_demo_feedback(score, attempt_index) + submission.sandbox_status = 'passed' if passed == 3 else 'partial' if passed else 'failed' + submission.sandbox_passed = passed + submission.sandbox_total = 3 + submission.sandbox_detail = _json({ + 'cases': [ + {'index': 1, 'status': 'passed' if passed >= 1 else 'failed'}, + {'index': 2, 'status': 'passed' if passed >= 2 else 'failed'}, + {'index': 3, 'status': 'passed' if passed >= 3 else 'failed'}, + ] + }) + submission.submitted_at = submitted_at + return submission + + +def _seed_demo_knowledge_scores(student, student_index): + """Seed all C-language dimensions with meaningful 0–100 profile values.""" + base = float(student.user_ascore or 3.0) * 20.0 + for point_index, (key, _name) in enumerate(C_LANGUAGE_POINTS): + variation = ((student_index * 7 + point_index * 11) % 19) - 9 + score = round(max(28.0, min(96.0, base + 18.0 + variation)), 1) + attempts = 2 + ((student_index + point_index) % 5) + correct = max(0, min(attempts, round(attempts * score / 100.0))) + _ensure_knowledge_score( + student.student_id, + key, + score, + attempts, + correct, + round(1.5 + ((student_index + point_index) % 4) * 0.6, 1), + ) + + +def _ensure_pending_trend(student_id, submission_count, student_index): + """Create numeric trend history while leaving narrative generation to real AI.""" + trend = AbilityTrend.query.filter_by(student_id=student_id).first() + if not trend: + trend = AbilityTrend(student_id=student_id, status='pending') + db.session.add(trend) + + if not trend.trend_data: + start = 48 + ((student_index * 5) % 15) + trend.trend_data = _json({ + 'labels': [f'D-{offset}' for offset in range(13, -1, -1)], + 'scores': [ + round(max(0, min(100, start + offset * (1.5 + student_index % 3))), 1) + for offset in range(14) + ], + 'submission_count': submission_count, + 'source': 'demo_fixture_history', + }) + trend.submissions_count = submission_count + if not trend.last_updated: + trend.last_updated = dt.utcnow() + if not trend.analysis_markdown and trend.status not in ('failed', 'processing'): + trend.status = 'pending' + return trend + + +def _ensure_pending_teacher_suggestion(demo_class): + suggestion = TeacherAISuggestion.query.filter_by(class_id=demo_class.id).first() + if not suggestion: + suggestion = TeacherAISuggestion( + class_id=demo_class.id, + teacher_id=DEMO_TEACHER_ID, + status='pending', + ) + db.session.add(suggestion) + suggestion.teacher_id = DEMO_TEACHER_ID + if not suggestion.suggestion_markdown and suggestion.status != 'failed': + suggestion.status = 'pending' + if not suggestion.last_updated: + suggestion.last_updated = dt.utcnow() + return suggestion + + +def _refresh_assignment_stats(assignment): + """Keep assignment aggregates on the same 0–5 scale as submissions.""" + submissions = Submission.query.filter_by(assignment_id=assignment.id).all() + scores = [submission.score for submission in submissions if submission.score is not None] + assignment.count = len(submissions) + assignment.total_score = sum(scores) if scores else 0 + assignment.average_score = round(sum(scores) / len(scores), 2) if scores else 0.0 + + +def _demo_run_from_request(): + if not has_request_context(): + return None + run_id = current_demo_run_id() + if not run_id: + return None + role = flask_session.get(DEMO_ROLE_SESSION_KEY) or 'student' + return DemoRun( + run_id=run_id, + role=role, + student_id=DEMO_STUDENT_ID, + teacher_id=DEMO_TEACHER_ID, + db_path=str(_db_path(run_id)), + created_at=dt.utcnow(), + ) + + +def seed_demo_experience(run: DemoRun) -> DemoExperience: + """Seed one rich demo workspace into the explicitly active temporary DB.""" + if not isinstance(run, DemoRun): + raise TypeError('seed_demo_experience 需要 DemoRun') + if not is_active_demo_run(run.run_id): + raise RuntimeError('体验临时数据库尚未激活,拒绝写入其他数据库') + + teacher = _ensure_user( + DEMO_TEACHER_ID, + DEMO_TEACHER_USERNAME, + '教师', + '李老师(演示)', + DEMO_TEACHER_PASSWORD, + user_ascore=4.8, + ) + _set_unique_email(teacher, 'teacher_demo@codesense.edu') + demo_class = _ensure_class(teacher) + + students = [] + for index, (student_id, username, full_name, ascore) in enumerate(DEMO_STUDENT_SPECS): + student = _ensure_user( + student_id, + username, + '学生', + full_name, + DEMO_STUDENT_PASSWORD, + class_id=demo_class.id, + class_name=demo_class.name, + user_ascore=ascore, + ) + _set_unique_email(student, f'{username}@demo.codesense.edu') + students.append(student) + + db.session.flush() + + for student in students: + _ensure_roster(student.student_id, student.full_name, demo_class, student.student_id) + # Keep a couple of pending roster entries so class management also shows + # the registration workflow without creating fake users for them. + _ensure_roster('demo_r_013', '何十三(待注册)', demo_class) + _ensure_roster('demo_r_014', '吕十四(待注册)', demo_class) + + assignments = {} + now = dt.utcnow() + for assignment_index, spec in enumerate(DEMO_ASSIGNMENT_SPECS): + assignment = _ensure_assignment( + spec['title'], + spec['description'], + spec['difficulty'], + demo_class, + ) + assignment.created_time = now - timedelta(days=35 - assignment_index * 4) + assignment.due_date = now + timedelta(days=7 + assignment_index * 2) + db.session.flush() + for knowledge_point, weight, difficulty in spec['knowledge']: + _ensure_assignment_knowledge(assignment, knowledge_point, weight, difficulty) + for case_index, (input_data, expected_output, is_public) in enumerate(spec['cases']): + _ensure_test_case(assignment, input_data, expected_output, is_public, case_index + 1) + assignments[spec['key']] = assignment + + db.session.flush() + assignment_list = list(assignments.values()) + + # The primary demo student has a visible progression history; the other + # students provide the teacher dashboard with enough cross-sectional data. + for student_index, student in enumerate(students): + _seed_demo_knowledge_scores(student, student_index) + if student_index == 0: + selected = ( + list(enumerate(assignment_list)) + + [(attempt_index + len(assignment_list), assignment) + for attempt_index, assignment in enumerate(assignment_list)] + ) + else: + selected = [ + (offset, assignment_list[(student_index + offset) % len(assignment_list)]) + for offset in range(4) + ] + for attempt_index, assignment in selected: + score = ((student_index * 2 + attempt_index * 3) % 5) + 1 + if student_index == 0: + score = min(5, max(2, 3 + ((attempt_index + 1) % 3))) + submitted_at = now - timedelta( + days=(student_index * 2 + attempt_index) % 14, + hours=(attempt_index * 3) % 8, + ) + _ensure_history_submission( + student.student_id, + assignment.id, + score, + attempt_index, + submitted_at, + assignment.title, + ) + + for assignment in assignment_list: + _refresh_assignment_stats(assignment) + for student_index, student in enumerate(students): + submissions_count = Submission.query.filter_by(student_id=student.student_id).count() + student.submit_count = submissions_count + _ensure_pending_trend(student.student_id, submissions_count, student_index) + + demo_class.student_count = len(students) + demo_class.avg_score = round( + sum(student.user_ascore for student in students) / len(students), 2 + ) + demo_class.total_submissions = Submission.query.join(User).filter( + User.class_id == demo_class.id, + ).count() + + _ensure_pending_teacher_suggestion(demo_class) + _ensure_preset(assignments['guided_fibonacci']) + _ensure_tree_preset(assignments['guided_tree']) + + db.session.commit() + return DemoExperience( + teacher_id=teacher.student_id, + student_id=students[0].student_id, + class_id=demo_class.id, + assignment_id=assignments['guided_fibonacci'].id, + second_assignment_id=assignments['guided_tree'].id, + ) + + +def ensure_demo_experience(run=None): + """Compatibility wrapper that only works when a demo run is active.""" + run = run or _demo_run_from_request() + if run is None: + raise RuntimeError('公开体验数据必须写入临时数据库') + return seed_demo_experience(run) + + +def get_demo_assignment_id(run_id: str, key: str = 'guided_fibonacci') -> int: + """Return a seeded demo assignment id from the active run.""" + if not is_active_demo_run(run_id): + raise RuntimeError('体验临时数据库尚未激活') + title_by_key = {spec['key']: spec['title'] for spec in DEMO_ASSIGNMENT_SPECS} + title = title_by_key.get(key) + if not title: + raise KeyError(f'未知的演示作业标识: {key}') + assignment = Assignment.query.filter_by( + title=title, + creator_id=DEMO_TEACHER_ID, + ).first() + if assignment is None: + raise LookupError(f'演示作业尚未初始化: {key}') + return assignment.id diff --git a/tests/demo_test_utils.py b/tests/demo_test_utils.py index f3da04f..b198038 100644 --- a/tests/demo_test_utils.py +++ b/tests/demo_test_utils.py @@ -5,6 +5,7 @@ from app import create_app from models import db +from services.demo_database import destroy_all_demo_runs def create_test_app(): @@ -28,8 +29,11 @@ def create_test_app(): def destroy_test_app(app): """释放测试应用占用的临时数据库文件。""" - with app.app_context(): - db.session.remove() - db.drop_all() - os.close(app._demo_test_db_fd) - os.unlink(app._demo_test_db_path) + try: + with app.app_context(): + db.session.remove() + db.drop_all() + finally: + os.close(app._demo_test_db_fd) + os.unlink(app._demo_test_db_path) + destroy_all_demo_runs() diff --git a/tests/test_demo_database_isolation.py b/tests/test_demo_database_isolation.py new file mode 100644 index 0000000..0998f39 --- /dev/null +++ b/tests/test_demo_database_isolation.py @@ -0,0 +1,102 @@ +import json +import os +import sqlite3 +import unittest + +from models import Assignment, Class, Submission, User +from services.demo_database import _db_path +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoDatabaseFixtureTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + self.client = self.app.test_client() + + def tearDown(self): + destroy_test_app(self.app) + + def _run_id(self): + with self.client.session_transaction() as demo_session: + return demo_session['demo_run_id'] + + def test_student_demo_starts_with_rich_realistic_fixture(self): + response = self.client.get('/demo-login/student') + + self.assertEqual(response.status_code, 302) + run_id = self._run_id() + db_path = str(_db_path(run_id)) + self.assertTrue(os.path.exists(db_path)) + + connection = sqlite3.connect(db_path) + try: + users = connection.execute('SELECT COUNT(*) FROM users').fetchone()[0] + assignments = connection.execute('SELECT COUNT(*) FROM assignments').fetchone()[0] + submissions = connection.execute('SELECT COUNT(*) FROM submissions').fetchone()[0] + knowledge_points = connection.execute( + "SELECT COUNT(*) FROM knowledge_point_scores WHERE student_id = 'demo_s_001'" + ).fetchone()[0] + presets = connection.execute( + 'SELECT COUNT(*) FROM assignment_thinking_presets' + ).fetchone()[0] + scores = [ + row[0] + for row in connection.execute( + "SELECT score FROM submissions WHERE student_id = 'demo_s_001'" + ) + if row[0] is not None + ] + feedback = connection.execute( + "SELECT ai_feedback FROM submissions " + "WHERE student_id = 'demo_s_001' AND ai_feedback IS NOT NULL LIMIT 1" + ).fetchone()[0] + finally: + connection.close() + + self.assertGreaterEqual(users, 10) + self.assertGreaterEqual(assignments, 6) + self.assertGreaterEqual(submissions, 10) + self.assertGreaterEqual(knowledge_points, 13) + self.assertGreaterEqual(presets, 2) + self.assertTrue(scores) + self.assertGreaterEqual(min(scores), 0) + self.assertLessEqual(max(scores), 5) + feedback_data = json.loads(feedback) + self.assertIn('algorithm_score', feedback_data) + self.assertIn('readability_score', feedback_data) + + with self.app.app_context(): + self.assertEqual(User.query.count(), 0) + self.assertEqual(Class.query.count(), 0) + self.assertEqual(Assignment.query.count(), 0) + self.assertEqual(Submission.query.count(), 0) + + def test_teacher_demo_starts_with_roster_trend_and_suggestions(self): + response = self.client.get('/demo-login/teacher') + + self.assertEqual(response.status_code, 302) + run_id = self._run_id() + db_path = str(_db_path(run_id)) + connection = sqlite3.connect(db_path) + try: + students = connection.execute( + "SELECT COUNT(*) FROM users WHERE usertype = '学生'" + ).fetchone()[0] + assignments = connection.execute('SELECT COUNT(*) FROM assignments').fetchone()[0] + submissions = connection.execute('SELECT COUNT(*) FROM submissions').fetchone()[0] + trends = connection.execute('SELECT COUNT(*) FROM ability_trends').fetchone()[0] + suggestions = connection.execute( + 'SELECT COUNT(*) FROM teacher_ai_suggestions' + ).fetchone()[0] + finally: + connection.close() + + self.assertGreaterEqual(students, 10) + self.assertGreaterEqual(assignments, 6) + self.assertGreaterEqual(submissions, 25) + self.assertGreaterEqual(trends, 4) + self.assertGreaterEqual(suggestions, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_demo_experience.py b/tests/test_demo_experience.py index 9afd8a4..4e43680 100644 --- a/tests/test_demo_experience.py +++ b/tests/test_demo_experience.py @@ -13,12 +13,13 @@ User, db, ) +from services.demo_database import activate_demo_run, create_demo_run, destroy_demo_run from services.demo_experience import ( DEMO_ASSIGNMENT_TITLE, DEMO_CLASS_NAME, DEMO_STUDENT_ID, DEMO_TEACHER_ID, - ensure_demo_experience, + seed_demo_experience, ) from tests.demo_test_utils import create_test_app, destroy_test_app @@ -26,24 +27,29 @@ class DemoExperienceTestCase(unittest.TestCase): def setUp(self): self.app = create_test_app() + with self.app.app_context(): + self.run = create_demo_run('student') + self.assertTrue(activate_demo_run(self.run.run_id)) + self.demo = seed_demo_experience(self.run) def tearDown(self): + destroy_demo_run(self.run.run_id) destroy_test_app(self.app) - def test_seed_is_complete_and_idempotent(self): + def test_seed_is_complete_and_idempotent_inside_one_temporary_database(self): with self.app.app_context(): - first = ensure_demo_experience() + self.assertTrue(activate_demo_run(self.run.run_id)) first_session = ThinkingSession( student_id=DEMO_STUDENT_ID, - assignment_id=first.assignment_id, + assignment_id=self.demo.assignment_id, current_stage=2, stage1_description='我已经完成了循环分析。', ) first_submission = Submission( student_id=DEMO_STUDENT_ID, - assignment_id=first.assignment_id, - code='int main() { return 0; }', - score=88, + assignment_id=self.demo.assignment_id, + code='int main(void) { return 4; }', + score=4, status='evaluated', ) db.session.add_all([first_session, first_submission]) @@ -51,36 +57,45 @@ def test_seed_is_complete_and_idempotent(self): session_id = first_session.id submission_id = first_submission.id - second = ensure_demo_experience() + second = seed_demo_experience(self.run) - self.assertEqual(first.teacher_id, DEMO_TEACHER_ID) - self.assertEqual(first.student_id, DEMO_STUDENT_ID) - self.assertEqual(first.class_id, second.class_id) - self.assertEqual(first.assignment_id, second.assignment_id) - self.assertEqual(User.query.filter_by(student_id=DEMO_TEACHER_ID).count(), 1) + self.assertEqual(self.demo.teacher_id, DEMO_TEACHER_ID) + self.assertEqual(self.demo.student_id, DEMO_STUDENT_ID) + self.assertEqual(self.demo.class_id, second.class_id) + self.assertEqual(self.demo.assignment_id, second.assignment_id) + self.assertNotEqual(self.demo.assignment_id, self.demo.second_assignment_id) + self.assertEqual(User.query.count(), 13) # 1 teacher + 12 students self.assertEqual(User.query.filter_by(student_id=DEMO_STUDENT_ID).count(), 1) self.assertEqual(Class.query.filter_by(name=DEMO_CLASS_NAME).count(), 1) - self.assertEqual(Assignment.query.filter_by(title=DEMO_ASSIGNMENT_TITLE).count(), 1) + self.assertEqual(Assignment.query.count(), 6) self.assertIsNotNone(ThinkingSession.query.get(session_id)) self.assertIsNotNone(Submission.query.get(submission_id)) + self.assertGreaterEqual(Submission.query.filter_by(student_id=DEMO_STUDENT_ID).count(), 13) - assignment = Assignment.query.get(first.assignment_id) + assignment = Assignment.query.get(self.demo.assignment_id) self.assertGreaterEqual(AssignmentTestCase.query.filter_by(assignment_id=assignment.id).count(), 2) - preset = AssignmentThinkingPreset.query.filter_by(assignment_id=assignment.id).one() - self.assertEqual(preset.status, 'ready') - self.assertTrue(preset.reference_code) - self.assertTrue(json.loads(preset.key_steps)) - self.assertTrue(json.loads(preset.code_blocks)) - self.assertTrue(json.loads(preset.quiz_steps)) + presets = AssignmentThinkingPreset.query.order_by(AssignmentThinkingPreset.assignment_id).all() + self.assertEqual(len(presets), 2) + for preset in presets: + self.assertEqual(preset.status, 'ready') + self.assertTrue(preset.reference_code) + self.assertTrue(json.loads(preset.key_steps)) + self.assertTrue(json.loads(preset.code_blocks)) + self.assertTrue(json.loads(preset.quiz_steps)) trend = AbilityTrend.query.filter_by(student_id=DEMO_STUDENT_ID).one() - self.assertEqual(trend.status, 'completed') - self.assertTrue(trend.analysis_markdown) + self.assertIn(trend.status, ('pending', 'completed', 'processing', 'failed')) self.assertTrue(json.loads(trend.trend_data)) - suggestion = TeacherAISuggestion.query.filter_by(class_id=first.class_id).one() - self.assertEqual(suggestion.status, 'completed') - self.assertIn('演示', suggestion.suggestion_markdown) + suggestion = TeacherAISuggestion.query.filter_by(class_id=self.demo.class_id).one() + self.assertIn(suggestion.status, ('pending', 'completed', 'processing', 'failed')) + + # The formal database is deliberately never used by the fixture. + with self.app.app_context(): + self.assertEqual(User.query.count(), 0) + self.assertEqual(Class.query.count(), 0) + self.assertEqual(Assignment.query.count(), 0) + self.assertEqual(Submission.query.count(), 0) if __name__ == '__main__': diff --git a/tests/test_demo_guided_learning.py b/tests/test_demo_guided_learning.py index 702203a..c688a19 100644 --- a/tests/test_demo_guided_learning.py +++ b/tests/test_demo_guided_learning.py @@ -3,7 +3,8 @@ from pathlib import Path from models import Assignment, AssignmentThinkingPreset, ThinkingSession, User, db -from services.demo_experience import ensure_demo_experience +from services.demo_database import activate_demo_run +from services.demo_experience import get_demo_assignment_id from tests.demo_test_utils import create_test_app, destroy_test_app @@ -12,7 +13,6 @@ def setUp(self): self.app = create_test_app() self.client = self.app.test_client() with self.app.app_context(): - self.demo = ensure_demo_experience() teacher = User( student_id='regular_teacher', username='regular_teacher', @@ -54,6 +54,17 @@ def setUp(self): db.session.commit() self.regular_assignment_id = regular_assignment.id + def _login_demo(self): + response = self.client.get('/demo-login/student') + self.assertEqual(response.status_code, 302) + with self.client.session_transaction() as client_session: + return client_session['demo_run_id'] + + def _demo_assignment_id(self, run_id, key='guided_fibonacci'): + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + return get_demo_assignment_id(run_id, key) + def tearDown(self): destroy_test_app(self.app) @@ -66,9 +77,10 @@ def test_demo_student_arena_has_demo_marker(self): self.assertIn('演示作业一:循环与斐波那契数列'.encode('utf-8'), arena_response.data) def test_demo_start_session_returns_all_three_stage_preset_data(self): - self.client.get('/demo-login/student') + run_id = self._login_demo() + assignment_id = self._demo_assignment_id(run_id) response = self.client.post('/thinking/api/start_session', json={ - 'assignment_id': self.demo.assignment_id, + 'assignment_id': assignment_id, }) self.assertEqual(response.status_code, 200) @@ -104,10 +116,14 @@ def test_frontend_exposes_four_demo_stage_shortcuts_but_keeps_auto_actions_local def test_public_demo_shortcuts_can_move_shared_session_through_all_stages(self): base_url = 'https://experience.codesense.test' self.client.get('/demo-login/student', base_url=base_url) + with self.client.session_transaction() as client_session: + run_id = client_session['demo_run_id'] with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + demo_assignment_id = get_demo_assignment_id(run_id) shared_session = ThinkingSession( student_id='demo_s_001', - assignment_id=self.demo.assignment_id, + assignment_id=demo_assignment_id, ) db.session.add(shared_session) db.session.commit() @@ -123,6 +139,7 @@ def test_public_demo_shortcuts_can_move_shared_session_through_all_stages(self): self.assertTrue(response.get_json()['success']) with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) updated = ThinkingSession.query.get(session_id) self.assertEqual(updated.status, 'completed') self.assertTrue(updated.stage3_completed) @@ -132,16 +149,11 @@ def test_public_shortcut_rejects_regular_student_other_assignment_and_anonymous( with self.app.app_context(): regular_session = ThinkingSession( student_id='regular_student', - assignment_id=self.demo.assignment_id, - ) - other_assignment_session = ThinkingSession( - student_id='demo_s_001', assignment_id=self.regular_assignment_id, ) - db.session.add_all([regular_session, other_assignment_session]) + db.session.add(regular_session) db.session.commit() regular_session_id = regular_session.id - other_assignment_session_id = other_assignment_session.id self.client.post('/login', base_url=base_url, data={ 'username': 'regular_student', @@ -156,6 +168,17 @@ def test_public_shortcut_rejects_regular_student_other_assignment_and_anonymous( self.client.get('/logout', base_url=base_url) self.client.get('/demo-login/student', base_url=base_url) + with self.client.session_transaction() as client_session: + run_id = client_session['demo_run_id'] + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + other_assignment_session = ThinkingSession( + student_id='demo_s_001', + assignment_id=get_demo_assignment_id(run_id, 'guided_tree'), + ) + db.session.add(other_assignment_session) + db.session.commit() + other_assignment_session_id = other_assignment_session.id other_assignment_response = self.client.post( '/thinking/api/debug/jump_stage', base_url=base_url, diff --git a/tests/test_demo_login.py b/tests/test_demo_login.py index 0a0a887..0e9c104 100644 --- a/tests/test_demo_login.py +++ b/tests/test_demo_login.py @@ -1,18 +1,12 @@ import unittest -from services.demo_experience import ( - DEMO_STUDENT_ID, - DEMO_TEACHER_ID, - ensure_demo_experience, -) +from services.demo_experience import DEMO_STUDENT_ID, DEMO_TEACHER_ID from tests.demo_test_utils import create_test_app, destroy_test_app class DemoLoginTestCase(unittest.TestCase): def setUp(self): self.app = create_test_app() - with self.app.app_context(): - ensure_demo_experience() self.client = self.app.test_client() def tearDown(self): diff --git a/tests/test_demo_teacher_experience.py b/tests/test_demo_teacher_experience.py index 8581726..f0cea3a 100644 --- a/tests/test_demo_teacher_experience.py +++ b/tests/test_demo_teacher_experience.py @@ -1,7 +1,8 @@ import unittest from models import Class -from services.demo_experience import DEMO_CLASS_NAME, ensure_demo_experience +from services.demo_database import activate_demo_run +from services.demo_experience import DEMO_CLASS_NAME from tests.demo_test_utils import create_test_app, destroy_test_app @@ -9,9 +10,6 @@ class DemoTeacherExperienceTestCase(unittest.TestCase): def setUp(self): self.app = create_test_app() self.client = self.app.test_client() - with self.app.app_context(): - self.demo = ensure_demo_experience() - self.class_id = Class.query.filter_by(name=DEMO_CLASS_NAME).one().id def tearDown(self): destroy_test_app(self.app) @@ -19,6 +17,11 @@ def tearDown(self): def test_teacher_can_browse_dashboard_class_detail_and_ai_suggestions(self): login_response = self.client.get('/demo-login/teacher') self.assertEqual(login_response.status_code, 302) + with self.client.session_transaction() as client_session: + run_id = client_session['demo_run_id'] + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + self.class_id = Class.query.filter_by(name=DEMO_CLASS_NAME).one().id dashboard = self.client.get('/home', follow_redirects=True) class_detail = self.client.get(f'/classes/{self.class_id}') @@ -26,7 +29,6 @@ def test_teacher_can_browse_dashboard_class_detail_and_ai_suggestions(self): self.assertEqual(dashboard.status_code, 200) self.assertIn('软件工程24-演示班'.encode('utf-8'), dashboard.data) - self.assertIn('孙三(风险)'.encode('utf-8'), dashboard.data) self.assertIn('演示作业一:循环与斐波那契数列'.encode('utf-8'), dashboard.data) self.assertEqual(class_detail.status_code, 200) @@ -36,8 +38,8 @@ def test_teacher_can_browse_dashboard_class_detail_and_ai_suggestions(self): self.assertEqual(suggestions.status_code, 200) self.assertIn('学情建议'.encode('utf-8'), suggestions.data) - self.assertIn('边界条件'.encode('utf-8'), suggestions.data) - self.assertIn('孙三'.encode('utf-8'), suggestions.data) + self.assertIn('AI'.encode('utf-8'), suggestions.data) + self.assertIn('刷新 AI 建议'.encode('utf-8'), suggestions.data) if __name__ == '__main__': From 957622d09233fe2b3a651f14d99ef012a56c94c2 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Wed, 26 Aug 2026 17:07:36 +0800 Subject: [PATCH 08/12] feat: isolate demo evaluation and real AI analysis --- routes/api.py | 22 +- routes/assignments.py | 6 +- routes/auth.py | 8 + routes/main.py | 23 +- routes/users.py | 12 +- services/ai_evaluator.py | 8 +- services/teacher_ai_advisor.py | 79 +++++- tasks/ability_analysis.py | 224 ++++++++++------ tasks/submission_tasks.py | 343 ++++++++++++++++-------- tests/test_demo_ai_refresh.py | 94 +++++++ tests/test_demo_submission_isolation.py | 78 ++++++ 11 files changed, 675 insertions(+), 222 deletions(-) create mode 100644 tests/test_demo_ai_refresh.py create mode 100644 tests/test_demo_submission_isolation.py diff --git a/routes/api.py b/routes/api.py index 39ed004..29705bf 100644 --- a/routes/api.py +++ b/routes/api.py @@ -13,6 +13,7 @@ from utils.code_advisor import generate_code_advice # 导入新的代码建议系统 from services.ai_evaluator import AIEvaluator from services.api_keys import api_keys # 导入 API 密钥管理器 +from services.demo_database import current_demo_run_id import json import traceback import os @@ -299,6 +300,21 @@ def submit_code(): assignment.average_score = assignment.total_score / assignment.count db.session.commit() + + # 与网页提交保持一致:每次成功提交都刷新学生能力分析。 + # demo 请求携带 run id,后台任务因此只会写入当前临时库。 + try: + from tasks.ability_analysis import trigger_analysis_if_needed + + AbilityTrend.mark_as_outdated(student_id) + trigger_analysis_if_needed( + student_id, + demo_run_id=current_demo_run_id(), + ) + except Exception as analysis_error: + current_app.logger.warning( + "提交后的能力分析刷新未启动: %s", analysis_error + ) return api_response( success=True, @@ -1004,6 +1020,7 @@ def stream_ability_analysis(): from flask import current_app, stream_with_context from models import KnowledgePointScore, AbilityTrend from tasks.ability_analysis import trigger_analysis_if_needed + demo_run_id = current_demo_run_id() def generate(): try: @@ -1026,7 +1043,10 @@ def generate(): # 如果没有缓存或需要更新,触发后台生成 if not ability_trend or ability_trend.status in ['pending', 'outdated', 'failed']: # 触发后台任务 - trigger_analysis_if_needed(student_id) + trigger_analysis_if_needed( + student_id, + demo_run_id=demo_run_id, + ) # 返回提示信息 yield f"data: {json.dumps({'type': 'analysis_start'})}\n\n" diff --git a/routes/assignments.py b/routes/assignments.py index 114556b..7a266bb 100644 --- a/routes/assignments.py +++ b/routes/assignments.py @@ -8,6 +8,7 @@ from utils.auth import login_required, admin_required, teacher_required, admin_or_teacher_required from utils.code_evaluator import evaluate_cpp_code, initialize_models from tasks.submission_tasks import evaluate_submission_async +from services.demo_database import current_demo_run_id from io import BytesIO from sqlalchemy import desc import traceback # 添加traceback模块 @@ -549,7 +550,8 @@ def submit_code(assignment_id): evaluate_submission_async( current_app._get_current_object(), submission.id, - assignment.title + assignment.title, + demo_run_id=current_demo_run_id(), ) print(f"已为提交 {submission.id} 启动后台评测") @@ -1199,4 +1201,4 @@ def edit_assignment(assignment_id): db.session.rollback() flash(f'更新作业失败: {str(e)}', 'danger') - return render_template('edit_assignment.html', form=form, assignment=assignment) \ No newline at end of file + return render_template('edit_assignment.html', form=form, assignment=assignment) diff --git a/routes/auth.py b/routes/auth.py index 16b94c6..c492098 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -128,6 +128,14 @@ def demo_login(role): demo = seed_demo_experience(run) login_demo_run(run) if role == 'student': + # 真实环境首次进入体验时即启动一次能力分析,分析结果会被 + # 缓存在本次临时库中;测试环境由专门的任务测试控制线程。 + if not current_app.config.get('TESTING'): + from tasks.ability_analysis import trigger_analysis_if_needed + trigger_analysis_if_needed( + DEMO_STUDENT_ID, + demo_run_id=run.run_id, + ) return redirect(url_for('thinking.arena', assignment_id=demo.assignment_id)) return redirect(url_for('main.home')) except Exception: diff --git a/routes/main.py b/routes/main.py index 37bb181..50181c8 100644 --- a/routes/main.py +++ b/routes/main.py @@ -10,6 +10,7 @@ from sqlalchemy import func from models import db, User, Assignment, Submission, SystemLog, SystemConfig from services.teacher_analytics import build_teacher_dashboard_data +from services.demo_database import current_demo_run_id from utils.auth import admin_required from utils.maturity_calculator import calculate_maturity_components @@ -601,7 +602,12 @@ def teacher_ai_suggestions(): # 异步触发生成 from services.teacher_ai_advisor import generate_class_suggestions_async from flask import current_app - generate_class_suggestions_async(cls.id, teacher.student_id, current_app._get_current_object()) + generate_class_suggestions_async( + cls.id, + teacher.student_id, + current_app._get_current_object(), + demo_run_id=current_demo_run_id(), + ) class_suggestions.append({ 'class': cls, @@ -639,7 +645,12 @@ def api_generate_teacher_suggestions(): sug.status = 'pending' db.session.commit() - generate_class_suggestions_async(cls.id, current_user.student_id, current_app._get_current_object()) + generate_class_suggestions_async( + cls.id, + current_user.student_id, + current_app._get_current_object(), + demo_run_id=current_demo_run_id(), + ) return jsonify({'success': True, 'message': 'AI 建议生成任务已启动'}) @@ -688,7 +699,13 @@ def api_stream_teacher_suggestions(): from flask import Response, stream_with_context return Response( - stream_with_context(generate_class_suggestions_stream(cls.id, current_user.student_id)), + stream_with_context( + generate_class_suggestions_stream( + cls.id, + current_user.student_id, + demo_run_id=current_demo_run_id(), + ) + ), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', diff --git a/routes/users.py b/routes/users.py index 0052099..146346c 100644 --- a/routes/users.py +++ b/routes/users.py @@ -6,6 +6,7 @@ from models import db, User, Submission, SystemLog, Class, AbilityTrend from utils.auth import login_required, admin_required, admin_or_teacher_required from tasks.ability_analysis import trigger_analysis_if_needed +from services.demo_database import current_demo_run_id from sqlalchemy import desc from forms import ChangePasswordForm, EditProfileForm from werkzeug.utils import secure_filename @@ -228,7 +229,10 @@ def view_submissions(): # 如果没有分析或者已过期,尝试触发异步生成 if not ability_trend or ability_trend.status in ['pending', 'outdated', 'failed']: - trigger_analysis_if_needed(student_id) + trigger_analysis_if_needed( + student_id, + demo_run_id=current_demo_run_id(), + ) return render_template('submissions.html', submissions=submissions, @@ -255,7 +259,11 @@ def refresh_analysis(): return jsonify({'status': 'error', 'message': '未找到学生 ID'}), 401 # 强制触发重新分析 - triggered = trigger_analysis_if_needed(student_id, force=True) + triggered = trigger_analysis_if_needed( + student_id, + force=True, + demo_run_id=current_demo_run_id(), + ) if triggered: return jsonify({'status': 'success', 'message': '已启动深度能力分析分析,请稍后刷新页面查看结果'}) diff --git a/services/ai_evaluator.py b/services/ai_evaluator.py index eded3fa..8024d39 100644 --- a/services/ai_evaluator.py +++ b/services/ai_evaluator.py @@ -467,11 +467,12 @@ def analyze_ability_trend_stream(self, submissions: List[Dict]) -> Generator[str except json.JSONDecodeError: continue else: - yield "\n\n【能力发展趋势】\n从您的提交记录来看,编程能力呈现稳步提升的趋势。\n\n" - yield "【改进建议】\n1. 加强算法基础\n2. 提高代码规范性\n3. 增加测试用例\n" + raise RuntimeError( + f"能力分析 API 请求失败: HTTP {response.status_code}" + ) except Exception as e: print(f"流式分析出错: {str(e)}") - yield f"\n\n分析过程中出现错误: {str(e)}\n请稍后重试。" + raise RuntimeError("能力分析 AI 服务调用失败") from e def detect_code_knowledge_points(self, code: str, assignment_title: str) -> List[Dict]: """使用AI自动检测代码涉及的C语言知识点""" @@ -554,4 +555,3 @@ def _infer_knowledge_points_from_title(self, title: str) -> List[Dict]: knowledge_points.append({'knowledge_point': 'basic_syntax', 'weight': 1.0, 'difficulty': 1.0}) return knowledge_points - \ No newline at end of file diff --git a/services/teacher_ai_advisor.py b/services/teacher_ai_advisor.py index 9fbe6b9..286b800 100644 --- a/services/teacher_ai_advisor.py +++ b/services/teacher_ai_advisor.py @@ -4,6 +4,7 @@ from models import db, User, Class, KnowledgePointScore, Assignment, AssignmentKnowledgePoint, TeacherAISuggestion from services.teacher_analytics import build_class_learning_rows from services.llm_client import SharedLLMClient +from services.demo_database import activate_demo_run, is_active_demo_run # 线程锁,防止重复并发生成同一班级的AI建议 _generation_locks = {} @@ -16,10 +17,32 @@ def get_generation_lock(class_id): return _generation_locks[class_id] -def generate_class_suggestions(class_id, teacher_id): +def _demo_database_is_available(demo_run_id): + return not demo_run_id or is_active_demo_run(demo_run_id) + + +def _mark_demo_suggestion_failed(class_id, teacher_id): + suggestion = TeacherAISuggestion.get_or_create( + class_id=class_id, + teacher_id=teacher_id, + ) + if suggestion is None: + return None + suggestion.status = 'failed' + suggestion.suggestion_markdown = None + suggestion.suggestion_json = None + suggestion.last_updated = dt.utcnow() + db.session.commit() + return suggestion + + +def generate_class_suggestions(class_id, teacher_id, demo_run_id=None): """ 同步生成班级学情建议,计算规则引擎结果,并可选调用LLM增强 """ + if demo_run_id and not activate_demo_run(demo_run_id): + return None + lock = get_generation_lock(class_id) acquired = lock.acquire(blocking=False) if not acquired: @@ -230,7 +253,12 @@ def generate_class_suggestions(class_id, teacher_id): except Exception as le: print(f"LLM 接口调用或处理失败: {le}") - # Rules-based fallback + # 公开体验要求使用真实 AI。没有可用模型或模型返回内容不完整时, + # 明确记录失败,不能把规则引擎结果伪装成 AI 报告。 + if demo_run_id: + raise RuntimeError('AI 服务不可用或未返回有效班级建议') + + # Rules-based fallback(正式账户的历史兼容行为) suggestion.suggestion_markdown = rule_markdown suggestion.suggestion_json = json.dumps(rule_json_dict, ensure_ascii=False) suggestion.status = 'completed' @@ -241,12 +269,19 @@ def generate_class_suggestions(class_id, teacher_id): except Exception as e: db.session.rollback() print(f"生成AI建议失败: {e}") - try: - suggestion = TeacherAISuggestion.get_or_create(class_id=class_id, teacher_id=teacher_id) - suggestion.status = 'failed' - db.session.commit() - except: - pass + if _demo_database_is_available(demo_run_id): + try: + if demo_run_id: + _mark_demo_suggestion_failed(class_id, teacher_id) + else: + suggestion = TeacherAISuggestion.get_or_create( + class_id=class_id, + teacher_id=teacher_id, + ) + suggestion.status = 'failed' + db.session.commit() + except Exception: + db.session.rollback() return None finally: lock.release() @@ -291,13 +326,19 @@ def _generate_rule_based_markdown(cls, weak_points, attention_students, suggeste return markdown -def generate_class_suggestions_async(class_id, teacher_id, app): +def generate_class_suggestions_async(class_id, teacher_id, app, demo_run_id=None): """ 异步启动班级AI建议生成任务 """ def task(): with app.app_context(): - generate_class_suggestions(class_id, teacher_id) + if demo_run_id and not activate_demo_run(demo_run_id): + return + generate_class_suggestions( + class_id, + teacher_id, + demo_run_id=demo_run_id, + ) thread = threading.Thread(target=task) thread.daemon = True @@ -305,10 +346,14 @@ def task(): return thread -def generate_class_suggestions_stream(class_id, teacher_id): +def generate_class_suggestions_stream(class_id, teacher_id, demo_run_id=None): """ 流式生成班级学情建议,计算规则引擎结果,并流式输出LLM反馈报告,最后保存入库 """ + if demo_run_id and not activate_demo_run(demo_run_id): + yield f"data: {json.dumps({'type': 'error', 'message': '体验会话已结束,请重新进入演示'})}\n\n" + return + yield f"data: {json.dumps({'type': 'status', 'message': '正在读取班级基本数据...'})}\n\n" cls = Class.query.get(class_id) @@ -524,6 +569,18 @@ def generate_class_suggestions_stream(class_id, teacher_id): except Exception as le: print(f"LLM 流式分析失败: {le}") + if demo_run_id: + if _demo_database_is_available(demo_run_id): + _mark_demo_suggestion_failed(class_id, teacher_id) + yield f"data: {json.dumps({'type': 'error', 'message': '真实 AI 建议生成失败,请稍后重试'})}\n\n" + return + + elif demo_run_id: + if _demo_database_is_available(demo_run_id): + _mark_demo_suggestion_failed(class_id, teacher_id) + yield f"data: {json.dumps({'type': 'error', 'message': 'AI 服务当前不可用,请稍后重试'})}\n\n" + return + # Fallback to rules-based yield f"data: {json.dumps({'type': 'start'})}\n\n" chunk_size = 30 diff --git a/tasks/ability_analysis.py b/tasks/ability_analysis.py index d92f575..a369030 100644 --- a/tasks/ability_analysis.py +++ b/tasks/ability_analysis.py @@ -1,137 +1,189 @@ -"""后台能力分析任务""" +"""后台能力分析任务。 + +公开体验的分析任务必须携带自己的 demo run id。这样后台线程即使在 +请求结束后才执行,也只会查询和写入该体验会话的临时数据库。 +""" + +from __future__ import annotations + import threading -import os -from models import db, Submission, AbilityTrend +import time +import traceback +from datetime import datetime + +from models import AbilityTrend, Submission, db from services.ai_evaluator import AIEvaluator -from services.api_keys import api_keys # 导入 API 密钥管理器 -from flask import current_app +from services.api_keys import api_keys +from services.demo_database import activate_demo_run, is_active_demo_run -def generate_ability_analysis_async(app, student_id): - """ - 异步生成学生能力分析 - 在后台线程中执行,不阻塞主请求 +def _demo_database_is_available(demo_run_id: str | None) -> bool: + """Return whether a demo worker may still touch its temporary database.""" + + return not demo_run_id or is_active_demo_run(demo_run_id) + + +def _mark_analysis_failed(student_id: str) -> None: + """Persist an explicit failure without leaving stale AI text visible.""" - Args: - app: Flask应用实例 - student_id: 学生ID + trend = AbilityTrend.get_or_create(student_id) + if trend is None: + return + trend.status = "failed" + trend.analysis_markdown = None + trend.last_updated = datetime.utcnow() + db.session.commit() + + +def generate_ability_analysis_async(app, student_id, demo_run_id=None): + """异步生成学生能力分析。 + + ``demo_run_id`` 为空时保持正式账户的原有行为;传入时,线程启动后 + 会先绑定对应的临时数据库,若会话已经退出或过期则直接结束,不触碰 + 正式数据库。 """ + def _generate(): with app.app_context(): + if demo_run_id and not activate_demo_run(demo_run_id): + print(f"公开体验会话已失效,跳过能力分析任务: {demo_run_id}") + return + try: - # 1. 标记为处理中 + # 任何业务查询前都再次确认临时库仍然存在。退出体验时, + # destroy_demo_run 会把运行从缓存移除,避免后台线程继续写入。 + if not _demo_database_is_available(demo_run_id): + return + AbilityTrend.mark_as_processing(student_id) - # 2. 获取最近20次提交 - submissions = Submission.query.filter_by(student_id=student_id)\ - .order_by(Submission.submitted_at.desc())\ - .limit(20)\ + submissions = ( + Submission.query.filter_by(student_id=student_id) + .order_by(Submission.submitted_at.desc()) + .limit(20) .all() + ) if not submissions: - # 没有提交记录,标记为完成但无数据 - AbilityTrend.update_analysis( - student_id=student_id, - analysis_markdown="暂无提交记录,请先完成一些作业。", - submissions_count=0 - ) + if _demo_database_is_available(demo_run_id): + AbilityTrend.update_analysis( + student_id=student_id, + analysis_markdown="暂无提交记录,请先完成一些作业。", + submissions_count=0, + ) return - # 3. 准备提交数据 submission_data = [] - for sub in submissions: - if sub.code and sub.assignment: - submission_data.append({ - 'assignment_title': sub.assignment.title, - 'code': sub.code[:500], # 只取前500字符 - 'score': sub.score, - 'submitted_at': sub.submitted_at.strftime('%Y-%m-%d %H:%M') - }) - - # 4. 调用AI生成分析(添加超时重试机制) + for submission in submissions: + if submission.code and submission.assignment: + submission_data.append( + { + "assignment_title": submission.assignment.title, + "code": submission.code[:500], + "score": submission.score, + "submitted_at": ( + submission.submitted_at.strftime("%Y-%m-%d %H:%M") + if submission.submitted_at + else "未知时间" + ), + } + ) + + if not submission_data: + raise RuntimeError("没有可供 AI 分析的有效提交内容") + api_key = api_keys.zhipu_key if not api_key: - print(f"❌ AI服务未配置,无法生成分析 - 学生 {student_id}") - AbilityTrend.update_analysis( - student_id=student_id, - analysis_markdown="AI服务未配置,无法生成分析", - submissions_count=len(submissions) - ) - return + raise RuntimeError("AI 服务未配置") ai_evaluator = AIEvaluator(api_key) - - # 收集完整的Markdown分析(带重试) analysis_markdown = "" - print(f"🚀 开始后台生成能力分析 - 学生 {student_id}") + print(f"开始后台生成能力分析 - 学生 {student_id}") - max_retries = 2 - for attempt in range(max_retries): + last_error = None + for attempt in range(2): try: - for chunk in ai_evaluator.analyze_ability_trend_stream(submission_data): - analysis_markdown += chunk - - # 成功完成,跳出重试循环 + analysis_markdown = "" + for chunk in ai_evaluator.analyze_ability_trend_stream( + submission_data + ): + if chunk: + analysis_markdown += chunk + + if not analysis_markdown.strip(): + raise RuntimeError("AI 未返回有效分析内容") + last_error = None break except Exception as chunk_error: - print(f"⚠️ 生成分析时出错(尝试 {attempt + 1}/{max_retries}): {str(chunk_error)}") - if attempt < max_retries - 1: - print(f"⏳ 等待3秒后重试...") - import time + last_error = chunk_error + print( + f"生成能力分析失败(尝试 {attempt + 1}/2): " + f"{chunk_error}" + ) + if attempt == 0: time.sleep(3) - analysis_markdown = "" # 重置 - else: - # 最后一次重试也失败了 - raise - # 5. 保存到数据库 + if last_error is not None: + raise last_error + + if not _demo_database_is_available(demo_run_id): + return + AbilityTrend.update_analysis( student_id=student_id, - analysis_markdown=analysis_markdown, - submissions_count=len(submissions) + analysis_markdown=analysis_markdown.strip(), + submissions_count=len(submissions), + ) + print( + f"能力分析生成完成 - 学生 {student_id}, " + f"长度: {len(analysis_markdown)} 字符" ) - print(f"✅ 能力分析生成完成 - 学生 {student_id}, 长度: {len(analysis_markdown)} 字符") - - except Exception as e: - print(f"❌ 生成能力分析失败 - 学生 {student_id}: {str(e)}") - import traceback + except Exception as error: + print(f"生成能力分析失败 - 学生 {student_id}: {error}") traceback.print_exc() - # 标记为失败 - trend = AbilityTrend.get_or_create(student_id) - trend.status = 'failed' - db.session.commit() + # 失败处理仍在已经绑定的会话中执行。临时库被销毁时, + # 直接结束,绝不重新打开默认正式数据库。 + if not _demo_database_is_available(demo_run_id): + return + try: + db.session.rollback() + _mark_analysis_failed(student_id) + except Exception: + db.session.rollback() + traceback.print_exc() - # 在后台线程中执行 thread = threading.Thread(target=_generate) thread.daemon = True thread.start() - print(f"📤 已启动后台分析任务 - 学生 {student_id}") + print(f"已启动后台分析任务 - 学生 {student_id}") + return thread -def trigger_analysis_if_needed(student_id, force=False): - """ - 检查是否需要触发分析 - - Args: - student_id: 学生ID - force: 是否强制重新生成 +def trigger_analysis_if_needed(student_id, force=False, demo_run_id=None): + """检查并触发能力分析。 - Returns: - bool: 是否触发了新的分析任务 + 对公开体验而言,调用方必须显式传入当前 run id。未找到该临时库时 + 返回 ``False``,不会因为查询不到临时数据而误读正式库。 """ + from flask import current_app + if demo_run_id and not _demo_database_is_available(demo_run_id): + return False + trend = AbilityTrend.query.filter_by(student_id=student_id).first() - # 如果强制更新或没有分析记录,触发分析 - if force or not trend or trend.status in ['pending', 'outdated', 'failed']: - generate_ability_analysis_async(current_app._get_current_object(), student_id) + if force or not trend or trend.status in ["pending", "outdated", "failed"]: + generate_ability_analysis_async( + current_app._get_current_object(), + student_id, + demo_run_id=demo_run_id, + ) return True - # 如果正在处理中,不重复触发 - if trend.status == 'processing': + if trend.status == "processing": return False return False diff --git a/tasks/submission_tasks.py b/tasks/submission_tasks.py index 31cd09f..1a69c5c 100644 --- a/tasks/submission_tasks.py +++ b/tasks/submission_tasks.py @@ -1,172 +1,289 @@ -"""后台异步评测任务""" +"""后台异步评测任务。""" + +from __future__ import annotations + +import json import threading import traceback -import json -from datetime import datetime -from models import db, User, Assignment, Submission, SystemLog, TestCase as TC + +from models import Assignment, Submission, SystemLog, TestCase as TC, User, db +from services.demo_database import activate_demo_run, is_active_demo_run from utils.code_evaluator import evaluate_cpp_code, llm_evaluator from utils.sandbox_runner import run_test_cases -def evaluate_submission_async(app, submission_id, assignment_title): - """ - 异步评测学生提交的代码 - 触发后台线程执行,不阻塞 Flask 主请求 + +def _demo_database_is_available(demo_run_id: str | None) -> bool: + """Return whether this worker may still use its temporary database.""" + + return not demo_run_id or is_active_demo_run(demo_run_id) + + +def _normalise_score(score) -> int: + """Keep every persisted submission score inside the product's 0–5 scale.""" + + try: + return max(0, min(5, int(round(float(score))))) + except (TypeError, ValueError): + raise ValueError("评测器未返回有效分数") + + +def _refresh_assignment_stats(assignment: Assignment) -> None: + """Recalculate aggregates from evaluated submissions, including history.""" + + scores = [ + score + for (score,) in db.session.query(Submission.score) + .filter( + Submission.assignment_id == assignment.id, + Submission.status == "evaluated", + Submission.score.isnot(None), + ) + .all() + ] + assignment.count = len(scores) + assignment.total_score = sum(scores) + assignment.average_score = sum(scores) / len(scores) if scores else 0.0 + + +def _refresh_user_stats(student_id: str) -> None: + """Recalculate the student's summary from all evaluated submissions.""" + + user = db.session.get(User, student_id) + if user is None: + return + + scores = [ + score + for (score,) in db.session.query(Submission.score) + .filter( + Submission.student_id == student_id, + Submission.status == "evaluated", + Submission.score.isnot(None), + ) + .all() + ] + user.submit_count = len(scores) + user.user_tscore = sum(scores) + user.user_ascore = sum(scores) / len(scores) if scores else 0.0 + + +def _mark_submission_failed(submission_id: int, message: str) -> None: + """Mark one submission failed in the already-bound database.""" + + submission = db.session.get(Submission, submission_id) + if submission is None: + return + submission.status = "failed" + submission.feedback = message + db.session.commit() + + +def evaluate_submission_async(app, submission_id, assignment_title, demo_run_id=None): + """异步评测学生提交的代码。 + + ``demo_run_id`` 为空时使用正式数据库;公开体验传入该值后,线程会 + 先切换到对应的临时数据库,并在会话失效时直接停止。 """ + def _evaluate(): with app.app_context(): + if demo_run_id and not activate_demo_run(demo_run_id): + print(f"公开体验会话已失效,跳过提交评测: {demo_run_id}") + return + try: - submission = Submission.query.get(submission_id) + if not _demo_database_is_available(demo_run_id): + return + + submission = db.session.get(Submission, submission_id) if not submission: - print(f"❌ 找不到提交记录: {submission_id}") + print(f"找不到提交记录: {submission_id}") return - - assignment = Assignment.query.get(submission.assignment_id) + + assignment = db.session.get(Assignment, submission.assignment_id) + if assignment is None: + raise RuntimeError("提交对应的作业不存在") + code = submission.code student_id = submission.student_id - - # 1. AI 基础评估 (使用 C++ 评估器) - print(f"🚀 开始后台評估提交 {submission_id},题目: {assignment_title}") + + print(f"开始后台评估提交 {submission_id},题目: {assignment_title}") + + # 1. AI 基础评估。公开体验不接受默认分数,AI 失败必须 + # 让提交进入 failed,方便前端提示用户重新提交。 try: - score, feedback = evaluate_cpp_code(code, assignment_title=assignment_title) - - # 保存 AI 结构化数据 - if hasattr(llm_evaluator, '_last_structured_data'): + score, feedback = evaluate_cpp_code( + code, assignment_title=assignment_title + ) + score = _normalise_score(score) + + if hasattr(llm_evaluator, "_last_structured_data"): structured_data = llm_evaluator._last_structured_data - submission.ai_feedback = json.dumps(structured_data, ensure_ascii=False) - elif isinstance(feedback, str) and ("【" in feedback or "改进建议" in feedback): + if structured_data: + submission.ai_feedback = json.dumps( + structured_data, ensure_ascii=False + ) + elif isinstance(feedback, str) and ( + "【" in feedback or "改进建议" in feedback + ): submission.ai_feedback = feedback - + submission.score = score submission.feedback = feedback - except Exception as ai_err: - print(f"AI 评估过程出错: {ai_err}") + except Exception as ai_error: + print(f"AI 评估过程出错: {ai_error}") + if demo_run_id: + raise RuntimeError("AI 评测失败,请稍后重试") from ai_error + # 正式账户保留历史兼容行为;公开体验永远不会走到这条 + # 默认分支,避免把失败伪装成成功分数。 submission.score = 1 - submission.feedback = f"AI 评估过程中出错: {str(ai_err)}" - - # 2. 沙箱测试用例评判 + submission.feedback = f"AI 评估过程中出错: {ai_error}" + + # 2. 沙箱测试用例评判。 try: - test_cases = TC.query.filter_by(assignment_id=submission.assignment_id)\ - .order_by(TC.order_index).all() + test_cases = ( + TC.query.filter_by(assignment_id=submission.assignment_id) + .order_by(TC.order_index) + .all() + ) if test_cases: - tc_list = [tc.to_dict() for tc in test_cases] + tc_list = [test_case.to_dict() for test_case in test_cases] sandbox_result = run_test_cases(code, tc_list) - - submission.sandbox_status = sandbox_result['status'] - submission.sandbox_passed = sandbox_result['passed'] - submission.sandbox_total = sandbox_result['total'] - submission.sandbox_detail = json.dumps(sandbox_result['details'], ensure_ascii=False) - - # 根据沙箱结果修正分数 (5分制) - if sandbox_result['total'] > 0: - sandbox_score = (sandbox_result['passed'] / sandbox_result['total']) * 5 + + submission.sandbox_status = sandbox_result["status"] + submission.sandbox_passed = sandbox_result["passed"] + submission.sandbox_total = sandbox_result["total"] + submission.sandbox_detail = json.dumps( + sandbox_result["details"], ensure_ascii=False + ) + + if sandbox_result["total"] > 0: + sandbox_score = ( + sandbox_result["passed"] + / sandbox_result["total"] + * 5 + ) final_score = sandbox_score - - if sandbox_result['status'] == 'error': + if sandbox_result["status"] == "error": final_score = min(final_score, 1) - - submission.score = round(final_score) - print(f"沙箱评判完成: {submission.sandbox_passed}/{submission.sandbox_total}, 最终得分: {submission.score}") - except Exception as sandbox_err: - print(f"沙箱评判过程出错: {sandbox_err}") - - # 3. 更新完成状态 - submission.status = 'evaluated' - - # 4. 更新作业统计信息 - assignment.total_score += submission.score - assignment.count += 1 - assignment.average_score = assignment.total_score / assignment.count - - # 5. 更新用户统计信息 - user = User.query.get(student_id) - if user: - user.submit_count += 1 - user.user_tscore += submission.score - user.user_ascore = user.user_tscore / user.submit_count - + submission.score = _normalise_score(final_score) + print( + "沙箱评判完成: " + f"{submission.sandbox_passed}/{submission.sandbox_total}, " + f"最终得分: {submission.score}" + ) + except Exception as sandbox_error: + print(f"沙箱评判过程出错: {sandbox_error}") + if demo_run_id: + raise RuntimeError("沙箱评测失败,请稍后重试") from sandbox_error + + if not _demo_database_is_available(demo_run_id): + return + + # 3. 提交和统计信息均从完整历史重新计算,避免累加种子 + # 数据时出现重复统计或 100 分制残留。 + submission.status = "evaluated" + _refresh_assignment_stats(assignment) + _refresh_user_stats(student_id) db.session.commit() - # 6. 更新知识点评分 (新流程:在评测完成后进行) + # 4. 更新本次提交覆盖的知识点。 try: from models import AssignmentKnowledgePoint, KnowledgePointScore from services.ai_evaluator import AIEvaluator - - # 获取作业的知识点标签 + assignment_kps = AssignmentKnowledgePoint.query.filter_by( assignment_id=assignment.id ).all() if assignment_kps: - print(f"为作业 {assignment.id} 更新 {len(assignment_kps)} 个既有知识点分数") - for kp in assignment_kps: + for knowledge_point in assignment_kps: KnowledgePointScore.update_score( student_id=student_id, - knowledge_point=kp.knowledge_point, - assignment_score=submission.score * 20, # 转换为0-100分 - difficulty=kp.difficulty, - weight=kp.weight + knowledge_point=knowledge_point.knowledge_point, + assignment_score=submission.score * 20, + difficulty=knowledge_point.difficulty, + weight=knowledge_point.weight, ) else: - # 如果没有标注,使用AI自动检测 (也移到了后台) - print(f"作业 {assignment.id} 无知识点标签,使用AI自动检测") - api_key = app.config.get('ZHIPU_API_KEY') + api_key = app.config.get("ZHIPU_API_KEY") if api_key: ai_evaluator = AIEvaluator(api_key) - detected_kps = ai_evaluator.detect_code_knowledge_points(code, assignment.title) - + detected_kps = ai_evaluator.detect_code_knowledge_points( + code, assignment.title + ) for kp_data in detected_kps: AssignmentKnowledgePoint.add_to_assignment( assignment_id=assignment.id, - knowledge_point=kp_data['knowledge_point'], - weight=kp_data.get('weight', 1.0), - difficulty=kp_data.get('difficulty', 1.0), - auto_detected=True + knowledge_point=kp_data["knowledge_point"], + weight=kp_data.get("weight", 1.0), + difficulty=kp_data.get("difficulty", 1.0), + auto_detected=True, ) - KnowledgePointScore.update_score( student_id=student_id, - knowledge_point=kp_data['knowledge_point'], + knowledge_point=kp_data["knowledge_point"], assignment_score=submission.score * 20, - difficulty=kp_data.get('difficulty', 1.0), - weight=kp_data.get('weight', 1.0) + difficulty=kp_data.get("difficulty", 1.0), + weight=kp_data.get("weight", 1.0), ) - print(f"AI自动检测并更新了 {len(detected_kps)} 个知识点") - except Exception as kp_err: - print(f"更新知识点评分失败: {kp_err}") + except Exception as kp_error: + print(f"更新知识点评分失败: {kp_error}") + if demo_run_id: + raise RuntimeError("知识点画像更新失败,请稍后重试") from kp_error - # 7. 触发后台能力分析任务 + # 5. 每次成功提交都让能力分析进入刷新链路;demo run id + # 必须继续向下传递,异步分析不会误读正式库。 try: from tasks.ability_analysis import trigger_analysis_if_needed + from models import AbilityTrend + AbilityTrend.mark_as_outdated(student_id) - trigger_analysis_if_needed(student_id) - print(f"已触发学生 {student_id} 的全量能力分析任务") - except Exception as ability_err: - print(f"触发能力分析失败: {ability_err}") + trigger_analysis_if_needed( + student_id, demo_run_id=demo_run_id + ) + print(f"已触发学生 {student_id} 的能力分析刷新") + except Exception as ability_error: + print(f"触发能力分析失败: {ability_error}") + if demo_run_id: + raise RuntimeError("能力分析任务启动失败") from ability_error + if not _demo_database_is_available(demo_run_id): + return db.session.commit() - - # 8. 添加系统日志 - SystemLog.add_log( - log_type='评测完成', - content=f'提交 {submission_id} 评测已完成,得分:{submission.score}/5', - user_id=student_id, - icon='bi bi-check-circle-fill' - ) - print(f"✅ 提交 {submission_id} 评测全部完成") - - except Exception as e: - print(f"❌ 评测线程崩溃: {e}") + + # 公开体验不写正式系统日志,也不把临时访客动作混入 + # 管理端审计数据。 + if not demo_run_id: + SystemLog.add_log( + log_type="评测完成", + content=( + f"提交 {submission_id} 评测已完成," + f"得分:{submission.score}/5" + ), + user_id=student_id, + icon="bi bi-check-circle-fill", + ) + print(f"提交 {submission_id} 评测全部完成") + + except Exception as error: + print(f"评测线程崩溃: {error}") traceback.print_exc() - with app.app_context(): - sub = Submission.query.get(submission_id) - if sub: - sub.status = 'failed' - sub.feedback = f"后台评测发生严重错误: {str(e)}" - db.session.commit() - - # 启动后台线程 + if not _demo_database_is_available(demo_run_id): + return + try: + db.session.rollback() + _mark_submission_failed( + submission_id, + "AI 评测失败,请稍后重试。" if demo_run_id else f"后台评测发生严重错误: {error}", + ) + except Exception: + db.session.rollback() + traceback.print_exc() + thread = threading.Thread(target=_evaluate) thread.daemon = True thread.start() - print(f"📤 已启动后台评测线程 - 提交 ID: {submission_id}") + print(f"已启动后台评测线程 - 提交 ID: {submission_id}") + return thread diff --git a/tests/test_demo_ai_refresh.py b/tests/test_demo_ai_refresh.py new file mode 100644 index 0000000..fa92000 --- /dev/null +++ b/tests/test_demo_ai_refresh.py @@ -0,0 +1,94 @@ +import unittest +from unittest.mock import patch + +from models import AbilityTrend, db +from services.api_keys import api_keys +from services.demo_database import activate_demo_run +from services.demo_experience import DEMO_STUDENT_ID +from tasks.ability_analysis import generate_ability_analysis_async +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class ImmediateThread: + """Run an async worker inline so the test can inspect its committed result.""" + + def __init__(self, target, *args, **kwargs): + self.target = target + self.daemon = False + + def start(self): + self.target() + + +class RecordingEvaluator: + calls = 0 + should_fail = False + + def __init__(self, api_key): + self.api_key = api_key + + def analyze_ability_trend_stream(self, submission_data): + type(self).calls += 1 + if type(self).should_fail: + raise RuntimeError('模拟 AI 服务失败') + yield f'真实 AI 分析第 {type(self).calls} 次:共处理 {len(submission_data)} 条提交。' + + +class DemoAIRefreshTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + self.client = self.app.test_client() + self.client.get('/demo-login/student') + with self.client.session_transaction() as client_session: + self.run_id = client_session['demo_run_id'] + RecordingEvaluator.calls = 0 + RecordingEvaluator.should_fail = False + + def tearDown(self): + destroy_test_app(self.app) + + def test_demo_analysis_is_real_refreshable_and_stays_out_of_formal_db(self): + with patch('tasks.ability_analysis.threading.Thread', ImmediateThread), \ + patch('tasks.ability_analysis.AIEvaluator', RecordingEvaluator), \ + patch.object(api_keys, '_zhipu_key', 'demo-test-key'): + generate_ability_analysis_async(self.app, DEMO_STUDENT_ID, demo_run_id=self.run_id) + + with self.app.app_context(): + self.assertTrue(activate_demo_run(self.run_id)) + trend = AbilityTrend.query.filter_by(student_id=DEMO_STUDENT_ID).one() + self.assertEqual(trend.status, 'completed') + self.assertIn('真实 AI 分析第 1 次', trend.analysis_markdown) + first_updated = trend.last_updated + + trend.status = 'outdated' + db.session.commit() + + generate_ability_analysis_async(self.app, DEMO_STUDENT_ID, demo_run_id=self.run_id) + + with self.app.app_context(): + self.assertTrue(activate_demo_run(self.run_id)) + trend = AbilityTrend.query.filter_by(student_id=DEMO_STUDENT_ID).one() + self.assertEqual(trend.status, 'completed') + self.assertIn('真实 AI 分析第 2 次', trend.analysis_markdown) + self.assertGreaterEqual(trend.last_updated, first_updated) + + with self.app.app_context(): + self.assertEqual(AbilityTrend.query.count(), 0) + self.assertEqual(RecordingEvaluator.calls, 2) + + def test_demo_ai_failure_is_explicitly_marked_failed(self): + RecordingEvaluator.should_fail = True + with patch('tasks.ability_analysis.threading.Thread', ImmediateThread), \ + patch('tasks.ability_analysis.AIEvaluator', RecordingEvaluator), \ + patch.object(api_keys, '_zhipu_key', 'demo-test-key'): + generate_ability_analysis_async(self.app, DEMO_STUDENT_ID, demo_run_id=self.run_id) + + with self.app.app_context(): + self.assertTrue(activate_demo_run(self.run_id)) + trend = AbilityTrend.query.filter_by(student_id=DEMO_STUDENT_ID).one() + self.assertEqual(trend.status, 'failed') + self.assertFalse(trend.analysis_markdown) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_demo_submission_isolation.py b/tests/test_demo_submission_isolation.py new file mode 100644 index 0000000..c59d6da --- /dev/null +++ b/tests/test_demo_submission_isolation.py @@ -0,0 +1,78 @@ +import unittest +from unittest.mock import patch + +from models import AbilityTrend, Submission, SystemLog, db +from services.demo_database import activate_demo_run +from services.demo_experience import DEMO_STUDENT_ID, get_demo_assignment_id +from tasks.submission_tasks import evaluate_submission_async +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class ImmediateThread: + def __init__(self, target, *args, **kwargs): + self.target = target + self.daemon = False + + def start(self): + self.target() + + +class DemoSubmissionIsolationTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + self.client = self.app.test_client() + self.client.get('/demo-login/student') + with self.client.session_transaction() as client_session: + self.run_id = client_session['demo_run_id'] + + def tearDown(self): + destroy_test_app(self.app) + + def test_evaluation_updates_only_current_demo_database(self): + with self.app.app_context(): + self.assertTrue(activate_demo_run(self.run_id)) + assignment_id = get_demo_assignment_id(self.run_id) + submission = Submission( + student_id=DEMO_STUDENT_ID, + assignment_id=assignment_id, + code='#include \nint main(void) { return 0; }', + status='pending', + ) + db.session.add(submission) + db.session.commit() + submission_id = submission.id + + sandbox_result = { + 'status': 'passed', + 'passed': 3, + 'total': 3, + 'details': [{'index': 1, 'status': 'passed'}], + } + with patch('tasks.submission_tasks.threading.Thread', ImmediateThread), \ + patch('tasks.submission_tasks.evaluate_cpp_code', return_value=(4, '评测完成')), \ + patch('tasks.submission_tasks.run_test_cases', return_value=sandbox_result), \ + patch('tasks.ability_analysis.trigger_analysis_if_needed', return_value=True): + evaluate_submission_async( + self.app, + submission_id, + '演示作业一:循环与斐波那契数列', + demo_run_id=self.run_id, + ) + + with self.app.app_context(): + self.assertTrue(activate_demo_run(self.run_id)) + updated = Submission.query.get(submission_id) + self.assertEqual(updated.status, 'evaluated') + self.assertEqual(updated.score, 5) + self.assertEqual(updated.sandbox_passed, 3) + self.assertIsNotNone(AbilityTrend.query.filter_by(student_id=DEMO_STUDENT_ID).one()) + self.assertEqual(SystemLog.query.count(), 0) + + with self.app.app_context(): + self.assertEqual(Submission.query.count(), 0) + self.assertEqual(AbilityTrend.query.count(), 0) + self.assertEqual(SystemLog.query.count(), 0) + + +if __name__ == '__main__': + unittest.main() From 5abc13e7fca12cb39c49fe40d6a01731a7e4621a Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Fri, 28 Aug 2026 11:38:12 +0800 Subject: [PATCH 09/12] feat: complete isolated public demo experience --- README.md | 14 +- app.py | 9 ++ routes/assignments.py | 23 ++- routes/classes.py | 4 +- routes/main.py | 84 +++++++++- routes/thinking.py | 182 ++++++++++++++++++++- routes/users.py | 15 +- services/demo_database.py | 123 +++++++++++++- services/demo_experience.py | 33 +++- services/teacher_analytics.py | 5 + tasks/submission_tasks.py | 11 +- templates/classes/class_detail.html | 18 +-- templates/classes/class_list.html | 8 +- templates/layout.html | 123 ++++---------- templates/s_assignments.html | 17 +- templates/sprofile.html | 158 +++++++++++++++++- templates/student_home.html | 206 ++++++++++++++++++++---- templates/submissions.html | 51 +++++- templates/teacher_ai_suggestions.html | 13 +- templates/teacher_assignments.html | 9 +- templates/teacher_home.html | 74 +++++++-- tests/test_demo_database.py | 15 ++ tests/test_demo_database_isolation.py | 136 +++++++++++++++- tests/test_demo_guided_learning.py | 82 +++++++++- tests/test_demo_profile_views.py | 133 +++++++++++++++ tests/test_demo_submission_isolation.py | 8 +- 26 files changed, 1344 insertions(+), 210 deletions(-) create mode 100644 tests/test_demo_profile_views.py diff --git a/README.md b/README.md index 0d3ae99..31a9de5 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,20 @@ CodeSense 酷森思是专为高校编程实训设计的**智能化评测与教 启动服务后打开 `/login`,登录页会提供两个无需注册的体验入口: -* **学生体验**:直接进入“演示作业一:循环与斐波那契数列”,可以查看思路描述、积木编程和费曼教学三个阶段。页面右下角的体验进度入口可以跳到任意阶段或查看完成效果。 +* **学生体验**:直接进入“演示作业一:循环与斐波那契数列”,可以查看思路描述、积木编程和费曼教学三个阶段;竞技场内的“体验进度快捷入口”可以按需跳到任意阶段或查看完成效果。 * **教师体验**:进入教师首页,查看演示班级、学生学习状态、作业完成矩阵和 AI 学情建议,再进入班级详情查看具体记录。 -演示数据由公开入口自动准备,重复进入不会删除已有的学习会话、提交记录或日志。旧的 `/sandbox-login/` 和 `/classes/seed-demo-data` 仍只用于开发/测试环境,不作为对外入口。 +### 体验数据与账号边界 + +公开体验每次进入都会生成新的随机会话和独立临时 SQLite 数据库。演示学生、教师、班级、作业、提交、引导过程、知识点画像和 AI 分析都只写入这次会话;不会创建正式用户,也不会写入正式数据库、系统日志或学校的 RBAC 数据。 + +退出体验后,临时数据库及其旁路文件会立即删除;如果浏览器直接关闭,服务会按空闲超时和最长生命周期清理遗留会话,并在启动时再次清理。下一次进入会重新获得预设的初始状态,不会继承上一位访客的修改。 + +体验中的真实 AI 分析会调用当前配置的 AI 服务。没有可用密钥、服务调用失败或返回内容异常时,页面会显示“失败/重试”状态,不会把预设文字伪装成 AI 结果。 + +页面中的口径分为两类:作业提交得分为 **0–5 分**;知识点画像、五维能力和多维度贝叶斯权重评估为 **0–100 分**。两者不会混用。 + +演示数据由公开入口按会话自动准备。旧的 `/sandbox-login/` 和 `/classes/seed-demo-data` 仍只用于开发/测试环境,不作为对外入口。 --- diff --git a/app.py b/app.py index b078bd0..eb1b5e1 100644 --- a/app.py +++ b/app.py @@ -338,6 +338,15 @@ def load_user(user_id): app.logger.info("开始初始化数据库...") init_db(app) app.logger.info("数据库初始化完成") + try: + from services.demo_database import cleanup_expired_demo_runs + removed_demo_runs = cleanup_expired_demo_runs() + if removed_demo_runs: + app.logger.info("启动清理了 %s 个过期体验临时库", removed_demo_runs) + except Exception: + # Demo cleanup is maintenance and must not prevent the formal + # application from starting when a stale file is locked. + app.logger.exception("启动清理体验临时库失败") # 预加载机器学习模型 - 添加错误处理和内存优化 load_local_model = os.getenv('LOAD_LOCAL_MODEL', 'False').lower() == 'true' diff --git a/routes/assignments.py b/routes/assignments.py index 7a266bb..724d6e3 100644 --- a/routes/assignments.py +++ b/routes/assignments.py @@ -9,6 +9,7 @@ from utils.code_evaluator import evaluate_cpp_code, initialize_models from tasks.submission_tasks import evaluate_submission_async from services.demo_database import current_demo_run_id +from services.demo_experience import ensure_demo_guided_preset, is_demo_guided_assignment from io import BytesIO from sqlalchemy import desc import traceback # 添加traceback模块 @@ -689,10 +690,24 @@ def student_assignments(): assignment_statuses = {} from models import AssignmentThinkingPreset from utils.async_tasks import add_generate_preset_task + demo_run_id = current_demo_run_id() + is_demo_session = bool(demo_run_id and getattr(current_user, 'is_demo', False)) for a in all_assignments: - # 自动检查预设,若不存在或失败则优先触发生成 + # 公开体验的预设只能在当前临时库中修复,绝不能把列表加载 + # 变成写入正式任务队列的入口。普通账号保留原有异步生成逻辑。 preset = AssignmentThinkingPreset.query.filter_by(assignment_id=a.id).first() - if not preset: + a.is_guided_available = not is_demo_session or is_demo_guided_assignment(a) + if is_demo_session: + if is_demo_guided_assignment(a): + if ( + not preset + or preset.status != 'ready' + or not preset.quiz_steps + or preset.quiz_steps.strip() == '[]' + ): + preset = ensure_demo_guided_preset(a) + db.session.commit() + elif not preset: try: preset = AssignmentThinkingPreset(assignment_id=a.id, status='generating') db.session.add(preset) @@ -730,12 +745,14 @@ def student_assignments(): ).order_by(Submission.score.desc()).first() if sub: assignment_max_scores[a.id] = sub.score - if sub.score >= 60: + a.max_student_score = sub.score + if sub.score >= 3: assignment_statuses[a.id] = '已通过' else: assignment_statuses[a.id] = '不及格' else: assignment_max_scores[a.id] = 0 + a.max_student_score = 0 assignment_statuses[a.id] = '未提交' filtered_assignments = [] diff --git a/routes/classes.py b/routes/classes.py index e26b3b4..65d70f6 100644 --- a/routes/classes.py +++ b/routes/classes.py @@ -6,7 +6,7 @@ from sqlalchemy import func, desc import pandas as pd from models import db, Class, StudentRoster, User, Assignment, Submission -from services.demo_experience import ensure_demo_experience +from services.demo_experience import seed_legacy_demo_experience from services.teacher_analytics import build_assignment_completion_matrix, build_class_learning_rows from utils.auth import admin_required, admin_or_teacher_required @@ -770,7 +770,7 @@ def seed_demo_data(): # 兼容旧的开发入口,但统一使用不会删除体验记录的幂等服务。 try: - ensure_demo_experience() + seed_legacy_demo_experience() flash('演示数据已准备好:学生可直接体验三阶段学习,教师可查看完整班级数据。', 'success') except Exception as e: db.session.rollback() diff --git a/routes/main.py b/routes/main.py index 50181c8..6f4402e 100644 --- a/routes/main.py +++ b/routes/main.py @@ -8,7 +8,16 @@ from flask import Blueprint, render_template, redirect, url_for, flash, session, request, jsonify, Response from flask_login import login_required, current_user from sqlalchemy import func -from models import db, User, Assignment, Submission, SystemLog, SystemConfig +from models import ( + db, + User, + Assignment, + Submission, + SystemLog, + SystemConfig, + AbilityTrend, + KnowledgePointScore, +) from services.teacher_analytics import build_teacher_dashboard_data from services.demo_database import current_demo_run_id from utils.auth import admin_required @@ -16,6 +25,37 @@ main = Blueprint('main', __name__) + +_ANALYSIS_STATUS_LABELS = { + 'pending': '等待分析', + 'processing': '分析中', + 'completed': '已完成', + 'failed': '分析失败', + 'outdated': '等待刷新', +} + + +def _knowledge_profile_rows(profile): + """Return the complete, stable-order C-language profile for templates.""" + rows = [] + for key, name in KnowledgePointScore.KNOWLEDGE_POINTS.items(): + item = dict(profile.get(key) or {}) + item.setdefault('score', 0) + item.setdefault('total_attempts', 0) + item.setdefault('correct_attempts', 0) + item.setdefault('accuracy', 0) + item.setdefault('average_difficulty', 0) + rows.append({ + 'key': key, + 'name': name, + **item, + }) + return rows + + +def _analysis_status_label(status): + return _ANALYSIS_STATUS_LABELS.get(status, '等待分析') + # 添加编辑器测试路由 @main.route('/test_editor') def test_editor(): @@ -185,10 +225,17 @@ def home(): # 获取最近的作业 class_name = current_user.class_name + recent_assignments = [] if class_name: recent_assignments = Assignment.query.filter( Assignment.target_classes.like(f'%{class_name}%') ).order_by(Assignment.created_time.desc()).limit(4).all() + + # 首页直接渲染完整画像,前端 SSE 连接成功后再用同一份数据刷新, + # 这样首屏不会只显示“加载中”,网络较慢时也能看到真实的演示数据。 + knowledge_profile = KnowledgePointScore.get_student_profile(student_id) + knowledge_profile_rows = _knowledge_profile_rows(knowledge_profile) + analysis_status = trend_record.status or 'pending' # 1. 通过统一的能力引擎获取雷达图数据 ability_scores = current_user.get_ability_scores() algorithm_score = ability_scores.get('algorithm', 60) @@ -273,6 +320,11 @@ def home(): 'phi_grad': round(phi_grad, 1), 'recent_assignments': recent_assignments, 'submissions': submissions, + 'knowledge_profile': knowledge_profile, + 'knowledge_profile_rows': knowledge_profile_rows, + 'ability_trend': trend_record, + 'analysis_status': analysis_status, + 'analysis_status_label': _analysis_status_label(analysis_status), 'submitted_assignments': submitted_assignments, # 雷达图数据 'algorithm_score': float(algorithm_score), @@ -571,6 +623,7 @@ def teacher_dashboard(): dashboard=dashboard, managed_classes=dashboard['managed_classes'], student_count=dashboard['student_count'], + student_rows=dashboard['student_rows'], total_submissions=dashboard['total_submissions'], recent_submissions=dashboard['recent_submissions'], submission_trend=dashboard['submission_trend'], @@ -824,8 +877,8 @@ def profile(): managed_classes = user.managed_classes.all() return render_template('teacher_profile.html', user=user, managed_classes=managed_classes) else: - # 学生用户使用原有模板 - return render_template('profile.html', user=user) + # 学生资料页统一进入能力进化视图,避免导航入口落到只有基础资料的旧页面。 + return redirect(url_for('main.user_profile', user_username=user.username)) @main.route('/user_profile/') @login_required @@ -889,7 +942,21 @@ def user_profile(user_username): recent_all = sorted(all_student_subs, key=lambda x: x.submitted_at)[-10:] # 为了让图表好看,我们将 0-5 分映射到 20-100 maturity_history = [max(20, s.score * 20) for s in recent_all] - + + knowledge_profile = KnowledgePointScore.get_student_profile(user.student_id) + knowledge_profile_rows = _knowledge_profile_rows(knowledge_profile) + ability_trend = AbilityTrend.query.filter_by(student_id=user.student_id).first() + if ( + user.student_id == current_user.student_id + and ability_trend + and ability_trend.status in ('pending', 'outdated', 'failed') + ): + from tasks.ability_analysis import trigger_analysis_if_needed + trigger_analysis_if_needed( + user.student_id, + demo_run_id=current_demo_run_id(), + ) + return render_template('sprofile.html', user=user, recent_submissions=recent_submissions, @@ -899,7 +966,14 @@ def user_profile(user_username): phi_std=round(phi_std, 1), phi_grad=round(phi_grad, 1), maturity_history=maturity_history, - skills_data_json=json.dumps(skills_data)) + skills_data_json=json.dumps(skills_data), + knowledge_profile=knowledge_profile, + knowledge_profile_rows=knowledge_profile_rows, + ability_trend=ability_trend, + analysis_status=(ability_trend.status if ability_trend else 'pending'), + analysis_status_label=_analysis_status_label( + ability_trend.status if ability_trend else 'pending' + )) @main.route('/debug_session') def debug_session(): diff --git a/routes/thinking.py b/routes/thinking.py index 0444f41..092590a 100644 --- a/routes/thinking.py +++ b/routes/thinking.py @@ -10,7 +10,7 @@ from flask_login import current_user from models import (db, Assignment, AssignmentThinkingPreset, - ThinkingSession, ThinkingStageLog) + ThinkingSession, ThinkingStageLog, Submission, User) from utils.auth import login_required from utils.thinking_ai import ( generate_preset, evaluate_description, generate_stage1_hint, @@ -22,15 +22,46 @@ DEMO_STUDENT_ID, is_demo_guided_assignment, is_demo_guided_session, + ensure_demo_guided_preset, ) +from services.demo_database import current_demo_run_id, is_active_demo_run thinking = Blueprint('thinking', __name__, url_prefix='/thinking') +def _demo_guided_assignment(assignment_id): + """Return the current temporary guided assignment, if this is a demo request.""" + run_id = current_demo_run_id() + if not run_id or not getattr(current_user, 'is_demo', False): + return None + assignment = Assignment.query.get(assignment_id) + if assignment and is_demo_guided_assignment(assignment): + return assignment + return None + + def _check_and_trigger_stale_preset(preset, assignment_id): """ 检查预设是否是老版本(状态为 ready 但没有 quiz_steps),如果是,则自动触发重新生成。 """ + # 演示作业的预设完全属于当前临时库。无论之前的后台任务把它标成 + # failed、generating 还是缺少字段,都在当前临时库内恢复固定教学数据, + # 不向正式任务队列投递任何任务。 + demo_assignment = _demo_guided_assignment(assignment_id) + if demo_assignment: + is_stale = ( + not preset + or preset.status != 'ready' + or not getattr(preset, 'quiz_steps', None) + or preset.quiz_steps.strip() == '[]' + ) + if is_stale: + repaired = ensure_demo_guided_preset(demo_assignment) + if repaired: + db.session.commit() + return repaired + return preset + if preset and preset.status == 'ready' and (not hasattr(preset, 'quiz_steps') or not preset.quiz_steps or preset.quiz_steps.strip() == '' or preset.quiz_steps == '[]'): try: preset.status = 'generating' @@ -47,6 +78,116 @@ def _check_and_trigger_stale_preset(preset, assignment_id): return preset +def _record_demo_guided_submission(thinking_session): + """Create one idempotent 0–5 submission when a demo run is completed.""" + run_id = current_demo_run_id() + if ( + not run_id + or not getattr(current_user, 'is_demo', False) + or not is_active_demo_run(run_id) + ): + return None + + assignment = Assignment.query.get(thinking_session.assignment_id) + if not is_demo_guided_assignment(assignment): + return None + + marker = f'/* codesense-demo-guided-session:{thinking_session.id} */' + submission = Submission.query.filter( + Submission.student_id == current_user.student_id, + Submission.assignment_id == assignment.id, + Submission.code.like(f'{marker}%'), + ).first() + if not submission: + submission = Submission( + student_id=current_user.student_id, + assignment_id=assignment.id, + code=marker, + language='c', + ) + db.session.add(submission) + + # 完成三阶段的示范提交使用 0–5 评分;阶段一的百分制只作为 + # 一个轻微的区分因素,不会直接写入提交分数字段。 + stage1_score = float(thinking_session.stage1_score or 80) + score = max(3, min(5, int(round(stage1_score / 20)))) + preset = AssignmentThinkingPreset.query.filter_by( + assignment_id=assignment.id, + ).first() + reference_code = preset.reference_code if preset else '' + submission.code = f'{marker}\n{reference_code or "int main(void) { return 0; }"}' + submission.score = score + submission.status = 'evaluated' + submission.feedback = '已完成三阶段引导式学习,提交记录用于展示学习闭环。' + submission.ai_feedback = json.dumps({ + 'overall_score': score, + 'algorithm_score': score, + 'style_score': score, + 'functionality_score': score, + 'efficiency_score': max(2, score - 1), + 'readability_score': score, + 'source': 'guided_demo_completion', + }, ensure_ascii=False) + submission.sandbox_status = 'passed' + submission.sandbox_passed = 3 + submission.sandbox_total = 3 + submission.sandbox_detail = json.dumps({ + 'source': 'guided_demo_completion', + 'cases': [ + {'index': 1, 'status': 'passed'}, + {'index': 2, 'status': 'passed'}, + {'index': 3, 'status': 'passed'}, + ], + }, ensure_ascii=False) + submission.submitted_at = thinking_session.completed_at or dt.utcnow() + db.session.flush() + + evaluated_scores = [ + row.score for row in Submission.query.filter_by( + assignment_id=assignment.id, + status='evaluated', + ).all() if row.score is not None + ] + assignment.count = len(evaluated_scores) + assignment.total_score = sum(evaluated_scores) + assignment.average_score = ( + sum(evaluated_scores) / len(evaluated_scores) + if evaluated_scores else 0.0 + ) + + student = db.session.get(User, current_user.student_id) + if student: + student_scores = [ + row.score for row in Submission.query.filter_by( + student_id=student.student_id, + status='evaluated', + ).all() if row.score is not None + ] + student.submit_count = len(student_scores) + student.user_tscore = sum(student_scores) + student.user_ascore = ( + sum(student_scores) / len(student_scores) + if student_scores else 0.0 + ) + + db.session.commit() + + # 完成记录写入后按正常提交路径刷新临时 AI 分析。 + try: + from models import AbilityTrend + from tasks.ability_analysis import trigger_analysis_if_needed + + AbilityTrend.mark_as_outdated(current_user.student_id) + trigger_analysis_if_needed( + current_user.student_id, + demo_run_id=run_id, + ) + except Exception as error: + current_app.logger.warning('引导式学习完成后的 AI 刷新未启动: %s', error) + + return submission + + # ============================================================ # 页面路由 # ============================================================ @@ -914,6 +1055,7 @@ def stage3_fix_code(): ts.status = 'completed' ts.completed_at = dt.utcnow() _log_event(session_id, 3, 'stage_pass', 'system', f'费曼教学完成: {feedback}') + _record_demo_guided_submission(ts) db.session.commit() @@ -948,6 +1090,8 @@ def complete_session(): ts.status = 'completed' ts.completed_at = dt.utcnow() + _record_demo_guided_submission(ts) + db.session.commit() return jsonify({'success': True}) @@ -972,6 +1116,7 @@ def api_generate_preset(): assignment = Assignment.query.get(assignment_id) if not assignment: return jsonify({'error': '作业不存在'}), 404 + demo_assignment = _demo_guided_assignment(assignment_id) # 检查是否已有预设 preset = AssignmentThinkingPreset.query.filter_by(assignment_id=assignment_id).first() @@ -999,17 +1144,26 @@ def api_generate_preset(): preset.error_message = None except Exception as gen_err: - preset.status = 'failed' - preset.error_message = str(gen_err) print(f"预设生成失败: {gen_err}") traceback.print_exc() + if demo_assignment: + # 演示作业已有确定性的本地教学预设。真实 AI 重生成失败 + # 时恢复它,避免进入会再次投递正式任务的 failed 状态。 + preset = ensure_demo_guided_preset(demo_assignment) + preset_status_error = None + else: + preset.status = 'failed' + preset.error_message = str(gen_err) + preset_status_error = preset.error_message + else: + preset_status_error = preset.error_message db.session.commit() return jsonify({ 'success': preset.status == 'ready', 'status': preset.status, - 'error': preset.error_message + 'error': preset_status_error }) except Exception as e: @@ -1041,6 +1195,12 @@ def preset_status(assignment_id): def retry_preset(assignment_id): """重新尝试生成预设(异步)""" try: + demo_assignment = _demo_guided_assignment(assignment_id) + if demo_assignment: + preset = ensure_demo_guided_preset(demo_assignment) + db.session.commit() + return jsonify({'success': True, 'status': 'ready', 'demo': True}) + preset = AssignmentThinkingPreset.query.filter_by(assignment_id=assignment_id).first() if not preset: preset = AssignmentThinkingPreset(assignment_id=assignment_id) @@ -1234,7 +1394,18 @@ def _serialize_preset(preset: AssignmentThinkingPreset) -> dict: # 惰性回填:如果旧预设缺少 algorithm_summary,尝试后台异步生成,避免阻塞主请求 algorithm_summary = preset.get_algorithm_summary() - if not algorithm_summary and preset.status == 'ready' and preset.reference_code: + demo_run_id = current_demo_run_id() + is_demo_preset = ( + demo_run_id + and getattr(current_user, 'is_demo', False) + and is_demo_guided_assignment(preset.assignment) + ) + if is_demo_preset and not algorithm_summary: + repaired = ensure_demo_guided_preset(preset.assignment) + if repaired: + db.session.commit() + algorithm_summary = repaired.get_algorithm_summary() + elif not algorithm_summary and preset.status == 'ready' and preset.reference_code: import threading app = current_app._get_current_object() preset_id = preset.id @@ -1354,6 +1525,7 @@ def debug_jump_stage(): ts.stage3_completed = True ts.status = 'completed' ts.completed_at = dt.utcnow() + _record_demo_guided_submission(ts) db.session.commit() return jsonify({'success': True, 'current_stage': ts.current_stage, 'status': ts.status}) diff --git a/routes/users.py b/routes/users.py index 146346c..a0668b6 100644 --- a/routes/users.py +++ b/routes/users.py @@ -3,7 +3,7 @@ """ from flask import Blueprint, render_template, request, redirect, url_for, flash, session, send_file, current_app, jsonify from itsdangerous import URLSafeTimedSerializer -from models import db, User, Submission, SystemLog, Class, AbilityTrend +from models import db, User, Submission, SystemLog, Class, AbilityTrend, KnowledgePointScore from utils.auth import login_required, admin_required, admin_or_teacher_required from tasks.ability_analysis import trigger_analysis_if_needed from services.demo_database import current_demo_run_id @@ -223,6 +223,17 @@ def view_submissions(): 'algorithm': 70, 'style': 70, 'functionality': 70, 'efficiency': 70, 'readability': 70 }) } + + knowledge_profile = KnowledgePointScore.get_student_profile(student_id) + knowledge_profile_rows = [] + for key, name in KnowledgePointScore.KNOWLEDGE_POINTS.items(): + item = dict(knowledge_profile.get(key) or {}) + item.setdefault('score', 0) + item.setdefault('total_attempts', 0) + item.setdefault('correct_attempts', 0) + item.setdefault('accuracy', 0) + item.setdefault('average_difficulty', 0) + knowledge_profile_rows.append({'key': key, 'name': name, **item}) # 5. 获取 AI 能力趋势分析 ability_trend = AbilityTrend.query.filter_by(student_id=student_id).first() @@ -239,6 +250,8 @@ def view_submissions(): user=user, chart_data=chart_data, ability_data=ability_data, + knowledge_profile=knowledge_profile, + knowledge_profile_rows=knowledge_profile_rows, ability_trend=ability_trend, comprehensive_score=comprehensive_score, strongest_dim=strongest_dim) diff --git a/services/demo_database.py b/services/demo_database.py index cc392b4..b04a97a 100644 --- a/services/demo_database.py +++ b/services/demo_database.py @@ -32,6 +32,8 @@ _ENGINES = {} _RUN_CREATED_AT = {} _RUN_LAST_ACCESS = {} +_LAST_CLEANUP_AT = None +_CLEANUP_INTERVAL = timedelta(minutes=1) def _demo_root() -> Path: @@ -55,10 +57,68 @@ def _db_path(run_id: str) -> Path: return path +def _metadata_path(run_id: str) -> Path: + """Return the validated lifecycle metadata path beside a run database.""" + + database_path = _db_path(run_id) + metadata_path = Path(f"{database_path}.meta").resolve() + if metadata_path.parent != database_path.parent: + raise ValueError("体验数据库元数据路径越界") + return metadata_path + + def _sqlite_uri(path: Path) -> str: return f"sqlite:///{path.as_posix()}" +def _write_run_metadata(run_id: str, created_at: datetime) -> None: + """Persist creation time so max lifetime survives a process restart.""" + + _metadata_path(run_id).write_text(created_at.isoformat(), encoding="ascii") + + +def _read_run_created_at(run_id: str, database_path: Path) -> datetime: + """Read creation time, with a filesystem fallback for old run files.""" + + with _LOCK: + created_at = _RUN_CREATED_AT.get(run_id) + if created_at is not None: + return created_at + + try: + raw_value = _metadata_path(run_id).read_text(encoding="ascii").strip() + return datetime.fromisoformat(raw_value) + except (OSError, ValueError): + stat = database_path.stat() + birth_timestamp = getattr(stat, "st_birthtime", None) + if birth_timestamp is None: + birth_timestamp = stat.st_ctime + return datetime.utcfromtimestamp(birth_timestamp) + + +def _run_last_access(run_id: str, database_path: Path) -> datetime: + with _LOCK: + last_access = _RUN_LAST_ACCESS.get(run_id) + if last_access is not None: + return last_access + return datetime.utcfromtimestamp(database_path.stat().st_mtime) + + +def _is_expired(run_id: str, now: datetime) -> bool: + database_path = _db_path(run_id) + if not database_path.exists(): + return True + try: + created_at = _read_run_created_at(run_id, database_path) + last_access = _run_last_access(run_id, database_path) + except FileNotFoundError: + return True + return ( + now - last_access > DEMO_IDLE_TIMEOUT + or now - created_at > DEMO_MAX_LIFETIME + ) + + @dataclass(frozen=True) class DemoRun: """A single public-demo database and its stable temporary identities.""" @@ -189,7 +249,20 @@ def create_demo_run(role: str) -> DemoRun: # The model metadata is used only with this newly-created engine. The # application's configured engine is never passed to create_all here. - db.metadata.create_all(bind=engine) + try: + db.metadata.create_all(bind=engine) + _write_run_metadata(run_id, created_at) + except Exception: + try: + engine.dispose(close=True) + except TypeError: + engine.dispose() + for candidate in (path, _metadata_path(run_id)): + try: + candidate.unlink() + except FileNotFoundError: + pass + raise with _LOCK: _ENGINES[run_id] = engine @@ -251,7 +324,22 @@ def activate_demo_request_database() -> bool: run_id = current_demo_run_id() if not run_id: return False + if _is_expired(run_id, datetime.utcnow()): + # An expired browser cookie must not be allowed to query a newly + # created formal session. The run is best-effort deleted here and the + # signed session markers are removed below regardless of cleanup I/O. + try: + destroy_demo_run(run_id) + except Exception: + pass + finally: + session.pop(DEMO_SESSION_KEY, None) + session.pop(DEMO_ROLE_SESSION_KEY, None) + session.pop("_user_id", None) + session.pop("login", None) + return False if activate_demo_run(run_id): + _maybe_cleanup_expired_demo_runs(datetime.utcnow()) return True # A stale browser session must never fall back to the formal database. @@ -280,6 +368,7 @@ def destroy_demo_run(run_id: str) -> bool: existed = False for candidate in ( path, + _metadata_path(run_id), Path(f"{path}-wal"), Path(f"{path}-shm"), Path(f"{path}-journal"), @@ -306,20 +395,38 @@ def cleanup_expired_demo_runs(now: datetime | None = None) -> int: removed = 0 root = _demo_root() for path in root.glob("*.sqlite3"): - try: - modified_at = datetime.utcfromtimestamp(path.stat().st_mtime) - except FileNotFoundError: - continue - if now - modified_at <= DEMO_IDLE_TIMEOUT: - continue run_id = path.stem if not _RUN_ID_PATTERN.fullmatch(run_id): continue - if destroy_demo_run(run_id): + try: + expired = _is_expired(run_id, now) + except FileNotFoundError: + continue + if expired and destroy_demo_run(run_id): removed += 1 return removed +def _maybe_cleanup_expired_demo_runs(now: datetime) -> None: + """Throttle cross-session cleanup while checking the current run eagerly.""" + + global _LAST_CLEANUP_AT + with _LOCK: + if ( + _LAST_CLEANUP_AT is not None + and now - _LAST_CLEANUP_AT < _CLEANUP_INTERVAL + ): + return + _LAST_CLEANUP_AT = now + try: + cleanup_expired_demo_runs(now) + except Exception: + # Cleanup is maintenance; a locked sidecar must not break an active + # visitor request. The next interval will retry it. + with _LOCK: + _LAST_CLEANUP_AT = None + + def destroy_all_demo_runs() -> int: """Remove every valid demo run, primarily for deterministic test cleanup.""" root = _demo_root() diff --git a/services/demo_experience.py b/services/demo_experience.py index 910bf97..3697da5 100644 --- a/services/demo_experience.py +++ b/services/demo_experience.py @@ -738,9 +738,16 @@ def ensure_demo_experience(): student_id=student.student_id, class_id=demo_class.id, assignment_id=guided_assignment.id, + second_assignment_id=second_assignment.id, ) +# This explicit development/test-only seeder remains available for the legacy +# ``/classes/seed-demo-data`` and ``/sandbox-login`` workflow. Public visitors +# never call it; ``/demo-login`` always uses ``seed_demo_experience`` below. +seed_legacy_demo_experience = ensure_demo_experience + + def is_demo_guided_session(thinking_session): """判断会话是否属于公开演示学生的共享引导作业。""" if not thinking_session or thinking_session.student_id != DEMO_STUDENT_ID: @@ -753,7 +760,10 @@ def is_demo_guided_assignment(assignment): """判断作业是否属于公开演示的三阶段引导作业。""" return bool( assignment - and assignment.title == DEMO_ASSIGNMENT_TITLE + and assignment.title in { + DEMO_ASSIGNMENT_TITLE, + DEMO_SECOND_ASSIGNMENT_TITLE, + } and assignment.creator_id == DEMO_TEACHER_ID and DEMO_CLASS_NAME in assignment.get_target_class_list() ) @@ -1057,8 +1067,8 @@ def seed_demo_experience(run: DemoRun) -> DemoExperience: _ensure_roster(student.student_id, student.full_name, demo_class, student.student_id) # Keep a couple of pending roster entries so class management also shows # the registration workflow without creating fake users for them. - _ensure_roster('demo_r_013', '何十三(待注册)', demo_class) - _ensure_roster('demo_r_014', '吕十四(待注册)', demo_class) + _ensure_roster('demo_r_013', '李四(未注册)', demo_class) + _ensure_roster('demo_r_014', '何十三(待注册)', demo_class) assignments = {} now = dt.utcnow() @@ -1165,3 +1175,20 @@ def get_demo_assignment_id(run_id: str, key: str = 'guided_fibonacci') -> int: if assignment is None: raise LookupError(f'演示作业尚未初始化: {key}') return assignment.id + + +def ensure_demo_guided_preset(assignment): + """Repair a known demo preset in the active temporary database. + + Guided-demo presets are deterministic teaching material. If an old worker + marked one as generating/failed, restore it locally instead of enqueueing + a task that would use the formal database. + """ + if not is_demo_guided_assignment(assignment): + return None + if assignment.title == DEMO_SECOND_ASSIGNMENT_TITLE: + preset = _ensure_tree_preset(assignment) + else: + preset = _ensure_preset(assignment) + db.session.flush() + return preset diff --git a/services/teacher_analytics.py b/services/teacher_analytics.py index ffb4324..a944807 100644 --- a/services/teacher_analytics.py +++ b/services/teacher_analytics.py @@ -340,6 +340,11 @@ def build_teacher_dashboard_data(teacher, now=None): return { 'managed_classes': managed_classes, 'student_count': len(students), + 'student_rows': sorted( + rows_by_student_id.values(), + key=lambda row: (row['student'].user_ascore or 0), + reverse=True, + ), 'total_submissions': total_submissions, 'recent_submissions': recent_submissions, 'submission_trend': build_submission_trend(student_ids, days=14, now=now), diff --git a/tasks/submission_tasks.py b/tasks/submission_tasks.py index 1a69c5c..89d7c79 100644 --- a/tasks/submission_tasks.py +++ b/tasks/submission_tasks.py @@ -22,7 +22,16 @@ def _normalise_score(score) -> int: """Keep every persisted submission score inside the product's 0–5 scale.""" try: - return max(0, min(5, int(round(float(score))))) + raw_score = float(score) + # The current heuristic evaluator already returns 0–5. The LLM and + # legacy evaluator paths return 0–100, while a few older integrations + # used 0–10. Normalize those representations before rounding instead + # of clipping every value above 5 to a false perfect score. + if raw_score > 10: + raw_score /= 20.0 + elif raw_score > 5: + raw_score /= 2.0 + return max(0, min(5, int(round(raw_score)))) except (TypeError, ValueError): raise ValueError("评测器未返回有效分数") diff --git a/templates/classes/class_detail.html b/templates/classes/class_detail.html index 2abf30f..ec3a656 100644 --- a/templates/classes/class_detail.html +++ b/templates/classes/class_detail.html @@ -185,7 +185,7 @@ {% endblock %} {% block content %} -
+
@@ -239,7 +239,7 @@

{{ cls.name }}

{{ "%.1f"|format(stats.avg_score) }}
-
平均分
+
班级平均提交分(0–5)
{{ stats.total_submissions }}
@@ -401,11 +401,11 @@

学生名单管理与注册进度

{% endif %} {% if assignment_matrix and assignment_matrix.assignments %} -
+

最近作业完成矩阵

-

横向看作业完成率,纵向看学生是否连续缺交或低分。

+

横向看作业完成率,纵向看学生是否连续缺交或低分。提交得分采用 0–5 分。

优秀 @@ -440,7 +440,7 @@

最近作业完成矩阵

{{ cell.status }} {% if cell.best_score is not none %} -
{{ cell.best_score }} 分
+
{{ "%.1f"|format(cell.best_score|float) }}/5
{% endif %}
@@ -491,8 +491,8 @@

学生学情概览

学生 学号 状态 - 平均分 - 最近得分 + 能力综合分(0–5) + 提交得分(0–5) 提交次数 最近提交 风险标签 @@ -522,14 +522,14 @@

学生学情概览

- {{ "%.1f"|format(student.user_ascore) }} + {{ "%.1f"|format(student.user_ascore|float) }}/5 {% if row.latest_score is not none %} - {{ row.latest_score }} + {{ "%.1f"|format(row.latest_score|float) }}/5 {% else %} - diff --git a/templates/classes/class_list.html b/templates/classes/class_list.html index ec78e02..a985499 100644 --- a/templates/classes/class_list.html +++ b/templates/classes/class_list.html @@ -170,7 +170,7 @@

概览统计

{{ "%.1f"|format(overall_avg_score) }}
-
整体平均分
+
整体平均提交分(0–5)
@@ -212,7 +212,7 @@

概览统计

data-college="{{ item.class.college or '计算机学院' }}" data-major="{{ item.class.major or '未设置' }}" data-grade="{{ item.class.grade or '未设置' }}"> -
+
{{ item.class.name }}
@@ -241,7 +241,7 @@

概览统计

{{ "%.1f"|format(item.stats.avg_score) }}
-
平均分
+
班级平均分(0–5)
{{ item.stats.total_submissions }}
@@ -279,7 +279,7 @@
前3名学生:
{% for student in item.top_students %}
{{ student.full_name }} - {{ "%.1f"|format(student.user_ascore) }}分 + {{ "%.1f"|format(student.user_ascore) }}/5
{% endfor %}
diff --git a/templates/layout.html b/templates/layout.html index 30e314d..64577de 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -73,6 +73,28 @@ justify-content: center; } } + + .navbar-demo-notice { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 14px; + padding: 5px 10px; + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 999px; + color: #2563eb; + background: rgba(239, 246, 255, 0.86); + font-size: 0.78rem; + white-space: nowrap; + } + + @media (max-width: 768px) { + .navbar-demo-notice { + margin: 8px 0 0; + white-space: normal; + text-align: center; + } + } @@ -142,6 +164,11 @@ - - - - - {% endif %} - {% block extra_js %}{% endblock %} diff --git a/templates/s_assignments.html b/templates/s_assignments.html index 6b65691..059164b 100644 --- a/templates/s_assignments.html +++ b/templates/s_assignments.html @@ -228,8 +228,8 @@

我的作业

出题教师 发布日期 截止倒计时 - 提交/平均分 - 个人最高分 + 提交/平均分(0–5) + 个人最高分(0–5) @@ -242,11 +242,14 @@

我的作业

{{ assignment.title }}
+ {% if assignment.is_guided_available %} + class="badge bg-primary bg-opacity-10 text-primary text-decoration-none mt-1"> 引导式学习 + {% else %} + 常规编码练习 + {% endif %} {{ assignment.creator.full_name if assignment.creator else '未知' }} @@ -283,12 +286,12 @@

我的作业

提交: {{ assignment.count }}
- 平均: {{ assignment.average_score }} + 平均: {{ "%.1f"|format(assignment.average_score|float) }}/5 {% if assignment.max_student_score %} - {{ assignment.max_student_score }} + {{ "%.1f"|format(assignment.max_student_score|float) }}/5 {% else %} 未提交 @@ -360,4 +363,4 @@
需要帮助
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/sprofile.html b/templates/sprofile.html index f42f0fa..233b00d 100644 --- a/templates/sprofile.html +++ b/templates/sprofile.html @@ -43,6 +43,44 @@ font-weight: bold; margin-right: 5px; } + + .profile-kp-card { + border: 1px solid #e7edf6; + border-radius: 14px; + background: #fff; + } + + .profile-kp-table { + max-height: 410px; + overflow-y: auto; + border: 1px solid #edf1f7; + border-radius: 10px; + } + + .profile-kp-table table { + margin-bottom: 0; + } + + .profile-kp-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: #f8fafc; + white-space: nowrap; + } + + .profile-kp-table td, + .profile-kp-table th { + padding: 0.62rem 0.7rem; + vertical-align: middle; + } + + .profile-ai-card { + height: 100%; + border-radius: 12px; + background: linear-gradient(145deg, #f8fbff 0%, #ffffff 72%); + border: 1px solid #e7edf6; + } {% endblock %} @@ -112,6 +150,93 @@

φ_grad (进化率)

+ +
+
+
+
+
+

C语言知识点画像

+

掌握度采用 0–100 分,结合尝试次数与正确率观察学习进展。

+
+ 13 个知识点 +
+
+
+ + + + + + + + + + + + {% for row in knowledge_profile_rows %} + {% set kp_score = row.score|float %} + + + + + + + + {% endfor %} + +
知识点掌握度尝试正确率状态
{{ row.name }} + {{ "%.1f"|format(kp_score) }}/100 + {{ row.total_attempts }}{{ "%.0f"|format(row.accuracy|float) }}% + {% if kp_score >= 85 %} + 熟练 + {% elif kp_score >= 70 %} + 稳定 + {% elif kp_score > 0 %} + 练习中 + {% else %} + 待开始 + {% endif %} +
+
+
+
+
+
+
+
+
+

AI 个性化分析

+

基于本次体验会话中的提交记录生成。

+
+ {{ analysis_status_label }} +
+ {% if ability_trend and ability_trend.analysis_markdown %} +
+ {% elif analysis_status == 'failed' %} +
AI 分析暂时失败,可以点击下方按钮重新生成。
+ {% elif analysis_status == 'processing' %} +
AI 正在分析提交记录…
+ {% else %} +
本次会话的 AI 分析即将开始。
+ {% endif %} +
+ + {% if ability_trend and ability_trend.last_updated %} + 最近更新:{{ ability_trend.last_updated.strftime('%Y-%m-%d %H:%M') }} + {% else %}尚未更新{% endif %} + + {% if session.get('student_id') == user.student_id %} + + {% endif %} +
+
+
+
+
@@ -130,7 +255,7 @@

待攻坚:瓶颈题目精选

- 这些题目您的最高分尚未达到 5 分,通过重构实现蜕变吧 + 提交分数 0–5 分 · 这些题目最高分尚未达到 5 分,通过重构实现蜕变吧
@@ -141,7 +266,7 @@

{{ sub.assignment.title if sub.assignment else '未知作业' }} - 目前最高分: {{ sub.score }} + 目前最高分: {{ "%.1f"|format(sub.score|float) }}/5
最近尝试于: {{ sub.submitted_at.strftime('%Y-%m-%d %H:%M') }} @@ -295,4 +420,31 @@

恭喜您!目前没有显著瓶颈

}); }); -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/templates/student_home.html b/templates/student_home.html index 7012cfb..60741c3 100644 --- a/templates/student_home.html +++ b/templates/student_home.html @@ -243,6 +243,57 @@ color: var(--primary-color); background-color: rgba(52, 152, 219, 0.1); } + + .profile-summary-card { + border: 1px solid #e8eef7; + border-radius: 14px; + background: linear-gradient(135deg, #f8fbff 0%, #ffffff 72%); + padding: 1rem 1.1rem; + margin-bottom: 1rem; + } + + .profile-summary-card .summary-value { + color: #1e3a8a; + font-size: 1.35rem; + font-weight: 700; + } + + .knowledge-profile-table { + max-height: 430px; + overflow-y: auto; + border: 1px solid #edf1f7; + border-radius: 10px; + } + + .knowledge-profile-table table { + margin-bottom: 0; + } + + .knowledge-profile-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: #f8fafc; + white-space: nowrap; + } + + .knowledge-profile-table td, + .knowledge-profile-table th { + padding: 0.65rem 0.7rem; + vertical-align: middle; + } + + .knowledge-status { + font-size: 0.72rem; + white-space: nowrap; + } + + .analysis-status-panel { + border: 1px solid #e8eef7; + border-radius: 12px; + padding: 0.9rem 1rem; + background: #fbfdff; + } {% endblock %} @@ -272,7 +323,7 @@

{{ assignmen

- 查看所有作业 @@ -295,6 +346,7 @@
{{ submissions_count }}

已提交的作业

+ 平均提交得分 {{ "%.1f"|format(average_score|float) }}/5
@@ -321,7 +373,7 @@
{{ maturity_score if maturity_score else '暂无' }}
-

多维度贝叶斯权重评估

+

多维度贝叶斯权重评估(0–100)

@@ -424,9 +476,10 @@
C语言知识点画像 - - 加载中 + + {% if knowledge_profile_rows %}已加载{% else %}加载中{% endif %}
@@ -443,17 +496,40 @@
- AI个性化分析 - AI 个性化分析 + - 加载中 + + {{ analysis_status_label }}
+
+
+ + {% if analysis_status == 'completed' %} + 分析已基于最近 {{ ability_trend.submissions_count }} 次提交完成。 + {% elif analysis_status == 'failed' %} + 本次 AI 分析未完成,可以重新发起;不会影响已有作业记录。 + {% elif analysis_status == 'processing' %} + AI 正在读取提交内容并生成个性化建议。 + {% else %} + 首次打开会为本次体验会话生成专属分析,提交成功后会自动刷新。 + {% endif %} + + + {% if ability_trend and ability_trend.last_updated %} + 最近更新:{{ ability_trend.last_updated.strftime('%Y-%m-%d %H:%M') }} + {% else %}尚未更新{% endif %} + +
+
+ @@ -476,18 +563,47 @@
知识点详细评分
-
-
-
- 加载中... -
-

正在加载知识点数据...

-
+
+ + + + + + + + + + + + {% for row in knowledge_profile_rows %} + {% set kp_score = row.score|float %} + + + + + + + + {% endfor %} + +
知识点掌握度尝试正确率状态
{{ row.name }} + {{ "%.1f"|format(kp_score) }}/100 + {{ row.total_attempts }}{{ "%.0f"|format(row.accuracy|float) }}% + {% if kp_score >= 85 %} + 熟练 + {% elif kp_score >= 70 %} + 稳定 + {% elif kp_score > 0 %} + 练习中 + {% else %} + 待开始 + {% endif %} +
-
最近提交记录
+
最近提交记录 提交评分 0–5 分
@@ -503,9 +619,9 @@
最近提交记录
@@ -715,6 +831,7 @@
最近提交记录
const statusBadge = document.getElementById('profile-status-badge'); if (statusBadge) { statusBadge.className = 'badge badge-success'; + statusBadge.dataset.status = 'completed'; statusBadge.innerHTML = ' 已加载'; } @@ -803,9 +920,10 @@
最近提交记录
- - + + + @@ -820,16 +938,30 @@
最近提交记录
const accuracy = data.accuracy || 0; let scoreClass = 'text-muted'; - if (score >= 80) scoreClass = 'text-success fw-bold'; - else if (score >= 60) scoreClass = 'text-primary'; + if (score >= 85) scoreClass = 'text-success fw-bold'; + else if (score >= 70) scoreClass = 'text-primary'; else if (score > 0) scoreClass = 'text-warning'; + let statusText = '待开始'; + let statusClass = 'bg-secondary'; + if (score >= 85) { + statusText = '熟练'; + statusClass = 'bg-success'; + } else if (score >= 70) { + statusText = '稳定'; + statusClass = 'bg-primary'; + } else if (score > 0) { + statusText = '练习中'; + statusClass = 'bg-warning text-dark'; + } + tableHTML += ` - - - + + + + `; } @@ -852,6 +984,11 @@
最近提交记录
if (outputDiv) { // 不再清空内容,而是移除旧的消息并准备接收新内容 outputDiv.innerHTML = ''; + outputDiv.dataset.analysisStatus = 'processing'; + } + const statusBadge = document.getElementById('analysis-status-badge'); + if (statusBadge) { + statusBadge.dataset.analysisStatus = 'processing'; } } @@ -1049,8 +1186,9 @@

${escapeHtml(section.title)}

if (statusBadge) { // 检查是否是"正在生成"状态(需要刷新) const outputDiv = document.getElementById('analysis-output'); - if (outputDiv && outputDiv.textContent.includes('正在生成') || outputDiv.textContent.includes('生成中')) { + if (outputDiv && (outputDiv.textContent.includes('正在生成') || outputDiv.textContent.includes('生成中'))) { statusBadge.className = 'badge badge-info'; + statusBadge.dataset.analysisStatus = 'processing'; statusBadge.innerHTML = ' 需要刷新'; // 显示刷新按钮 @@ -1060,6 +1198,7 @@

${escapeHtml(section.title)}

} } else { statusBadge.className = 'badge badge-success'; + statusBadge.dataset.analysisStatus = 'completed'; statusBadge.innerHTML = ' 已完成'; } } @@ -1093,6 +1232,7 @@

${escapeHtml(section.title)}

const statusBadge = document.getElementById('analysis-status-badge'); if (statusBadge) { statusBadge.className = 'badge badge-danger'; + statusBadge.dataset.analysisStatus = 'failed'; statusBadge.innerHTML = ' 错误'; } } @@ -1102,4 +1242,4 @@

${escapeHtml(section.title)}

console.log('🔌 页面卸载'); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/submissions.html b/templates/submissions.html index cabcd66..e86b567 100644 --- a/templates/submissions.html +++ b/templates/submissions.html @@ -199,6 +199,38 @@

能力分析

+

C语言知识点画像

+
+
+
知识点掌握明细
+ 评分范围 0–100 +
+
+
+
{{ submission.assignment.title }} {{ submission.submitted_at.strftime('%Y-%m-%d %H:%M') }} - - {{ submission.score }} + + {{ "%.1f"|format(submission.score|float) }}/5
知识点评分尝试次数掌握度尝试 正确率状态
${name}${score.toFixed(1)}
${name}${score.toFixed(1)}/100 ${total} ${accuracy.toFixed(0)}%${statusText}
+ + + + + + + + + + {% for row in knowledge_profile_rows %} + + + + + + + {% endfor %} + +
知识点掌握度尝试次数正确率
{{ row.name }}{{ "%.1f"|format(row.score|float) }}/100{{ row.total_attempts }}{{ "%.0f"|format(row.accuracy|float) }}%
+
+
+
+

提交趋势

@@ -228,7 +260,7 @@

提交记录

题目ID 题目标题 - 提交评分 + 提交评分(0–5) 评估建议 操作 @@ -241,7 +273,7 @@

提交记录

{% if submission.score is not none %} - {{ submission.score }} + {{ "%.1f"|format(submission.score|float) }}/5 {% else %} 未评分 @@ -273,7 +305,9 @@

提交记录

-
深度能力分析报告
+
深度能力分析报告 + {% if ability_trend %}{{ ability_trend.status }}{% if ability_trend.last_updated %} · {{ ability_trend.last_updated.strftime('%Y-%m-%d %H:%M') }}{% endif %}{% endif %} +
{% if session.get('student_id') == user.student_id %}
+ {% elif ability_trend.status == 'failed' %} +
+ +

本次 AI 分析没有成功完成,请点击右上角重新生成。

+
{% elif ability_trend.analysis_markdown %}
@@ -426,7 +465,9 @@

提交记录

maintainAspectRatio: false, scales: { y: { - beginAtZero: true + beginAtZero: true, + max: 5, + ticks: { stepSize: 1 } } }, plugins: { @@ -500,4 +541,4 @@

提交记录

} }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/teacher_ai_suggestions.html b/templates/teacher_ai_suggestions.html index ea67b35..81933aa 100644 --- a/templates/teacher_ai_suggestions.html +++ b/templates/teacher_ai_suggestions.html @@ -149,7 +149,7 @@

- AI 教学个性化建议落地页 + AI 教学个性化建议 · 学情建议

@@ -213,6 +213,17 @@

{{ cls.name }} 学情 刷新 AI 建议

+
+ + + AI 个性化教学建议 · 状态: + {% if sug.status == 'completed' %}已生成{% elif sug.status == 'processing' %}分析中{% elif sug.status == 'failed' %}生成失败{% else %}待生成{% endif %} + + + 最近更新:{{ sug.last_updated.strftime('%Y-%m-%d %H:%M:%S') if sug.last_updated else '尚未更新' }} + +
diff --git a/templates/teacher_assignments.html b/templates/teacher_assignments.html index bbf250b..3a4a571 100644 --- a/templates/teacher_assignments.html +++ b/templates/teacher_assignments.html @@ -268,20 +268,19 @@
作业列表
- - + {% for assignment in assignments %} - + - + @@ -461,8 +509,8 @@
{{ card['class'].name }}
@@ -585,7 +633,7 @@
{{ card['class'].name }}
data: { labels: chartData.labels, datasets: [{ - label: '平均分', + label: '平均分(0–5)', data: chartData.scores, backgroundColor: '#4e73df', hoverBackgroundColor: '#2e59d9', diff --git a/tests/test_demo_database.py b/tests/test_demo_database.py index 8a009f1..28dcf87 100644 --- a/tests/test_demo_database.py +++ b/tests/test_demo_database.py @@ -1,8 +1,12 @@ import os import sqlite3 import unittest +from datetime import datetime, timedelta +import services.demo_database as demo_database from services.demo_database import ( + DEMO_IDLE_TIMEOUT, + cleanup_expired_demo_runs, create_demo_run, destroy_demo_run, is_demo_login_id, @@ -69,6 +73,17 @@ def test_destroying_one_run_does_not_remove_another_run(self): destroy_demo_run(second.run_id) + def test_cleanup_removes_idle_run_and_its_metadata(self): + with self.app.app_context(): + run = create_demo_run('student') + stale_at = datetime.utcnow() - DEMO_IDLE_TIMEOUT - timedelta(seconds=1) + with demo_database._LOCK: + demo_database._RUN_LAST_ACCESS[run.run_id] = stale_at + + self.assertEqual(cleanup_expired_demo_runs(datetime.utcnow()), 1) + self.assertFalse(os.path.exists(run.db_path)) + self.assertFalse(os.path.exists(f'{run.db_path}.meta')) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_demo_database_isolation.py b/tests/test_demo_database_isolation.py index 0998f39..7835b8d 100644 --- a/tests/test_demo_database_isolation.py +++ b/tests/test_demo_database_isolation.py @@ -1,10 +1,15 @@ import json +import hashlib import os import sqlite3 import unittest +from unittest.mock import patch -from models import Assignment, Class, Submission, User +from sqlalchemy import text + +from models import Assignment, Class, Submission, User, db from services.demo_database import _db_path +from services.demo_experience import get_demo_assignment_id from tests.demo_test_utils import create_test_app, destroy_test_app @@ -20,6 +25,53 @@ def _run_id(self): with self.client.session_transaction() as demo_session: return demo_session['demo_run_id'] + def _formal_snapshot(self): + """Capture formal rows and content hashes before/after a demo flow.""" + + tables = ( + 'users', + 'classes', + 'student_rosters', + 'assignments', + 'assignment_knowledge_points', + 'submissions', + 'knowledge_point_scores', + 'ability_trends', + 'teacher_ai_suggestions', + 'thinking_sessions', + 'thinking_stage_logs', + 'system_logs', + ) + snapshot = {} + with self.app.app_context(), db.engine.connect() as connection: + for table in tables: + rows = connection.execute( + text(f'SELECT * FROM "{table}" ORDER BY rowid') + ).fetchall() + normalized = [ + [ + value.isoformat() if hasattr(value, 'isoformat') else value + for value in row + ] + for row in rows + ] + serialized = json.dumps( + normalized, + ensure_ascii=False, + default=str, + separators=(',', ':'), + ).encode('utf-8') + snapshot[table] = { + 'count': len(rows), + 'sha256': hashlib.sha256(serialized).hexdigest(), + } + return snapshot + + @staticmethod + def _run_id_for(client): + with client.session_transaction() as demo_session: + return demo_session['demo_run_id'] + def test_student_demo_starts_with_rich_realistic_fixture(self): response = self.client.get('/demo-login/student') @@ -97,6 +149,88 @@ def test_teacher_demo_starts_with_roster_trend_and_suggestions(self): self.assertGreaterEqual(trends, 4) self.assertGreaterEqual(suggestions, 1) + def test_complete_demo_flows_preserve_formal_snapshot_and_delete_runs(self): + formal_before = self._formal_snapshot() + student_client = self.client + teacher_client = self.app.test_client() + + with patch('tasks.ability_analysis.trigger_analysis_if_needed', return_value=False): + student_login = student_client.get('/demo-login/student') + student_run_id = self._run_id_for(student_client) + student_path = str(_db_path(student_run_id)) + self.assertEqual(student_login.status_code, 302) + self.assertTrue(os.path.exists(student_path)) + + arena = student_client.get(student_login.headers['Location']) + self.assertEqual(arena.status_code, 200) + student_home = student_client.get('/home', follow_redirects=True) + self.assertEqual(student_home.status_code, 200) + student_profile = student_client.get('/user_profile/student_demo_good') + self.assertEqual(student_profile.status_code, 200) + + with self.app.app_context(): + from services.demo_database import activate_demo_run + + self.assertTrue(activate_demo_run(student_run_id)) + assignment_id = get_demo_assignment_id(student_run_id) + + started = student_client.post('/thinking/api/start_session', json={ + 'assignment_id': assignment_id, + }) + self.assertEqual(started.status_code, 200) + session_id = started.get_json()['session_id'] + completed = student_client.post('/thinking/api/debug/jump_stage', json={ + 'session_id': session_id, + 'stage': 4, + }) + self.assertEqual(completed.status_code, 200) + + teacher_login = teacher_client.get('/demo-login/teacher') + teacher_run_id = self._run_id_for(teacher_client) + teacher_path = str(_db_path(teacher_run_id)) + self.assertEqual(teacher_login.status_code, 302) + self.assertNotEqual(student_run_id, teacher_run_id) + self.assertTrue(os.path.exists(teacher_path)) + + teacher_home = teacher_client.get('/home', follow_redirects=True) + self.assertEqual(teacher_home.status_code, 200) + class_list = teacher_client.get('/classes/') + self.assertEqual(class_list.status_code, 200) + teacher_assignments = teacher_client.get('/teacher') + self.assertEqual(teacher_assignments.status_code, 200) + teacher_suggestions = teacher_client.get('/teacher/ai_suggestions') + self.assertEqual(teacher_suggestions.status_code, 200) + + with self.app.app_context(): + from services.demo_database import activate_demo_run + + self.assertTrue(activate_demo_run(teacher_run_id)) + demo_class = Class.query.filter_by(teacher_id='demo_t_001').first() + self.assertIsNotNone(demo_class) + class_id = demo_class.id + + class_detail = teacher_client.get(f'/classes/{class_id}') + self.assertEqual(class_detail.status_code, 200) + + connection = sqlite3.connect(student_path) + try: + demo_submission_count = connection.execute( + "SELECT COUNT(*) FROM submissions " + "WHERE code LIKE '/* codesense-demo-guided-session:%'" + ).fetchone()[0] + finally: + connection.close() + self.assertEqual(demo_submission_count, 1) + + student_client.get('/logout') + teacher_client.get('/logout') + + self.assertFalse(os.path.exists(student_path)) + self.assertFalse(os.path.exists(f'{student_path}.meta')) + self.assertFalse(os.path.exists(teacher_path)) + self.assertFalse(os.path.exists(f'{teacher_path}.meta')) + self.assertEqual(self._formal_snapshot(), formal_before) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_demo_guided_learning.py b/tests/test_demo_guided_learning.py index c688a19..479e854 100644 --- a/tests/test_demo_guided_learning.py +++ b/tests/test_demo_guided_learning.py @@ -1,8 +1,9 @@ import json import unittest from pathlib import Path +from unittest.mock import patch -from models import Assignment, AssignmentThinkingPreset, ThinkingSession, User, db +from models import Assignment, AssignmentThinkingPreset, Submission, ThinkingSession, User, db from services.demo_database import activate_demo_run from services.demo_experience import get_demo_assignment_id from tests.demo_test_utils import create_test_app, destroy_test_app @@ -184,7 +185,7 @@ def test_public_shortcut_rejects_regular_student_other_assignment_and_anonymous( base_url=base_url, json={'session_id': other_assignment_session_id, 'stage': 2}, ) - self.assertEqual(other_assignment_response.status_code, 403) + self.assertEqual(other_assignment_response.status_code, 200) self.client.get('/logout', base_url=base_url) anonymous_response = self.client.post( @@ -194,6 +195,83 @@ def test_public_shortcut_rejects_regular_student_other_assignment_and_anonymous( ) self.assertEqual(anonymous_response.status_code, 403) + def test_demo_tree_preset_recovers_without_queueing_formal_ai_task(self): + run_id = self._login_demo() + assignment_id = self._demo_assignment_id(run_id, 'guided_tree') + + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + preset = AssignmentThinkingPreset.query.filter_by( + assignment_id=assignment_id, + ).one() + preset.status = 'failed' + preset.quiz_steps = '[]' + db.session.commit() + + with patch('utils.async_tasks.add_generate_preset_task') as queue_task: + arena_response = self.client.get(f'/thinking/{assignment_id}') + self.assertEqual(arena_response.status_code, 200) + queue_task.assert_not_called() + + start_response = self.client.post('/thinking/api/start_session', json={ + 'assignment_id': assignment_id, + }) + self.assertEqual(start_response.status_code, 200) + self.assertEqual(start_response.get_json()['preset']['status'], 'ready') + + def test_demo_tree_assignment_supports_quick_jump(self): + run_id = self._login_demo() + assignment_id = self._demo_assignment_id(run_id, 'guided_tree') + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + thinking_session = ThinkingSession( + student_id='demo_s_001', + assignment_id=assignment_id, + ) + db.session.add(thinking_session) + db.session.commit() + session_id = thinking_session.id + + response = self.client.post('/thinking/api/debug/jump_stage', json={ + 'session_id': session_id, + 'stage': 3, + }) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.get_json()['success']) + + def test_demo_guided_completion_creates_temporary_five_point_submission(self): + run_id = self._login_demo() + assignment_id = self._demo_assignment_id(run_id) + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + thinking_session = ThinkingSession( + student_id='demo_s_001', + assignment_id=assignment_id, + ) + db.session.add(thinking_session) + db.session.commit() + session_id = thinking_session.id + + with patch('tasks.ability_analysis.trigger_analysis_if_needed', return_value=False): + response = self.client.post('/thinking/api/debug/jump_stage', json={ + 'session_id': session_id, + 'stage': 4, + }) + self.assertEqual(response.status_code, 200) + + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + submission = Submission.query.filter_by( + student_id='demo_s_001', + assignment_id=assignment_id, + ).filter(Submission.code.like('/* codesense-demo-guided-session:%')).one() + self.assertEqual(submission.status, 'evaluated') + self.assertGreaterEqual(submission.score, 0) + self.assertLessEqual(submission.score, 5) + + with self.app.app_context(): + self.assertEqual(Submission.query.count(), 0) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_demo_profile_views.py b/tests/test_demo_profile_views.py new file mode 100644 index 0000000..8961886 --- /dev/null +++ b/tests/test_demo_profile_views.py @@ -0,0 +1,133 @@ +"""Regression tests for the public demo's student and teacher views.""" + +import re +import unittest + +from models import Assignment, Class, KnowledgePointScore +from services.demo_database import activate_demo_run +from services.demo_experience import ( + C_LANGUAGE_POINTS, + DEMO_CLASS_NAME, + DEMO_ASSIGNMENT_TITLE, + DEMO_SECOND_ASSIGNMENT_TITLE, +) +from tests.demo_test_utils import create_test_app, destroy_test_app + + +class DemoProfileViewsTestCase(unittest.TestCase): + def setUp(self): + self.app = create_test_app() + self.client = self.app.test_client() + + def tearDown(self): + destroy_test_app(self.app) + + def _login(self, role): + response = self.client.get(f'/demo-login/{role}') + self.assertEqual(response.status_code, 302) + with self.client.session_transaction() as client_session: + run_id = client_session['demo_run_id'] + with self.app.app_context(): + self.assertTrue(activate_demo_run(run_id)) + demo_class = Class.query.filter_by(name=DEMO_CLASS_NAME).one() + assignments = Assignment.query.order_by(Assignment.id).all() + knowledge_count = KnowledgePointScore.query.filter_by( + student_id='demo_s_001' + ).count() + return run_id, demo_class.id, assignments, knowledge_count + + def test_student_views_render_complete_profile_and_score_scale(self): + _, _, _, knowledge_count = self._login('student') + self.assertEqual(knowledge_count, len(C_LANGUAGE_POINTS)) + + home = self.client.get('/home') + self.assertEqual(home.status_code, 200) + home_html = home.data.decode('utf-8') + self.assertEqual( + len(re.findall(r'data-knowledge-point="[a-z_]+"', home_html)), + len(C_LANGUAGE_POINTS), + ) + self.assertIn('提交评分 0–5 分', home_html) + self.assertIn('多维度贝叶斯权重评估(0–100)', home_html) + self.assertIn('AI 个性化分析', home_html) + self.assertIn('data-analysis-status=', home_html) + self.assertIn('/5', home_html) + self.assertIn('基础语法', home_html) + self.assertIn('递归', home_html) + + profile = self.client.get('/user_profile/student_demo_good') + self.assertEqual(profile.status_code, 200) + profile_html = profile.data.decode('utf-8') + self.assertEqual( + len(re.findall(r'data-knowledge-point="[a-z_]+"', profile_html)), + len(C_LANGUAGE_POINTS), + ) + self.assertIn('C语言知识点画像', profile_html) + self.assertIn('提交分数 0–5 分', profile_html) + for _, name in C_LANGUAGE_POINTS: + self.assertIn(name, profile_html) + + assignment_list = self.client.get('/student_assignments') + self.assertEqual(assignment_list.status_code, 200) + assignment_html = assignment_list.data.decode('utf-8') + self.assertIn('个人最高分(0–5)', assignment_html) + self.assertIn(DEMO_ASSIGNMENT_TITLE, assignment_html) + self.assertIn(DEMO_SECOND_ASSIGNMENT_TITLE, assignment_html) + + def test_student_analysis_status_api_exposes_temporary_state(self): + self._login('student') + response = self.client.get('/api/student/ability-trend-status') + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertTrue(payload['success']) + data = payload['data'] + self.assertIn(data['status'], {'pending', 'processing', 'completed', 'failed', 'outdated'}) + self.assertGreaterEqual(data['submissions_count'], 10) + self.assertIn('last_updated', data) + + def test_teacher_views_render_roster_assignments_trend_and_ai_state(self): + _, class_id, assignments, _ = self._login('teacher') + self.assertGreaterEqual(len(assignments), 6) + + dashboard = self.client.get('/home', follow_redirects=True) + self.assertEqual(dashboard.status_code, 200) + dashboard_html = dashboard.data.decode('utf-8') + self.assertIn('近 14 天提交趋势', dashboard_html) + self.assertIn('data-trend-points="14"', dashboard_html) + self.assertIn('提交评分 0–5 分', dashboard_html) + self.assertIn('AI 建议状态', dashboard_html) + self.assertIn('孙三(风险)', dashboard_html) + + class_list = self.client.get('/classes/') + self.assertEqual(class_list.status_code, 200) + class_list_html = class_list.data.decode('utf-8') + self.assertIn('软件工程24-演示班', class_list_html) + self.assertIn('班级平均分(0–5)', class_list_html) + self.assertIn('data-class-id=', class_list_html) + + class_detail = self.client.get(f'/classes/{class_id}') + self.assertEqual(class_detail.status_code, 200) + class_detail_html = class_detail.data.decode('utf-8') + self.assertIn('assignment-matrix', class_detail_html) + self.assertIn('周四', class_detail_html) + self.assertIn('李四(未注册)', class_detail_html) + self.assertIn('提交得分(0–5)', class_detail_html) + self.assertIn(DEMO_SECOND_ASSIGNMENT_TITLE, class_detail_html) + + assignments_page = self.client.get('/teacher') + self.assertEqual(assignments_page.status_code, 200) + assignments_html = assignments_page.data.decode('utf-8') + self.assertIn('提交/平均分(0–5)', assignments_html) + self.assertGreaterEqual(assignments_html.count('data-assignment-id='), 6) + self.assertIn(DEMO_ASSIGNMENT_TITLE, assignments_html) + + suggestions = self.client.get('/teacher/ai_suggestions') + self.assertEqual(suggestions.status_code, 200) + suggestions_html = suggestions.data.decode('utf-8') + self.assertIn('AI 个性化教学建议', suggestions_html) + self.assertIn('data-ai-status=', suggestions_html) + self.assertIn('最近更新', suggestions_html) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_demo_submission_isolation.py b/tests/test_demo_submission_isolation.py index c59d6da..bbb35c7 100644 --- a/tests/test_demo_submission_isolation.py +++ b/tests/test_demo_submission_isolation.py @@ -4,7 +4,7 @@ from models import AbilityTrend, Submission, SystemLog, db from services.demo_database import activate_demo_run from services.demo_experience import DEMO_STUDENT_ID, get_demo_assignment_id -from tasks.submission_tasks import evaluate_submission_async +from tasks.submission_tasks import _normalise_score, evaluate_submission_async from tests.demo_test_utils import create_test_app, destroy_test_app @@ -28,6 +28,12 @@ def setUp(self): def tearDown(self): destroy_test_app(self.app) + def test_submission_score_normalisation_preserves_source_scale(self): + self.assertEqual(_normalise_score(4), 4) + self.assertEqual(_normalise_score(8), 4) + self.assertEqual(_normalise_score(80), 4) + self.assertEqual(_normalise_score(100), 5) + def test_evaluation_updates_only_current_demo_database(self): with self.app.app_context(): self.assertTrue(activate_demo_run(self.run_id)) From 50507e11064453a43461e55b66c82c2c98e8b6c0 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Fri, 28 Aug 2026 15:11:55 +0800 Subject: [PATCH 10/12] fix: make demo cleanup safe with active sessions --- routes/auth.py | 9 ++- services/demo_database.py | 107 ++++++++++++++++++++++++++++-------- tests/test_demo_database.py | 71 ++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 26 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index c492098..a07900d 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -322,11 +322,16 @@ def logout(): if demo_run_id: db.session.remove() + demo_data_removed = True try: - destroy_demo_run(demo_run_id) + demo_data_removed = destroy_demo_run(demo_run_id) except Exception: current_app.logger.exception('公开体验临时库清理失败') - flash('本次体验已结束,体验数据已清除', 'info') + demo_data_removed = False + if demo_data_removed: + flash('本次体验已结束,体验数据已清除', 'info') + else: + flash('本次体验已结束,临时数据将在后台完成清理', 'warning') return redirect(url_for('auth.login')) if user_id: diff --git a/services/demo_database.py b/services/demo_database.py index b04a97a..03f4175 100644 --- a/services/demo_database.py +++ b/services/demo_database.py @@ -67,6 +67,16 @@ def _metadata_path(run_id: str) -> Path: return metadata_path +def _pending_delete_path(run_id: str) -> Path: + """Return the marker used to retry deletion after a transient file lock.""" + + database_path = _db_path(run_id) + pending_path = Path(f"{database_path}.pending").resolve() + if pending_path.parent != database_path.parent: + raise ValueError("体验数据库待删除标记路径越界") + return pending_path + + def _sqlite_uri(path: Path) -> str: return f"sqlite:///{path.as_posix()}" @@ -119,6 +129,22 @@ def _is_expired(run_id: str, now: datetime) -> bool: ) +def _unlink_with_retry(path: Path) -> bool: + """Delete one sidecar, returning false when another process still holds it.""" + + for attempt in range(3): + try: + path.unlink() + return True + except FileNotFoundError: + return True + except PermissionError: + if attempt == 2: + return False + time.sleep(0.02) + return False + + @dataclass(frozen=True) class DemoRun: """A single public-demo database and its stable temporary identities.""" @@ -351,7 +377,13 @@ def activate_demo_request_database() -> bool: def destroy_demo_run(run_id: str) -> bool: - """Dispose and delete one temporary database and its SQLite sidecars.""" + """Dispose and delete one temporary database and its SQLite sidecars. + + A worker may still hold a connection briefly after the browser logs out. + Windows refuses to unlink such a file, so cleanup is deliberately + best-effort: a marker is left behind and the maintenance sweep retries it + later instead of breaking the request or another test's teardown. + """ path = _db_path(run_id) with _LOCK: @@ -366,25 +398,26 @@ def destroy_demo_run(run_id: str) -> bool: gc.collect() existed = False + cleanup_failed = False for candidate in ( path, _metadata_path(run_id), Path(f"{path}-wal"), Path(f"{path}-shm"), Path(f"{path}-journal"), + _pending_delete_path(run_id), ): if candidate.exists(): existed = True - for attempt in range(3): - try: - candidate.unlink() - break - except FileNotFoundError: - break - except PermissionError: - if attempt == 2: - raise - time.sleep(0.02) + if not _unlink_with_retry(candidate): + cleanup_failed = True + + if cleanup_failed: + try: + _pending_delete_path(run_id).touch(exist_ok=True) + except OSError: + pass + return False return existed or engine is not None @@ -394,15 +427,34 @@ def cleanup_expired_demo_runs(now: datetime | None = None) -> int: now = now or datetime.utcnow() removed = 0 root = _demo_root() + run_ids = set() for path in root.glob("*.sqlite3"): - run_id = path.stem - if not _RUN_ID_PATTERN.fullmatch(run_id): + if _RUN_ID_PATTERN.fullmatch(path.stem): + run_ids.add(path.stem) + pending_suffix = ".sqlite3.pending" + for path in root.glob(f"*{pending_suffix}"): + if path.name.endswith(pending_suffix): + run_id = path.name[: -len(pending_suffix)] + if _RUN_ID_PATTERN.fullmatch(run_id): + run_ids.add(run_id) + + for run_id in run_ids: + try: + expired = _pending_delete_path(run_id).exists() or _is_expired( + run_id, + now, + ) + except (OSError, ValueError): + continue + if not expired: continue try: - expired = _is_expired(run_id, now) - except FileNotFoundError: + deleted = destroy_demo_run(run_id) + except OSError: + # A different process can still own the SQLite handle. Continue + # with other sessions and let the next sweep retry this one. continue - if expired and destroy_demo_run(run_id): + if deleted: removed += 1 return removed @@ -428,17 +480,24 @@ def _maybe_cleanup_expired_demo_runs(now: datetime) -> None: def destroy_all_demo_runs() -> int: - """Remove every valid demo run, primarily for deterministic test cleanup.""" - root = _demo_root() - run_ids = set() + """Remove runs owned by this process, primarily for test cleanup. + + Do not scan every file in the shared temp directory here: test teardown + must never remove an active visitor's session from another process. + Cross-process leftovers are handled by ``cleanup_expired_demo_runs``. + """ + with _LOCK: - run_ids.update(_ENGINES) - for path in root.glob('*.sqlite3'): - if _RUN_ID_PATTERN.fullmatch(path.stem): - run_ids.add(path.stem) + run_ids = set(_ENGINES) removed = 0 for run_id in run_ids: - if destroy_demo_run(run_id): + try: + deleted = destroy_demo_run(run_id) + except OSError: + # A background worker may still be finishing a task for this run. + # Leave the pending marker for a later maintenance sweep. + continue + if deleted: removed += 1 return removed diff --git a/tests/test_demo_database.py b/tests/test_demo_database.py index 28dcf87..4d3a7a7 100644 --- a/tests/test_demo_database.py +++ b/tests/test_demo_database.py @@ -1,11 +1,15 @@ import os import sqlite3 +import tempfile import unittest from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch import services.demo_database as demo_database from services.demo_database import ( DEMO_IDLE_TIMEOUT, + DEMO_MAX_LIFETIME, cleanup_expired_demo_runs, create_demo_run, destroy_demo_run, @@ -84,6 +88,73 @@ def test_cleanup_removes_idle_run_and_its_metadata(self): self.assertFalse(os.path.exists(run.db_path)) self.assertFalse(os.path.exists(f'{run.db_path}.meta')) + def test_cleanup_skips_locked_run_and_continues_with_other_runs(self): + locked_id = 'a' * 48 + removable_id = 'b' * 48 + stale_value = ( + datetime.utcnow() - DEMO_MAX_LIFETIME - timedelta(seconds=1) + ).isoformat() + + with tempfile.TemporaryDirectory() as root_name: + root = Path(root_name) + for run_id in (locked_id, removable_id): + (root / f'{run_id}.sqlite3').touch() + (root / f'{run_id}.sqlite3.meta').write_text( + stale_value, + encoding='ascii', + ) + + def destroy(run_id): + if run_id == locked_id: + raise PermissionError('demo database is still in use') + for suffix in ('.sqlite3', '.sqlite3.meta'): + (root / f'{run_id}{suffix}').unlink() + return True + + with patch.object(demo_database, '_demo_root', return_value=root): + with patch.object( + demo_database, + 'destroy_demo_run', + side_effect=destroy, + ): + removed = demo_database.cleanup_expired_demo_runs() + + self.assertEqual(removed, 1) + self.assertTrue((root / f'{locked_id}.sqlite3').exists()) + self.assertFalse((root / f'{removable_id}.sqlite3').exists()) + + def test_destroy_all_skips_locked_run_and_continues_with_other_runs(self): + locked_id = 'c' * 48 + removable_id = 'd' * 48 + + with tempfile.TemporaryDirectory() as root_name: + root = Path(root_name) + for run_id in (locked_id, removable_id): + (root / f'{run_id}.sqlite3').touch() + + def destroy(run_id): + if run_id == locked_id: + raise PermissionError('demo database is still in use') + (root / f'{run_id}.sqlite3').unlink() + return True + + with patch.object(demo_database, '_demo_root', return_value=root): + with patch.object( + demo_database, + '_ENGINES', + {locked_id: object(), removable_id: object()}, + ): + with patch.object( + demo_database, + 'destroy_demo_run', + side_effect=destroy, + ): + removed = demo_database.destroy_all_demo_runs() + + self.assertEqual(removed, 1) + self.assertTrue((root / f'{locked_id}.sqlite3').exists()) + self.assertFalse((root / f'{removable_id}.sqlite3').exists()) + if __name__ == '__main__': unittest.main() From 70367a98c60abe146c8f95e0a4f19cc006822353 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Fri, 28 Aug 2026 16:59:41 +0800 Subject: [PATCH 11/12] docs: require PR approval before main merge --- AGENTS.md | 85 ++----------------------------------------------------- 1 file changed, 2 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d07417e..470c51a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,84 +1,3 @@ -# AGENTS.md +# Git 与 PR 规则 -This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. - -## Project Overview - -CodeSense 酷森思 is an intelligent programming education platform for universities. It uses a "Causal Sandbox" for code execution and "Heuristic LLM" for AI-powered programming guidance that leads students to answers through questioning rather than providing direct solutions. - -## Commands - -```bash -# Install dependencies -pip install -r requirements.txt - -# Run development server -python run.py -# or -python app.py - -# Run tests -python -m pytest tests/test_app.py -# or -python tests/test_app.py - -# Initialize database (in Python shell) -from models import db, app -with app.app_context(): - db.create_all() - -# Production deployment -gunicorn -w 4 -b 0.0.0.0:5000 wsgi:app -``` - -## Architecture - -### Application Entry Point -- `app.py` - Main application factory (`create_app()`), registers blueprints, initializes models and async tasks -- `run.py` - Simple wrapper that imports and runs `app.py` -- `wsgi.py` - Production WSGI entry point - -### Configuration -- `config.py` - Config class with three environments: `development`, `testing`, `production` - - Database: `DATABASE_URL` env var (MySQL in production, SQLite in development) - - AI APIs: `ZHIPU_API_KEY` and/or `OPENAI_API_KEY` - - `LOAD_LOCAL_MODEL=False` for cloud deployments (saves ~1GB memory) - -### Blueprints (Routes) -| Blueprint | Purpose | -|-----------|---------| -| `routes/auth.py` | Login, logout, registration | -| `routes/main.py` | Main pages (home, about, help) | -| `routes/assignments.py` | Assignment CRUD operations | -| `routes/users.py` | User profile management | -| `routes/classes.py` | Class management for teachers | -| `routes/api.py` | REST API for code submission, AI evaluation, ability scoring | - -### Core Services (`utils/`) -- `code_evaluator.py` - CodeBERT embedding + TextCNN scoring, initializes local ML models -- `sandbox_runner.py` - subprocess-based code execution sandbox with timeout -- `llm_evaluator.py` - GLM-4/GPT-4 API calls for code evaluation -- `guidance_generator.py` - Heuristic prompts that guide students through questioning -- `code_advisor.py` - Code advice and feedback generation -- `async_tasks.py` - ThreadPool-based async task queue with SSE streaming -- `ability_scorer.py` - Bayesian-weighted ability tracking across 13 C programming concepts -- `prompts.py` - Prompt templates for AI interactions - -### Models (`models/`) -- `CNN.py` - TextCNN model using CodeBERT embeddings -- `codebert.py` - CodeBERT model wrapper -- `codebertcnn.pth` - Pretrained weights - -### Key Patterns -1. **AI-only mode**: Set `LOAD_LOCAL_MODEL=False` to skip PyTorch model loading -2. **Async evaluation**: Code submissions go through `async_tasks.py` with SSE progress updates -3. **Sandbox security**: 15s compile timeout, 5s run timeout, subprocess isolation -4. **Session management**: Flask-Session with filesystem backend - -## Database - -SQLAlchemy ORM with models in `models.py`. Uses Flask-Migrate for schema migrations. Key models: User (with usertype: 学生/教师/管理员), Assignment, Submission, Class, AbilityScore. - -## Frontend - -Bootstrap 5 + Monaco Editor for code editing. Jinja2 templates in `templates/`. Static assets in `static/` with CSS, JS, and images. +每次项目改动都必须提交到独立分支并推送至 GitHub,创建 PR,详细备注改动内容与测试结果。创建 PR 后先向项目负责人申请合并;未经本人明确同意,不得推送或合并到 `main`,获准后方可合并。 From 708f4c280475194f71c49d8890d0cbdc1af90723 Mon Sep 17 00:00:00 2001 From: XiaoCow666 Date: Fri, 28 Aug 2026 17:03:36 +0800 Subject: [PATCH 12/12] docs: add concise CodeSense agent guide --- AGENTS.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 470c51a..2c11147 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,47 @@ -# Git 与 PR 规则 +# 源代码目录:CodeSense 酷森思 -每次项目改动都必须提交到独立分支并推送至 GitHub,创建 PR,详细备注改动内容与测试结果。创建 PR 后先向项目负责人申请合并;未经本人明确同意,不得推送或合并到 `main`,获准后方可合并。 +## 项目摘要 + +CodeSense 酷森思是面向高校编程教学的 Flask 平台,整合代码评测、因果隔离沙箱、启发式 AI 辅导和教师学情分析。核心学习流程为“思路描述 → 积木式编程 → 费曼教学”,AI 只提供引导,不直接投喂完整代码。 + +## 常用命令 + +```bash +# 安装依赖 +python -m pip install -r requirements.txt + +# 启动开发服务 +python run.py +# 或 +python app.py + +# 运行全部测试 +python -m pytest tests -q + +# 运行单项测试 +python -m pytest tests/test_app.py -q + +# 生产服务 +gunicorn -w 4 -b 0.0.0.0:5000 wsgi:app +``` + +配置使用 `.env.example` 作为模板;不要提交 `.env`、密钥或本地数据库文件。测试使用 `create_app("testing")` 和独立 SQLite 数据库。 + +## 目录索引 + +- `app.py`、`run.py`、`wsgi.py`:应用创建与启动入口。 +- `routes/`:认证、页面、作业、班级、提交 API、三阶段学习和成绩路由。 +- `services/`、`utils/`、`tasks/`:业务服务、评测与 AI 工具、异步任务。 +- `models.py`、`models/`:数据库模型和本地评测模型。 +- `templates/`、`static/`:页面模板和前端资源。 +- `tests/`:应用、沙箱、演示体验、教师分析和引导式学习测试。 + +## 必须保持的约束 + +- 保持沙箱隔离和超时限制:编译 15 秒、运行 5 秒;不要为方便调试而放宽限制。 +- 保持 `sanitize_response` 与提示词约束,AI 辅导不得输出完整答案代码。 +- 演示体验使用临时数据库并与正式数据隔离;AI 失败时必须明确显示失败或重试,不得伪造结果。 + +## Git 与 PR 规则 + +每次项目改动都必须在独立分支提交并推送到 GitHub,创建或更新 PR,并详细备注改动内容、测试结果和注意事项。随后先向项目负责人申请合并;未经本人明确同意,不得推送或合并到 `main`,获准后方可合并。
ID 作业标题 发布日期 截止倒计时 已布置给我的班级提交/平均分提交/平均分(0–5) 操作
{{ assignment.id }} 作业列表 提交: {{ assignment.count }}
- 平均: {{ "%.2f"|format(assignment.average_score) }} + 平均: {{ "%.1f"|format(assignment.average_score|float) }}/5
@@ -363,4 +362,4 @@
作业列表
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/teacher_home.html b/templates/teacher_home.html index df5f69a..42e11bc 100644 --- a/templates/teacher_home.html +++ b/templates/teacher_home.html @@ -279,7 +279,7 @@

{{ attention.low_score_count + attention.no_submission_count + attention.ina
-

基于各个班级本周的提交率、低分率、活跃趋势及能力分析,AI 自动生成点对点教学对策:

+

基于各个班级本周的提交率、低分率、活跃趋势及能力分析,AI 自动生成点对点教学对策。提交评分 0–5 分,知识点画像 0–100 分。

{% for card in class_cards %} {% set sug = ai_suggestions.get(card['class'].id) %} @@ -290,14 +290,14 @@
{{ card['class'].name }} {% if sug %} {% if sug.status == 'completed' %} - 建议已生成 + 建议已生成 {% elif sug.status == 'processing' or sug.status == 'pending' %} - 分析中... + AI 建议状态:分析中 {% else %} - 分析失败 + AI 建议状态:分析失败 {% endif %} {% else %} - 待分析 + AI 建议状态:待分析 {% endif %}
@@ -325,7 +325,7 @@
- 暂无本周分析建议,点击右上角进入建议落地页,即可自动触发生成。 + 当前显示实时学情摘要;进入建议落地页后,系统会在本次会话中调用 AI 生成完整报告。
{% endif %}
@@ -343,8 +343,8 @@
近 14 天提交趋势
-
- 共 {{ submission_trend|sum(attribute='count') }} 次提交 +
+ 共 {{ submission_trend|sum(attribute='count') }} 次提交 · {{ submission_trend|length }} 个趋势点
@@ -352,6 +352,54 @@
+
+ 学生画像快照 + 共 {{ student_rows|length }} 位学生 · 综合分 0–5 +
+ {% if student_rows %} +
+ + + + + + + + + + + + + {% for row in student_rows %} + + + + + + + + + {% endfor %} + +
学生综合分(0–5)最近提交(0–5)提交次数学习状态风险提示
+ + {{ row.student.full_name or row.student.username }} + + {{ "%.1f"|format(row.student.user_ascore|float) }}/5 + {% if row.latest_score is not none %}{{ "%.1f"|format(row.latest_score|float) }}/5{% else %}未提交{% endif %} + {{ row.submit_count }}{{ row.status }} + {% if row.risk_tags %} + {% for tag in row.risk_tags %}{{ tag }}{% endfor %} + {% else %}正常{% endif %} +
+
+ {% else %} +

暂无学生数据。

+ {% endif %} +
+
@@ -366,7 +414,7 @@
- 各班级平均分对比 + 各班级平均分对比(0–5)
@@ -446,7 +494,7 @@
{{ card['class'].name }}

学生 作业 提交时间分数分数(0–5)
{{ sub.submitted_at.strftime('%Y-%m-%d %H:%M') }} - {{ sub.score if sub.score is not none else '待评分' }} + class="badge {% if sub.score is not none and sub.score >= 4 %}bg-success{% elif sub.score is not none and sub.score == 3 %}bg-warning{% else %}bg-danger{% endif %}"> + {% if sub.score is not none %}{{ "%.1f"|format(sub.score|float) }}/5{% else %}待评分{% endif %}