21 KiB
麦洛教育大型课程改造计划
Goal
在不改动 OpenMAIC 原互动课堂内核的前提下,建立用户端、运营端、服务端的可验证能力边界,并把现有大型课程骨架升级为“主 Agent 规划连续课程、逐模块复用原单课件生成、发布后仍进入原课堂”的生产链路。
Current Phase
Phase 7:公网安全网络与身份边界
Protected Core
除修复原项目自身独立缺陷外,本项目改造不得修改:
components/stage.tsxcomponents/edit/PlaybackChromeRoot.tsxcomponents/chat/**components/roundtable/**components/scene-renderers/**lib/playback/**lib/chat/**lib/action/**lib/whiteboard/**lib/pbl/**- 原有 Stage/Scene/Action DSL 与单课件课堂播放链路
Phases
Phase 1:基线与第一批外围改造
- 确认产品边界与保护范围
- 审计现有大课、课堂运行时和三端边界
- 建立部署角色/运营权限的服务端边界
- 移除学习端额外助教挂载,保留原课堂助教为唯一入口
- 恢复用户端材料/互动/职教单课件生成入口
- 补充对应自动化测试
- Status: complete
Phase 2:大型课程主 Agent 契约
- 主 Agent 显式生成每模块
generationPrompt - 增加课程目标、术语、教学风格、难度和考核连续性契约
- runner 传递语言、时长、知识边界和模块提示词
- 保持旧课程记录兼容
- Status: complete
Phase 3:跨模块连续性与互动生成
- 生成并持久化每模块输出摘要/知识覆盖
- 后续模块读取前序实际产出
- 大课模块使用原 Interactive Mode 生成能力
- 验证实际产生可操作 HTML 互动场景
- Status: complete
Phase 4:冻结发布与版本锁定
- 打通服务端 classroom 到 frozen bundle 的发布路径
- 保留 Agent persona/voice、媒体、音频和必要 Stage 契约
- 大型课程 manifest 锁定模块版本与内容哈希
- 学习端只加载 manifest 指定版本
- Status: complete
Phase 5:三端拆分准备与验收
- 完整能力矩阵和部署文档
- 原课堂交互回归完成(1,291/1,294 通过,3 个受保护 Chat 旧基线漂移已隔离记录)
- 大课生成、冻结发布、manifest 精确加载的无 LLM 端到端数据边界通过
- 基于稳定边界制定 desktop/ops/server 机械拆分计划
- Status: complete
Phase 6:独立部署边界加固
- Docker learner/ops/server 分别在构建期内联正确公开角色
- learner 的任务与大课目录不依赖 ops API,运营页面全部受保护
- 上游模块重生成会级联失效并顺序重生下游
- learner 不能绕过 manifest pin 读取大课源 classroom,且 learner 课堂不恢复课程生成
- 浏览器重发与 learner loader 严格校验 bundle 内部 version
- 运营 ACCESS_CODE 会话具备 TTL、登录限流与同源写保护
- server 部署无 UI,且不暴露运营页面/API
- 定义并实现 ops → server 的跨进程事务发布契约
- 大课模块在 manifest exact pin 提交前不对 learner 可见
- 冻结发布具备全源 revision 校验、路径防穿越和同版本 ZIP 完整性复核
- server 隐藏明确非课堂必需的高风险 API,并阻断 metadata SSRF 与已发布源删除绕过
- 将数据库事务、对象存储、共享锁与资源 owner 明确列为生产上线硬前置,不在单实例文件仓库阶段伪装完成
- Status: complete
Phase 7:公网安全网络与身份边界
- 建立 DNS 固定解析、逐跳重定向校验的 public safe-fetch,并迁移匿名媒体代理
- 盘点其余接收用户 URL、上游返回 URL 与 SDK/fetch 出站链路并完成风险分级
- 封住公网 server 的 unmanaged BYOK,并加固 AliDoc、MinerU、Qwen 的 provider-returned URL
- 按 trust class 迁移 provider/生成路由,生产网络增加 metadata/private egress deny
- 复核专用 server 的 method/path 边界和已发布源删除保护
- 完成 learner 身份方案审计:正式账号为长期主体、桌面设备密钥做持有证明、课堂使用短时 exact-resource grant
- 建立 provider-neutral principal/ownership 纯策略,并以 job 做不改变响应的 observe-only 竖切
- 用户确认正式账号为永久 owner/权益主体,设备身份只做持有证明或可迁移游客
- 用户确认登录渠道、多租户、课程可见性/离线、BYOK 与学习记录可信度策略
- 建立 owner 不可变绑定、显式 legacy/guest 迁移和可切换 fail-closed enforcement
- 定义 shadow/required 模式、稳定 deny 响应与账号/设备 subject 映射
- job create 原子绑定 owner/guest,普通状态 patch 禁止改归属
- job list/detail/cancel/resume/delete 在 required 模式按资源归属 fail closed
- legacy/guest → account 仅允许显式 compare-and-set 迁移
- 课堂继承 job owner,直接写入不能覆盖已有归属
- 课堂 GET/POST 在 required 模式按 owner/guest/capability fail closed
- 课堂 legacy/guest 显式 compare-and-set 迁移,归属不进入 Stage/Scene
- 完成扩展兼容、越权、迁移和原课堂回归
- 为 job/classroom/voice/render/draft 建立 owner 与资源级授权
- job
- classroom
- voice/render/draft
- 为 Chat、Quiz/PBL 动态评分、TTS 与单课件生成建立 entitlement、配额和并发边界
- 多实例上线前迁移到数据库事务、对象存储、分布式锁/CAS 与共享限流
- Status: in_progress
Decisions Made
| Decision | Rationale |
|---|---|
| 先在当前应用封能力边界,后物理拆成三端 | 避免把未稳定的权限、发布和生成问题复制到三套应用 |
| 原互动课堂作为受保护内核 | 用户明确要求完整保留 HTML 互动、原教师/助教、Quiz、PBL、白板和语音 |
| 大课始终复用现有单课件生成与同一 Stage 播放链路 | 保持交互品质且降低分叉维护成本 |
| 同一课程同一时间只允许一个生成 run | 保证模块顺序和提示词快照语义,避免整课流水线与单模块重试互相覆盖 |
| 不再使用 DeepSeek Harness | 用户已明确要求停止使用;后续只用本地实现、验证和内置协作能力 |
| 受保护 Chat 基线失败只记录、不顺手改 | 用户明确要求保持原课堂交互代码;Phase 4 未触及这些文件,避免把独立 Chat 语义选择混入大课改造 |
| 正式 learner 采用“账号主体 + 设备持有证明 + 课堂短时 grant”三层身份 | 账号负责权益/跨设备/找回,设备密钥降低令牌复制风险,精确资源 grant 在不改课堂内核的前提下约束 Chat/QA/PBL/TTS |
| 身份提供方未确定前只做 observe-only 授权竖切 | 当前 resolver 明确返回 unavailable,不信任请求头,也不让授权判断改变既有响应;待 owner 主体确认后再启用 fail-closed enforcement |
| 公网 server 的 provider URL 必须按 trust class 分流 | server-managed 公网地址、运营自托管私网地址与 learner BYOK 不能继续共用 ALLOW_LOCAL_NETWORKS;先封公开 BYOK,再逐步迁移 secure transport |
| 正式账号是永久 owner/权益主体 | 用户已确认;设备密钥只证明设备持有,游客身份必须可显式迁移,不能成为不可恢复的永久权益主体 |
Errors Encountered
| Error | Attempt | Resolution |
|---|---|---|
| 当前工作区没有 Git 仓库,无法依赖 git diff/rollback | 1 | 严格记录触及文件、使用小批次补丁并逐批测试 |
| 三个 DeepSeek Harness 只读任务并行调用超过预期超时且未返回结果 | 1 | 终止该组调用;后续缩小任务并改为串行,避免重复相同失败方式 |
| 首个补丁 Prettier 检查发现两个新文件格式不符合项目规则 | 1 | 使用项目固定版本 Prettier 机械格式化后重新检查 |
运营边界首轮验证:课程页残留 publishToken、测试环境 all 绕过发布 Token、zsh 展开方括号路径 |
1 | 删除残留 UI 变量引用;仅显式 ops/本地 development 使用会话发布,test/all 保持 Bearer 合约;格式检查改用逐项引用路径 |
Prettier 无法为 .env.example 推断 parser |
1 | 环境示例保持人工格式;后续 Prettier 清单排除该文件 |
| 新增部署边界 E2E 首次格式检查未通过 | 1 | 使用项目 Prettier 格式化单文件后再运行浏览器测试 |
| Phase 2 测试中的无参数 mock 让 TypeScript 将调用参数推断为空元组 | 1 | 为 framework AI mock 显式声明 system/user 两个字符串参数 |
| Phase 2 首轮 UI 类型检查仍把详情模块识别为持久化模块,且 compat 局部变量命中 Next.js 保留名 | 1 | CourseDetail 显式覆盖为 CourseModuleView[];局部变量由 module 改名为 moduleObj |
| Phase 3 prompt adapter 测试再次把无参数 mock 推断成空元组 | 1 | 给 adapter 的 base AICall mock 显式声明 system/user/images 参数 |
| Phase 4 发布路由首个补丁使用了已漂移的 import 上下文,无法套用 | 1 | 重新读取文件后缩小补丁并成功应用 |
| Phase 4 learner loader 既有测试仍使用无哈希详情和旧 stage ID | 1 | 已定位为测试 fixture 漂移,正在更新为 version+hash 身份契约 |
| 更新 Phase 4 计划日志的首个补丁匹配了旧错误文案 | 1 | 用 rg 读取实际上下文后重新套用 |
Phase 4 冻结包测试发现 audioRef 存在时 ...rest 仍泄漏 audioUrl |
1 | 两处 speech 序列化先显式剔除 audioUrl,仅在无 audioRef 时回填 |
| 受保护课堂回归 1,294 项中 3 项既有 Chat director 断言失败 | 1 | Phase 4 相关 1,291 项通过;正在只读定位基线漂移,不跨边界修改 Chat 内核 |
| Phase 5 Playwright 首轮把部署边界用例跑在非 learner 的 dev server 上 | 1 | E2E webServer 显式固定 learner 角色,隔离重跑 1 项通过 |
| 受保护浏览器回归并行跑 9 项时 5 项失败 | 1 | 4 项通过;3 项卡在并行 IndexedDB seed,另 2 项为跳转/旧场景断言,改为单 worker 隔离诊断 |
| 中断 Playwright 后遗留的 Next dev server 进程占用 3002 但不再响应 | 1 | 终止由本次测试启动的残留进程,重新由 Playwright 启动干净 learner 服务 |
| zsh 将 Next.js 动态路由的方括号当作 glob,首次读取 classroom-media 路由失败,且假定了错误的参数名 | 1 | 用 find 确认实际 [classroomId] 路径,之后对动态路径整体加引号 |
| 为 registry 增加 fail-closed source 反查的首个合并补丁匹配了 Prettier 前的单行类型签名 | 1 | 重新读取已格式化上下文,拆成三个小补丁后成功 |
| Phase 6 manifest repo 工厂首个大补丁因上下文过大且含误字无法套用 | 1 | 重读当前文件,改为边界更精确的局部替换并成功 |
Docker CLI 不可用,无法运行 docker compose config |
1 | 用 js-yaml 静态解析 Compose 并实测 ops 角色构建;待有 Docker 环境时再做容器 smoke test |
| 级联重生首次测试对“省略的可选键”使用了错误匹配方式 | 1 | 改为 not.toHaveProperty 后重跑 47 项全绿 |
| ACCESS_CODE 加固首轮 TSC 发现角色缩小后的冗余分支与 WebCrypto BufferSource 类型差异 | 1 | 删除冗余分支并显式传递 ArrayBuffer,后续 TSC/构建通过 |
文档检查首次假定中文 README 名为 README.zh-CN.md |
1 | 用 ls README* 确认实际文件为 README-zh.md 后重跑 |
Phase 7 查看并发 safe-fetch 文件时 zsh 对尚不存在的 *safe* glob 报 no matches found |
1 | 改用 rg --files/显式路径检查,不再用未解析 glob |
| Phase 7 主线收口补丁使用了代理已更新前的 server 复核状态行 | 1 | 重读计划与进度后保留代理结果,只追加主线独立验证 |
| 复算受保护内核哈希的两个探索命令分别遇到 zsh 字符串不拆词和嵌套 awk 转义错误 | 1 | 停止依赖猜测命令;以已记录基线哈希、明确触及文件清单和后续受保护回归共同验证,本批身份文件全部位于受保护边界外 |
| AliDoc HTTP→HTTPS 回归用例首次 Prettier check 未通过 | 1 | 使用项目固定 Prettier 格式化该测试后重验通过 |
| Qwen 超时回归首轮在 Promise 可能拒绝后才挂断言,Vitest 报 unhandled rejection | 1 | 在推进 fake timer 前先挂载 rejection 断言,干净重跑通过 |
provider 扩展回归受宿主 HTTP(S)_PROXY 影响,4 个既有 Response mock 被 Undici 真实代理接管 |
1 | 清除代理环境变量后重跑,99 files / 773 tests 通过,确认非代码回归 |
主线审查时假定 extract-document 测试位于 tests/api |
1 | 用 rg --files 找到实际 tests/document/extract-document-route.test.ts 后继续复核 |
| provider 403 映射测试首轮 mock 使用 rest 参数导致 TypeScript 签名不匹配 | 1 | 改为显式参数签名,随后 TSC 与回归通过 |
| Phase 6 远程发布新测试首轮 4 项中 ops receipt 闭环返回 409 | 1 | 结构化错误定位为 mock fetch 与 ops route 共用同一进程 publish mutex;测试用独立 module instance 模拟两个 Node 进程后通过 | | 为跨进程测试加独立 module instance 的首个补丁因 Prettier 已收缩 import 格式而上下文不匹配 | 1 | 读取当前行后以精确单行上下文成功套用 |
| Phase 6 远程发布复验时全量 TSC 出现 classroom-storage-security.test.ts 两处 Stage fixture 缺少时间字段 | 1 | 本批首轮 TSC 已通过且未触及该文件;已向主线报告并由并发 security 任务修正 fixture,本任务先继续定向检查 |
| Phase 6 幂等复验发现同一 classroom 在旧哈希契约下因不同 publishedAt 产生不同 contentHash | 1 | 新冻结包升级为兼容 v2 哈希并规范化导出时间;旧包保持 v1 验证,远程与本地同内容重试现在都返回既有 manifest |
| 将稳定 publishedAt 与测试回退合在一个补丁时,测试注释已被 Prettier 压成单行导致上下文失配 | 1 | 分别读取 production/test 精确上下文后用小补丁成功完成 | | 本地 fallback 复用统一事务后,publish route 的 result 被推断为永远成功,旧错误分支触发 TS2339 | 1 | 删除已不可达的旧 union 分支;事务错误统一通过结构化异常进入现有 catch,随后全量 TSC 通过 |
Acceptance Invariants
- 发布模块仍走
/classroom → Stage → PlaybackChromeRoot → SceneRenderer,不存在 learner 专用简化播放器。 - 原 ChatArea/Roundtable 是课堂唯一问答入口,能够携带课堂上下文并中断/恢复讲解。
- HTML iframe、四类 Scene、Action、Quiz、PBL、白板、音频和讨论 TTS 不因大课改造退化。
- 用户端可生成单课件;只有运营端可制作、重生成和发布大型课程。
- 大课模块由主 Agent 生成专属提示词,后续模块理解前序模块实际覆盖内容。
- 已发布大型课程锁定具体模块版本,不随模块单独重发而静默漂移。
生成任务恢复专项计划(2026-08-16)
触发事件
本次 Python 单课件和初一英语大型课程都暴露了同一组外围缺陷:生成 runner 只存在于 Node 进程内存中,后端进程重启后磁盘上的 running 记录没有可接管的执行者;单课件生成又只在全部场景完成后一次性持久化,因此失败或重启时没有场景级检查点。
专项目标
在不改动原 Stage/Scene/Action、播放、Chat、Quiz、PBL、白板和 iframe 内核的前提下:
- 后端重启后把孤儿任务变成明确的“可恢复中断”,不把它伪装成正常完成,也不让活跃长调用被误判为失败。
- 每完成一个完整场景就持久化私有检查点;恢复时从最后一个完整场景继续,而不是重跑整个课件。
- 大型课程普通“继续”只重试失败模块及其尚未完成的下游,保留已成功模块;只有显式“从此重新生成”才清除级联下游。
- 旧 JSON 任务、旧课程记录和没有检查点的历史任务继续可读;历史任务最多退化为从头重跑,不静默丢失或覆盖。
约束与非目标
- 检查点只属于服务端 job/course 数据,不进入 Stage/Scene DSL,也不暴露给 learner。
- 最终 classroom 仍保持原子可见:检查点是恢复材料,不是半成品课堂;只有完整生成通过后才写入正式 classroom。
- 第一阶段兼容当前文件仓库和单实例开发环境;多实例生产仍必须把 job/checkpoint/lease 迁移到数据库、对象存储和分布式锁/CAS。
- 在现有 Python 任务未明确停稳前不热更新生成链路;需要中断时保留任务 JSON,禁止删除以免失去故障样本。
分阶段实施
R0:故障数据保护与兼容基线
- 为现有
running、failed、cancelled、succeeded记录建立读取兼容矩阵。 - 明确
Xjk5UcLtOx、S9V3Mm0AWy和旧 Python 大课YtqWlpO5zF的迁移/重试语义;不把它们混成同一条课程。 - 增加一次性故障样本快照测试,确保中断、恢复和旧记录读取不依赖实际模型。
R1:可接管的 job lease、心跳与进程恢复
- 在 job 中增加
attemptId、runnerId、heartbeatAt、leaseExpiresAt、resumeReason和checkpointVersion。 - runner 启动时取得带 token 的 lease;所有状态、进度和检查点写入都校验 token,旧进程不能覆盖新 runner。
- 用定时心跳延长 lease;把当前“读取时直接把 running 映射为 failed”的逻辑改成显式 orphan recovery,避免单次模型调用超过 30 分钟时误判。
- 后端启动或首次访问时扫描过期 lease,将任务置为
interrupted/resumable;不自动重新计费/运行,等用户显式继续。 - 对没有 checkpoint 的旧任务保留兼容路径:继续时从头运行一次,并在界面明确标注“旧任务无断点”。
R2:场景级 checkpoint 与模型调用边界
- 抽出可序列化的
GenerationCheckpoint:输入指纹、outline 快照、Stage 快照、已完成 Scene/Action、下一个场景索引和生成阶段。 - 大纲生成完成后先保存 checkpoint;每个场景的 content 与 actions 都完整成功后再一次性追加该场景 checkpoint。
- 恢复时复用同一输入/大纲/Stage 快照,从
nextSceneIndex开始;中断在场景内部时只重做当前场景,不能产生重复场景。 - 检查点写入使用原子文件替换,并限制单 job 大小;后续生产迁移到对象存储,JSON 记录只保留引用和摘要。
- 将 job 的
AbortSignal传入所有生成调用,增加可配置的单次 LLM timeout、有限重试和明确的MODEL_TIMEOUT错误;心跳不能替代 timeout。 - 媒体/TTS 阶段单独记录阶段和已完成资源;第一版至少保证场景生成可断点,媒体/TTS 失败可重试且不污染已完成场景。
R3:大型课程续跑与 UI 语义
- 失败模块保留其 job/checkpoint 引用;普通 resume 复用检查点,不因为创建新 job 就丢掉上一个尝试的恢复材料。
runModulePhase继续跳过succeeded模块;失败模块从最近检查点继续,后续模块仍严格按顺序生成。- 只有显式模块级 regenerate 才执行现有的 N..end 失效级联;普通 resume 不清除前序成功模块和当前模块已有检查点。
- UI 区分“后端中断,可从场景 N 继续”“模型调用失败,重试当前场景”“旧任务无检查点,将从头重跑”,避免都显示成笼统的失败。
- 对 Python 单课件和大型课程模块使用同一 checkpoint 协议,但保留两层恢复语义:单课件恢复场景,大课恢复模块内场景并继续课程顺序。
R4:故障演练、回归与上线门槛
- 单测覆盖 checkpoint schema、旧记录兼容、原子写入、重复 resume、旧 runner token、过期 lease 和损坏 checkpoint fail closed。
- 集成测试在大纲后、场景完成后、场景内部、媒体阶段、TTS 阶段和最终 persist 前模拟进程终止,再由新 runner 恢复。
- 大课程测试验证:模块 1 失败后只重做模块 1;模块 1–3 成功、模块 4 中断时继续保留 1–3,并按顺序完成 4 以后模块。
- 运行真实文件仓库的恢复 smoke test;验证最终 classroom 只有一个正式版本、无重复 Scene、continuity digest 与实际输出一致。
- 完成受保护课堂回归、全量 TypeScript、定向 ESLint/Prettier、生成任务回归和一次生产构建后,再开放默认 checkpoint 路径。
专项验收不变量
- 杀掉生成进程并重新启动后,任务不会永久停留在
running,也不会被静默标记为成功。 - 在第 N 个场景完成后中断,继续任务只调用第 N+1 个场景及后续,不重复 N,也不重新生成大纲。
- 两个 runner 不能同时写同一 attempt;旧 runner 的迟到写入必须被拒绝或丢弃。
- 大课普通继续不重生成已成功模块;显式级联重生成仍保持现有 N..end 语义。
- 没有 checkpoint 的 legacy job 仍可重跑,但接口和 UI 明确说明会从头开始。
- 原互动课堂内核文件保持不变,最终课堂仍走原有
classroom → Stage → SceneRenderer链路。