Files
openmaic/OpenMAIC/task_plan.md
2026-08-16 14:58:47 +08:00

257 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 麦洛教育大型课程改造计划
## Goal
在不改动 OpenMAIC 原互动课堂内核的前提下,建立用户端、运营端、服务端的可验证能力边界,并把现有大型课程骨架升级为“主 Agent 规划连续课程、逐模块复用原单课件生成、发布后仍进入原课堂”的生产链路。
## Current Phase
Phase 7公网安全网络与身份边界
## Protected Core
除修复原项目自身独立缺陷外,本项目改造不得修改:
- `components/stage.tsx`
- `components/edit/PlaybackChromeRoot.tsx`
- `components/chat/**`
- `components/roundtable/**`
- `components/scene-renderers/**`
- `lib/playback/**`
- `lib/chat/**`
- `lib/action/**`
- `lib/whiteboard/**`
- `lib/pbl/**`
- 原有 Stage/Scene/Action DSL 与单课件课堂播放链路
## Phases
### Phase 1基线与第一批外围改造
- [x] 确认产品边界与保护范围
- [x] 审计现有大课、课堂运行时和三端边界
- [x] 建立部署角色/运营权限的服务端边界
- [x] 移除学习端额外助教挂载,保留原课堂助教为唯一入口
- [x] 恢复用户端材料/互动/职教单课件生成入口
- [x] 补充对应自动化测试
- **Status:** complete
### Phase 2大型课程主 Agent 契约
- [x] 主 Agent 显式生成每模块 `generationPrompt`
- [x] 增加课程目标、术语、教学风格、难度和考核连续性契约
- [x] runner 传递语言、时长、知识边界和模块提示词
- [x] 保持旧课程记录兼容
- **Status:** complete
### Phase 3跨模块连续性与互动生成
- [x] 生成并持久化每模块输出摘要/知识覆盖
- [x] 后续模块读取前序实际产出
- [x] 大课模块使用原 Interactive Mode 生成能力
- [x] 验证实际产生可操作 HTML 互动场景
- **Status:** complete
### Phase 4冻结发布与版本锁定
- [x] 打通服务端 classroom 到 frozen bundle 的发布路径
- [x] 保留 Agent persona/voice、媒体、音频和必要 Stage 契约
- [x] 大型课程 manifest 锁定模块版本与内容哈希
- [x] 学习端只加载 manifest 指定版本
- **Status:** complete
### Phase 5三端拆分准备与验收
- [x] 完整能力矩阵和部署文档
- [x] 原课堂交互回归完成1,291/1,294 通过3 个受保护 Chat 旧基线漂移已隔离记录)
- [x] 大课生成、冻结发布、manifest 精确加载的无 LLM 端到端数据边界通过
- [x] 基于稳定边界制定 desktop/ops/server 机械拆分计划
- **Status:** complete
### Phase 6独立部署边界加固
- [x] Docker learner/ops/server 分别在构建期内联正确公开角色
- [x] learner 的任务与大课目录不依赖 ops API运营页面全部受保护
- [x] 上游模块重生成会级联失效并顺序重生下游
- [x] learner 不能绕过 manifest pin 读取大课源 classroom且 learner 课堂不恢复课程生成
- [x] 浏览器重发与 learner loader 严格校验 bundle 内部 version
- [x] 运营 ACCESS_CODE 会话具备 TTL、登录限流与同源写保护
- [x] server 部署无 UI且不暴露运营页面/API
- [x] 定义并实现 ops → server 的跨进程事务发布契约
- [x] 大课模块在 manifest exact pin 提交前不对 learner 可见
- [x] 冻结发布具备全源 revision 校验、路径防穿越和同版本 ZIP 完整性复核
- [x] server 隐藏明确非课堂必需的高风险 API并阻断 metadata SSRF 与已发布源删除绕过
- [x] 将数据库事务、对象存储、共享锁与资源 owner 明确列为生产上线硬前置,不在单实例文件仓库阶段伪装完成
- **Status:** complete
### Phase 7公网安全网络与身份边界
- [x] 建立 DNS 固定解析、逐跳重定向校验的 public safe-fetch并迁移匿名媒体代理
- [x] 盘点其余接收用户 URL、上游返回 URL 与 SDK/fetch 出站链路并完成风险分级
- [x] 封住公网 server 的 unmanaged BYOK并加固 AliDoc、MinerU、Qwen 的 provider-returned URL
- [ ] 按 trust class 迁移 provider/生成路由,生产网络增加 metadata/private egress deny
- [x] 复核专用 server 的 method/path 边界和已发布源删除保护
- [x] 完成 learner 身份方案审计:正式账号为长期主体、桌面设备密钥做持有证明、课堂使用短时 exact-resource grant
- [x] 建立 provider-neutral principal/ownership 纯策略,并以 job 做不改变响应的 observe-only 竖切
- [x] 用户确认正式账号为永久 owner/权益主体,设备身份只做持有证明或可迁移游客
- [ ] 用户确认登录渠道、多租户、课程可见性/离线、BYOK 与学习记录可信度策略
- [ ] 建立 owner 不可变绑定、显式 legacy/guest 迁移和可切换 fail-closed enforcement
- [x] 定义 shadow/required 模式、稳定 deny 响应与账号/设备 subject 映射
- [x] job create 原子绑定 owner/guest普通状态 patch 禁止改归属
- [x] job list/detail/cancel/resume/delete 在 required 模式按资源归属 fail closed
- [x] legacy/guest → account 仅允许显式 compare-and-set 迁移
- [x] 课堂继承 job owner直接写入不能覆盖已有归属
- [x] 课堂 GET/POST 在 required 模式按 owner/guest/capability fail closed
- [x] 课堂 legacy/guest 显式 compare-and-set 迁移,归属不进入 Stage/Scene
- [x] 完成扩展兼容、越权、迁移和原课堂回归
- [ ] 为 job/classroom/voice/render/draft 建立 owner 与资源级授权
- [x] job
- [x] 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
1. 发布模块仍走 `/classroom → Stage → PlaybackChromeRoot → SceneRenderer`,不存在 learner 专用简化播放器。
2. 原 ChatArea/Roundtable 是课堂唯一问答入口,能够携带课堂上下文并中断/恢复讲解。
3. HTML iframe、四类 Scene、Action、Quiz、PBL、白板、音频和讨论 TTS 不因大课改造退化。
4. 用户端可生成单课件;只有运营端可制作、重生成和发布大型课程。
5. 大课模块由主 Agent 生成专属提示词,后续模块理解前序模块实际覆盖内容。
6. 已发布大型课程锁定具体模块版本,不随模块单独重发而静默漂移。
## 生成任务恢复专项计划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模块 13 成功、模块 4 中断时继续保留 13并按顺序完成 4 以后模块。
- [ ] 运行真实文件仓库的恢复 smoke test验证最终 classroom 只有一个正式版本、无重复 Scene、continuity digest 与实际输出一致。
- [ ] 完成受保护课堂回归、全量 TypeScript、定向 ESLint/Prettier、生成任务回归和一次生产构建后再开放默认 checkpoint 路径。
### 专项验收不变量
1. 杀掉生成进程并重新启动后,任务不会永久停留在 `running`,也不会被静默标记为成功。
2. 在第 N 个场景完成后中断,继续任务只调用第 N+1 个场景及后续,不重复 N也不重新生成大纲。
3. 两个 runner 不能同时写同一 attempt旧 runner 的迟到写入必须被拒绝或丢弃。
4. 大课普通继续不重生成已成功模块;显式级联重生成仍保持现有 N..end 语义。
5. 没有 checkpoint 的 legacy job 仍可重跑,但接口和 UI 明确说明会从头开始。
6. 原互动课堂内核文件保持不变,最终课堂仍走原有 `classroom → Stage → SceneRenderer` 链路。