From 8813e5983da0cd271c615bc14ec9c58f058fd9a2 Mon Sep 17 00:00:00 2001 From: Swan1127 <3444176319@qq.com> Date: Wed, 16 Sep 2026 00:02:04 +0800 Subject: [PATCH] feat: add explainable adaptive learning recommendations --- .../2026-09-15-adaptive-learning-phase1.md | 66 ++++ routes/main.py | 33 +- services/adaptive_learning.py | 298 ++++++++++++++++++ templates/student_home.html | 36 +++ tests/test_adaptive_learning.py | 202 ++++++++++++ 5 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-09-15-adaptive-learning-phase1.md create mode 100644 services/adaptive_learning.py create mode 100644 tests/test_adaptive_learning.py diff --git a/docs/superpowers/plans/2026-09-15-adaptive-learning-phase1.md b/docs/superpowers/plans/2026-09-15-adaptive-learning-phase1.md new file mode 100644 index 0000000..25ec16d --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-adaptive-learning-phase1.md @@ -0,0 +1,66 @@ +# 自适应掌握度学习闭环:Phase 1 只读建议基础 + +## 目标与变更范围 + +本阶段在学生首页增加一张“自适应下一步”卡片。它从当前账号已经可见的、当前未截止作业、最近三阶段学习会话、知识点掌握度和最近提交摘要中,按固定规则给出一个可解释的下一步入口。 + +- 新增 `services/adaptive_learning.py`:纯函数、无 Flask/数据库/网络/AI 依赖。 +- `routes/main.py` 在既有学生与班级范围查询之后调用该函数;操作 URL 仅由服务端按已有 endpoint 生成。 +- `templates/student_home.html` 显示建议、证据和边界说明。 +- `tests/test_adaptive_learning.py` 覆盖优先级、无证据回退、隐私输出边界和首页集成。 + +本阶段不增加 API、角色、表、字段、索引、迁移、后台任务、模型调用、生产配置或外部通知。 + +## 规则与事实边界 + +推荐优先级是确定性的: + +1. 继续当前有效作业上可恢复的三阶段会话。 +2. 在至少一次已有练习、掌握度低于 70 分的知识点中,匹配带有该知识点标签的当前作业。 +3. 复练当前有效作业中最近一条失败、部分通过或得分低于 60 分的提交。 +4. 开始一份尚无提交记录的当前有效作业。 +5. 没有当前证据时转到作业列表,并明确不对真实掌握情况作推断。 + +事实:作业有效性、会话可恢复状态、知识点得分/次数、提交状态与得分均为已有持久数据的只读投影。建议内容不读取或输出提交代码、评语、AI 输出、会话对话内容或学生标识。 + +推断:低于 70 分且已有练习记录被标记为“优先巩固”,只是可复核的排序规则,不是对学习能力、成绩或教学效果的判断。没有记录时不把 0 分解释为薄弱。 + +## 安全与权限边界 + +- 输入仍由 `/home` 既有 `login_required`、当前学生 ID、权威班级与 `assignment_target_class_filter` 限定;服务层不扩大查询范围。 +- 只对 `active_assignment_ids` 中的作业生成操作建议;会话和提交再次按该集合过滤。 +- 跳转 URL 不由数据库文本或推荐服务提供:`thinking.arena`、`assignments.submit_code` 和 `assignments.student_assignments` 三种既有服务端路由按动作类型生成。 +- 没有新写入,因此不存在计划持久化、跨学生读取、教师覆盖、权限提升或可回滚数据清理问题。 +- 未读取 `.env`、未接触凭据、未执行真实 AI、沙箱、Redis、生产部署或数据库结构操作。 + +## 验证命令与预期证据 + +本机没有仓库运行指南中提到的 `student-eval` Conda 环境,因此验证使用工作区内、Git 忽略的 Python 3.13 `.venv`。该环境只安装仓库声明的运行/测试依赖;为运行仓库既有 `template_rendered` 测试而额外安装的 `blinker` 仅存在于该隔离环境,未修改依赖清单。 + +实际执行: + +```powershell +\.venv\Scripts\python.exe -m pytest tests/test_adaptive_learning.py -q --disable-warnings +\.venv\Scripts\python.exe -m pytest tests/test_student_home_history.py -q --disable-warnings +\.venv\Scripts\python.exe -m pytest tests/test_session_lifecycle_routes.py -q --disable-warnings +\.venv\Scripts\python.exe -m compileall -q services/adaptive_learning.py routes/main.py tests/test_adaptive_learning.py +git diff --check +``` + +结果:新增自适应测试 `6 passed`;既有学生首页历史统计 `1 passed`;既有会话连续性路由 `7 passed`;`compileall` 与 `git diff --check` 均以 0 退出。pytest 同时报告了仓库既有依赖/`datetime.utcnow` 等大量 warning;本阶段未通过忽略或改写警告来掩盖它们。 + +全量 `pytest -q --disable-warnings` 的事实边界: + +- 第一次:`686 passed, 1 failed`,失败在未改动的 AI/SSE 测试导入 OpenAI 前;根因是 `.venv` 中 CPython 3.11 的 `pydantic_core` 扩展与 Python 3.13 不兼容。 +- 修复隔离环境中已声明的二进制依赖后,AI/SSE 原测试单独为 `1 passed`。 +- 第二次:`686 passed, 1 failed`,失败为未改动的 RQ 提交 worker 集成测试。该次全量运行中该测试意外尝试真实 LLM provider 并遇到鉴权失败,随后同一测试单独重跑为 `1 passed`。 + +因此可以复核的结论是:本阶段新增与相邻回归共 `14 passed`,全量其余 `686` 项在第二次运行通过,唯一失败项单独复核通过;但尚未获得一次单进程、无波动的 `687 passed` 全量输出。这个 worker/真实 provider 耦合是现有测试隔离风险,不是本阶段改动;仍应由 CI 或人工评审复跑全量门禁。 + +验收证据包括:纯函数优先级测试、首页模板中建议与服务器生成 URL 的断言、既有学生首页历史统计与会话连续性回归、Python 编译和 diff 空白检查。测试使用临时 SQLite 文件;不连接主开发数据库或生产数据库。 + +## 回滚与下一阶段门槛 + +回滚仅需撤销本阶段提交;没有迁移、后台状态或已保存学习计划需要恢复。 + +下一阶段若要保存推荐历史、让教师覆盖推荐、写入掌握度规则版本,或根据推荐闭环统计效果,将触及数据模型和权限语义。届时必须先提交:数据最小化方案、迁移/回滚方案、角色授权矩阵、历史数据兼容策略、隔离测试数据库验证和人工审批;在批准前不得直接写入这些状态。 diff --git a/routes/main.py b/routes/main.py index 8850204..52771bd 100644 --- a/routes/main.py +++ b/routes/main.py @@ -8,7 +8,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, Response, current_app, abort, g from flask_login import login_required, current_user from sqlalchemy import and_, func, or_ -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, selectinload from models import ( db, User, @@ -43,6 +43,7 @@ ) from services.submission_reviews import count_open_reviews from services.session_lifecycle import latest_session_activity, session_lifecycle_payload +from services.adaptive_learning import build_adaptive_learning_plan from services.action_center import build_action_center from services.profile import get_profile_settings, PROFILE_VISIBILITY_PUBLIC from utils.auth import admin_required @@ -154,6 +155,16 @@ def home(): (Assignment.due_date >= now) | (Assignment.due_date.is_(None)) ).with_entities(Assignment.id).all()] + # 自适应建议仅使用已经按学生班级筛选出的当前作业。预加载知识点标签, + # 避免推荐规则按作业逐条查询,也不读取作业描述或学生代码。 + active_assignments = [] + if active_assignment_ids: + active_assignments = Assignment.query.options( + selectinload(Assignment.knowledge_points), + ).filter( + Assignment.id.in_(active_assignment_ids), + ).all() + # 2. 首页统计保留历史作业,避免截止日期过滤让学生误以为数据被清空。 # 当前有效作业仍单独保留,供页面展示“当前未截止”信息。 assignments_count = len(all_assigned_ids) @@ -268,6 +279,25 @@ def home(): # 这样首屏不会只显示“加载中”,网络较慢时也能看到真实的演示数据。 knowledge_profile = KnowledgePointScore.get_student_profile(student_id) knowledge_profile_rows = _knowledge_profile_rows(knowledge_profile) + adaptive_learning_plan = build_adaptive_learning_plan( + active_assignments=active_assignments, + recent_learning_sessions=recent_learning_sessions, + knowledge_profile_rows=knowledge_profile_rows, + recent_submissions=submissions, + submitted_assignment_ids=submitted_assignment_ids, + ) + adaptive_action = adaptive_learning_plan['action'] + assignment_id = adaptive_action.get('assignment_id') + if adaptive_action['kind'] == 'resume_learning' and assignment_id: + adaptive_action['href'] = url_for('thinking.arena', assignment_id=assignment_id) + elif adaptive_action['kind'] in { + 'practice_knowledge_point', + 'retry_submission', + 'start_assignment', + } and assignment_id: + adaptive_action['href'] = url_for('assignments.submit_code', assignment_id=assignment_id) + else: + adaptive_action['href'] = url_for('assignments.student_assignments') analysis_status = trend_record.status or 'pending' # 1. 通过统一的能力引擎获取雷达图数据 ability_scores = current_user.get_ability_scores() @@ -349,6 +379,7 @@ def home(): 'phi_grad': round(phi_grad, 1), 'recent_assignments': recent_assignments, 'recent_learning_sessions': recent_learning_sessions, + 'adaptive_learning_plan': adaptive_learning_plan, 'submissions': submissions, 'knowledge_profile': knowledge_profile, 'knowledge_profile_rows': knowledge_profile_rows, diff --git a/services/adaptive_learning.py b/services/adaptive_learning.py new file mode 100644 index 0000000..ca30bba --- /dev/null +++ b/services/adaptive_learning.py @@ -0,0 +1,298 @@ +"""Deterministic, read-only recommendations for the student home page. + +The adaptive-learning foundation deliberately consumes already-authorized data +from a route instead of opening its own database session. That keeps its +scope explicit: it neither persists a learning plan nor inspects submission +source code, feedback, prompts, or private session content. +""" + +from datetime import datetime, timezone + + +NEEDS_PRACTICE_BELOW = 70.0 +MIN_KNOWLEDGE_ATTEMPTS = 1 +PASSING_SCORE = 60.0 + + +def _value(item, name, default=None): + """Read a named value from either a mapping or a lightweight object.""" + if isinstance(item, dict): + return item.get(name, default) + return getattr(item, name, default) + + +def _positive_int(value): + try: + result = int(value) + except (TypeError, ValueError, OverflowError): + return None + return result if result > 0 else None + + +def _non_negative_int(value, default=0): + try: + return max(0, int(value or 0)) + except (TypeError, ValueError, OverflowError): + return default + + +def _score(value): + try: + result = float(value) + except (TypeError, ValueError, OverflowError): + return None + return min(100.0, max(0.0, result)) + + +def _display_score(value): + normalized = _score(value) + if normalized is None: + return None + return f"{normalized:g}" + + +def _assignment_id(assignment): + return _positive_int(_value(assignment, "id")) + + +def _assignment_title(assignment): + title = str(_value(assignment, "title", "") or "").strip() + return title or "当前作业" + + +def _assignment_due_sort_key(assignment): + due_date = _value(assignment, "due_date") + if isinstance(due_date, datetime): + if due_date.tzinfo is None: + due_date = due_date.replace(tzinfo=timezone.utc) + return due_date.timestamp() + return float("inf") + + +def _assignment_knowledge_weight(assignment, knowledge_key): + """Return the strongest matching tag weight without exposing tag metadata.""" + weights = [] + for item in _value(assignment, "knowledge_points", ()) or (): + if str(_value(item, "knowledge_point", "") or "").strip() != knowledge_key: + continue + try: + weights.append(float(_value(item, "weight", 1.0) or 1.0)) + except (TypeError, ValueError, OverflowError): + weights.append(1.0) + return max(weights) if weights else None + + +def _base_plan(status, source, headline, summary, action, evidence): + """Build the strictly content-free contract consumed by the template.""" + return { + "schema_version": 1, + "status": status, + "source": source, + "headline": headline, + "summary": summary, + "action": action, + "evidence": evidence, + "boundary": "建议仅使用当前账号已记录的学习、作业和知识点数据,不会保存为学习计划,也不替代教师判断。", + } + + +def _resume_plan(recent_learning_sessions, active_assignment_ids): + for item in recent_learning_sessions or (): + assignment = _value(item, "assignment") + assignment_id = _assignment_id(assignment) + lifecycle = _value(item, "lifecycle", {}) or {} + if assignment_id not in active_assignment_ids or not bool(_value(lifecycle, "is_resumable")): + continue + + stage_label = str(_value(lifecycle, "stage_label", "当前阶段") or "当前阶段") + next_action = str(_value(lifecycle, "next_action", "继续当前学习") or "继续当前学习") + return _base_plan( + "ready", + "resumable_session", + "先完成正在进行的学习", + f"《{_assignment_title(assignment)}》仍可继续,先完成当前阶段能让后续建议建立在完整学习记录上。", + { + "kind": "resume_learning", + "assignment_id": assignment_id, + "label": "继续当前阶段", + }, + [{ + "kind": "learning_session", + "label": "可继续的学习会话", + "detail": f"{stage_label}:{next_action}", + }], + ) + return None + + +def _knowledge_plan(active_assignments, knowledge_profile_rows): + weak_rows = [] + for row in knowledge_profile_rows or (): + key = str(_value(row, "key", "") or "").strip() + score = _score(_value(row, "score")) + attempts = _non_negative_int(_value(row, "total_attempts")) + if not key or score is None or attempts < MIN_KNOWLEDGE_ATTEMPTS: + continue + if score < NEEDS_PRACTICE_BELOW: + weak_rows.append((score, -attempts, key, row)) + + for score, negative_attempts, key, row in sorted( + weak_rows, + key=lambda item: item[:3], + ): + candidates = [] + for assignment in active_assignments or (): + assignment_id = _assignment_id(assignment) + if assignment_id is None: + continue + weight = _assignment_knowledge_weight(assignment, key) + if weight is None: + continue + candidates.append((-weight, _assignment_due_sort_key(assignment), assignment_id, assignment)) + if not candidates: + continue + + _, _, assignment_id, assignment = min(candidates) + attempts = -negative_attempts + name = str(_value(row, "name", key) or key) + return _base_plan( + "ready", + "knowledge_gap", + f"优先巩固{name}", + f"系统为你匹配了当前可提交、且标注为“{name}”的作业;完成后可用新的练习记录重新观察掌握情况。", + { + "kind": "practice_knowledge_point", + "assignment_id": assignment_id, + "label": "开始针对练习", + }, + [ + { + "kind": "knowledge_point", + "label": "已有练习记录", + "detail": f"{name} 当前掌握度 {score:g} 分,已有 {attempts} 次练习记录。", + }, + { + "kind": "assignment_match", + "label": "匹配当前作业", + "detail": _assignment_title(assignment), + }, + ], + ) + return None + + +def _needs_retry(submission): + status = str(_value(submission, "status", "") or "").strip().lower() + sandbox_status = str(_value(submission, "sandbox_status", "") or "").strip().lower() + score = _score(_value(submission, "score")) + return status == "failed" or sandbox_status in {"failed", "error", "partial"} or ( + score is not None and score < PASSING_SCORE + ) + + +def _retry_plan(recent_submissions, active_assignments_by_id): + for submission in recent_submissions or (): + assignment_id = _positive_int(_value(submission, "assignment_id")) + assignment = active_assignments_by_id.get(assignment_id) + if assignment is None or not _needs_retry(submission): + continue + + score = _display_score(_value(submission, "score")) + detail = "最近一次有效评测尚未达到通过目标。" + if score is not None: + detail = f"最近一次有效评测得分 {score} 分,尚未达到通过目标。" + return _base_plan( + "ready", + "retry_submission", + "回到最近一次尚未达标的作业", + f"先复练《{_assignment_title(assignment)}》并重新提交,再根据新的评测结果安排下一步。", + { + "kind": "retry_submission", + "assignment_id": assignment_id, + "label": "复练并重新提交", + }, + [{ + "kind": "submission_result", + "label": "最近一次评测", + "detail": detail, + }], + ) + return None + + +def _unstarted_assignment_plan(active_assignments, submitted_assignment_ids): + candidates = [ + assignment for assignment in active_assignments or () + if _assignment_id(assignment) not in submitted_assignment_ids + ] + if not candidates: + return None + assignment = min(candidates, key=lambda item: (_assignment_due_sort_key(item), _assignment_id(item))) + assignment_id = _assignment_id(assignment) + return _base_plan( + "ready", + "unstarted_assignment", + "从一份当前作业开始", + f"《{_assignment_title(assignment)}》还没有提交记录;完成一次练习后,系统才能基于实际结果提供更具体的建议。", + { + "kind": "start_assignment", + "assignment_id": assignment_id, + "label": "开始编码", + }, + [{ + "kind": "assignment_status", + "label": "当前可提交作业", + "detail": "尚无提交记录。", + }], + ) + + +def build_adaptive_learning_plan( + *, + active_assignments, + recent_learning_sessions, + knowledge_profile_rows, + recent_submissions, + submitted_assignment_ids, +): + """Return one deterministic, content-free next-step recommendation. + + Inputs must already be scoped to the current student and their currently + accessible assignments. The function makes no database, network, AI, or + state-changing calls, which makes the precedence order fully testable. + """ + active_assignments = list(active_assignments or ()) + active_assignments_by_id = { + assignment_id: assignment + for assignment in active_assignments + if (assignment_id := _assignment_id(assignment)) is not None + } + active_assignment_ids = set(active_assignments_by_id) + submitted_assignment_ids = { + assignment_id + for value in submitted_assignment_ids or () + if (assignment_id := _positive_int(value)) is not None + } + + return ( + _resume_plan(recent_learning_sessions, active_assignment_ids) + or _knowledge_plan(active_assignments, knowledge_profile_rows) + or _retry_plan(recent_submissions, active_assignments_by_id) + or _unstarted_assignment_plan(active_assignments, submitted_assignment_ids) + or _base_plan( + "starting_point", + "no_current_evidence", + "从作业列表选择下一步", + "当前没有可用于排序的有效作业或学习记录;打开作业并完成一次练习后,系统会基于新的记录给出建议。", + { + "kind": "review_assignments", + "assignment_id": None, + "label": "查看作业列表", + }, + [{ + "kind": "data_boundary", + "label": "当前可用证据不足", + "detail": "不会根据缺失记录推断你的真实掌握情况。", + }], + ) + ) diff --git a/templates/student_home.html b/templates/student_home.html index 8d023be..285d572 100644 --- a/templates/student_home.html +++ b/templates/student_home.html @@ -444,6 +444,42 @@
{{ adaptive_learning_plan.summary }}
++ {{ adaptive_learning_plan.boundary }} +
+