16 KiB
大型课程模式:主 Agent、顺序复用单课件、冻结发布
事实基线:2026-08-15
当前主链路已经实现:主 Agent 框架 → 人工确认 → 顺序复用原单课件生成 → 实际产出连续性 → Interactive HTML 门禁 → classroom 冻结 → manifest 精确 pin → learner 严格验证 → 原 Stage。三端物理拆分与数据库/对象存储替换仍是后续工作。
1. 与单课件模式的关系
大型课程不是第二套课堂生成器。它只增加课程级编排:
大型课程
= 主 Agent 生成课程框架
+ 每个模块复用原单课件 agent
+ 课程级连续性与发布门禁
每个模块最终仍是一份普通 OpenMAIC classroom,拥有原四类 Scene、Action、Agent、 HTML 互动、Quiz、PBL、白板、媒体和讲解音频。learner 也仍进入原 Stage。
learner desktop 的单课件模式继续存在,并有两条入口:
- 普通纯文本需求走 POST /api/generate-classroom 后台任务;
- 有课程材料或开启 Interactive/职教模式时,保存 generationSession 后进入原 /generation-preview 前台流程。
只有大型课程的创建、框架重生成、确认、模块重试和课程发布属于 ops。
2. 主 Agent 产物
CourseFramework 是人工确认前的课程级契约,包含:
| 字段 | 含义 |
|---|---|
| courseTitle / summary | 整课标题与说明 |
| languageDirective | 所有模块一致的教学语言和术语处理 |
| targetAudience | 稳定学习者画像 |
| courseGoals | 3–8 个可观察整课目标 |
| continuityContract.terminology | 跨模块统一术语 |
| continuityContract.teachingStyle | 稳定讲解、示例和互动风格 |
| continuityContract.difficultyProgression | 难度递进 |
| continuityContract.assessmentStrategy | 形成性检查与综合考核连续性 |
| modules | 4–15 个有序完整教学单元 |
每个 CourseModuleSpec 不再只有标题和说明,还必须包含:
| 字段 | 用途 |
|---|---|
| generationPrompt | 主 Agent 写给原单课件 agent 的可独立执行 brief |
| learningObjectives | 本模块可观察目标 |
| prerequisites | 前置模块/知识 |
| incomingKnowledge | 本模块可假定的输入知识 |
| outgoingKnowledge | 必须交给后续模块的输出知识 |
| excludedTopics | 由其他模块负责、此处不得深度重复的主题 |
| estimatedMinutes | 模块预计时长 |
新的 AI 输出经过严格校验;JSON 解析或契约失败时最多再试一次。旧 CourseRecord 在读取时 由 compat 层确定性补齐 generationPrompt、课程目标和连续性默认值,便于审阅和重跑; 这不会降低新 AI 输出的校验,也不会让旧记录自动通过发布门禁。
3. 两阶段与人工确认
3.1 框架阶段
POST /api/courses
→ 保存 CourseRecord(status=queued)
→ 可选联网研究
→ resolveModel(stage=course-framework)
→ generateCourseFramework
→ 严格校验
→ 保存 framework + pending modules
→ status=framework_ready
到 framework_ready 为止不会生成任何模块。运营必须在 /courses/{courseId} 审阅:
- 学习者、整课目标与语言;
- 术语、教学风格、难度和考核策略;
- 每模块 generationPrompt 与知识边界;
- 模块顺序、目标、时长和前置关系。
确认后才调用 POST /api/courses/{id}/start。不满意可调用 POST /api/courses/{id}/framework/regenerate;该操作会重置模块状态后重跑框架。
3.2 模块阶段
每个模块通过 buildModuleRequirement 合成完整输入:
整课标题、简介、目标学习者、整课目标
+ languageDirective
+ continuityContract
+ 当前模块 generationPrompt
+ objectives / prerequisites
+ incoming / outgoing / excluded knowledge
+ 所有前序模块的实际 outputDigest
+ 原始整课需求
随后调用同一套:
createClassroomGenerationJob
→ runClassroomGenerationJob
→ generateClassroom
→ outlines
→ Scene content / actions
→ media
→ TTS
→ 原子 persistClassroom
当前顺序语义:
- 同一课程只允许一个活动 run,整课、框架和单模块重试互斥。
- 模块按 index 串行,不并发。
- 成功模块保存 classroomId、generationPromptSnapshot、outputDigest 和 continuityInputRefs。
- 任一模块失败会停止后续模块,避免在缺失前序实际产出的情况下继续生成。
- 单模块重试成功后,可从后续未成功模块继续按序生成。
- 已成功模块不会被整课 resume 无条件重做。
- 取消会中止当前 run,并协作式取消当前 classroom job;已成功模块保留。
旧文档中的“模块失败后继续下一个”已经不符合当前实现,不应恢复。
4. 实际产出连续性
4.1 outputDigest
每个成功 classroom 会生成确定性、有限大小的 CourseModuleOutputDigest:
- classroomId、createdAt、stage title/description/language;
- sceneCount 与 slide/quiz/interactive/pbl 分类计数;
- 每个 Scene 的标题、顺序、类型和有界关键内容;
- coveredConcepts、assessments、interactiveExperiences;
- 由实际语义内容计算的 sha256 semanticHash;
- digestVersion=2 的 sourceRevisionHash,对完整持久化 classroom JSON 做规范化 哈希,不会漏掉仅发生在 HTML 脚本、样式、布局或媒体的漂移。
digest 从已经持久化的 classroom 读取,而不是从主 Agent 的计划或 job 日志推测。 它忽略学习者 messages、submissions、evaluations 等运行态。
4.2 continuityInputRefs
生成模块 N 前,runner 必须确认所有 index 小于 N 的模块:
- status=succeeded;
- classroom 可读取;
- 具有 outputDigest,缺失时可从旧成功课堂补建;
- 按顺序形成 moduleIndex + classroomId + semanticHash 引用。
这些引用既注入后续生成 requirement,也持久化到模块记录。发布时会从当前 classroom 重新计算 digest,并逐项核对每个后续模块引用的正是当前前序实际输出。
因此:
- 前序模块重新生成后 semanticHash 变化,旧后续模块会被判 CONTINUITY_STALE;
- 缺少历史引用的旧下游模块会被判 CONTINUITY_UNVERIFIED;
- 必须从最早漂移位置开始顺序重生成,不能手工改 hash。
5. Interactive Mode 与 HTML 门禁
大型课程模块固定:
- interactiveMode=true;
- enableTTS=true。
后台 generateClassroom 不另写 Interactive prompt,而是通过 lib/server/classroom-outline-mode.ts 复用原 INTERACTIVE_OUTLINES 模板,并传入 同一 teacher persona、PDF、研究和媒体开关。
有两层门禁:
- 生成门:Interactive Mode 在 persistClassroom 前要求至少一个 interactive Scene 且 content.html 非空;否则整个 job 失败且不落半成品。
- 发布门:重新检查每个模块仍有非空 HTML;packager 要求 HTML 资产严格内联, 远端引用、无法冻结资产或空 HTML 均拒绝。
这保证了大课模块不是只打了 Interactive 标志却实际退化为纯幻灯片。
6. 发布门禁与原课堂保真
POST /api/courses/{id}/publish 必须通过:
- ops 角色 + ACCESS_CODE 会话;
- COURSEWARE_PUBLISH_TOKEN 已配置;
- 当前课程没有生成 run;
- framework 与 runtime module 数量/顺序一致;
- 所有模块 status=succeeded 且 classroom 可读取;
- 当前 digest 与模块保存的 semanticHash 一致;
- 当前完整 classroom 与模块保存的 sourceRevisionHash 一致;
- continuityInputRefs 精确;
- 每模块有生成的 Interactive HTML。
6.1 classroom → frozen bundle
lib/server/classroom-courseware-publish.ts 从持久化 classroom 直接冻结:
| 内容 | 冻结行为 |
|---|---|
| Stage | 标题、说明、语言、style、Interactive/Task mode、初始白板 |
| Agent | id 映射、name、role、persona、avatar、priority、voiceConfig、voiceDesign |
| Scene | slide、quiz、interactive、pbl 原内容 |
| Action | speech、discussion 和原教学动作 |
| 白板 | Stage 与 Scene whiteboards,含其媒体 |
| HTML | 严格内联并拒绝残留远端资产 |
| 媒体 | 本地或 SSRF 安全远端字节转为包内稳定 ref |
| 音频 | speech 必须改写为包内 audioRef;不得同时保留 audioUrl |
| PBL | 发布设计模板,不携带运营/学习者运行态 |
packager 以 requireAgentRoster、requireInteractiveHtml、 strictInteractiveAssets、requireComplete 全部开启运行;任何缺失都 fail closed。
6.2 原子可见性
课程发布流程:
- 先验证 CourseRecord 快照;
- ops 为每个 classroom 生成完整 placeholder-version ZIP,server 在课程锁内运行原发布门禁、 分配真实 version 并冻结为 unpublished;
- 所有模块完成后再次验证来源快照未变化;
- 把本批模块精确版本切为 published;大课 registry 私有 courseId/moduleIndex 让公共读 API 在 manifest exact pin 出现前继续隐藏这些版本;
- 写入下一 CourseManifest version;
- 若 manifest 未提交,补偿把本批 staged 版本退回 unpublished。
旧 published 版本不可变且不受本批失败影响。幂等命中会读取 exact 存储 ZIP 并复用完整 publish gate;丢失或篡改对象不会被当作成功。contentHash v2 明确忽略 manifest.json 的 exportedAt,因此远程和本地 fallback 对相同内容重复发布均返回 既有 manifest。无 contentHashVersion 的历史包仍用 v1 原始字节哈希验证,且只有 精确 registry 身份和重算 v1 hash 均通过才允许 learner 兼容加载。
ops 在 server 返回的模块 id/version/contentHash 与本地冻结包逐项匹配后才保存 publication receipt,并在其中固定 sourceRevisionHash。课程详情重读当前 classroom 后再验证该回执;任何完整源修改都会 fail closed。
当前锁和回退只是单进程内的补偿事务,没有跨进程隔离性或崩溃一致性; 文件仓库只允许单写实例。
7. CourseManifest v2 与 legacy
新 manifest 是 append-only 历史记录:
CourseManifestRecord
schemaVersion = 2
courseId
version
modules[]
index
title
description
coursewareId
coursewareVersion
contentHash
模块单独重发只会产生新的 courseware version;已提交 manifest v1 继续指向旧精确版本。 只有重新发布课程,新的 manifest v2 才会选择新的模块版本。
旧 singleton 文件仍可读取,并会在下一次成功发布时升级成 history envelope:
- 旧记录本身原样保留;
- 新记录使用旧最高 version + 1;
- 新记录必须是 schemaVersion=2 且 pin 完整。
learner API 对旧无 pin 记录返回 409。安全策略是运营重发,不是把当前 latest 伪造成旧 pin。 如发布校验发现旧课程缺 digest、连续性、HTML 或音频,从最早失败模块开始顺序重生成。
8. learner 严格加载并进入原 Stage
大型课程页面读取指定或 latest manifest version。点击模块时把以下值全部传入:
- coursewareId;
- coursewareVersion;
- contentHash;
- courseId、courseVersion、module index 作为返回导航。
loadLearnerCourseware 的检查顺序:
- version 与 expectedContentHash 必须成对出现且格式合法;
- registry 必须返回同一 coursewareId、精确 version 与合法 hash;
- registry hash 必须等于 manifest pin;
- 下载 bundle;
- bundle 内 coursewareId 必须一致;
- bundle 声明 hash、registry hash 与重新计算 hash 必须三者一致;
- completeness.complete 必须为 true;
- Agent、音频和媒体全部物化后,才原子写入 document。
缓存 stage id 包含 id、version 和完整 hash,所以不同版本或相同版本的异常不同内容不会 共用缓存。失败时回滚文档、媒体、音频和资产池。
物化文档不带 outlines,随后跳转:
/classroom/{learn_id_v_version_hash}?learner=1
课堂页仍渲染原 Stage;learner 参数只控制返回课程导航,不选择另一套播放器或助教。
9. 学习进度的当前边界
课程目录的 started/completed 标记当前保存在 localStorage,key 包含 courseId、 course manifest version 和 module index:
milo.course.{courseId}.v{courseVersion}.module.{index}.{kind}
它是单设备目录标记,不是服务端真实课堂进度。原课堂内部进度/作答仍走现有 runtime store; 启用 NEXT_PUBLIC_PERSISTENCE=1 时可走 HTTP backend。未来应从逐模块 runtime 记录聚合 课程进度,而不是让目录提前物化全部 bundle。
10. API
| API | 用途 | 权限/性质 |
|---|---|---|
| POST /api/courses | 创建 CourseRecord 并启动框架阶段 | ops |
| GET /api/courses | 运营课程列表 | ops |
| GET /api/courses/{id} | 框架、模块、job 与发布状态 | ops |
| POST /api/courses/{id}/start | 人工确认后启动顺序模块生成 | ops |
| POST /api/courses/{id}/framework/regenerate | 重生成框架并重置模块 | ops |
| POST /api/courses/{id}/modules/{index}/regenerate | 单模块重试,成功后顺序续跑 | ops |
| POST /api/courses/{id}/resume | 根据当前阶段续跑 | ops |
| POST /api/courses/{id}/cancel | 取消当前 run/job | ops |
| POST /api/courses/{id}/publish | 校验、冻结全部模块并提交 manifest | ops |
| GET /api/learn/courses | 已发布课程目录 | 公共静态读 |
| GET /api/learn/courses/{id}?version=N | 精确课程清单 | 公共静态读;legacy 返回 409 |
| GET /api/coursewares/{id}?version=N | 精确模块元数据 | 公共静态读 |
| GET /api/coursewares/{id}/bundles/{version}/download | 不可变 bundle | 公共静态读 |
配置 COURSE_PUBLISH_SERVER_BASE_URL 时,课程发布 route 先在 ops 冻结全部模块,再通过 POST /api/internal/course-publish 一次提交 metadata + 全部 ZIP;server 分配真实版本、暂存、 提交 schema-v2 manifest 并在失败时补偿回 unpublished。未配置时保留原同进程 共享文件库 fallback,但复用相同批量事务、幂等与 manifest 可见性门禁。 该整批 multipart 接口只在 server/all 角色开放,使用 server-only COURSEWARE_PUBLISH_TOKEN Bearer 认证。server 生产发布还必须配置 COURSEWARE_PUBLIC_BASE_URL,公开 bundle URL 不从请求 Host 推导。
11. 存储与恢复
| 数据 | 目录 | 恢复行为 |
|---|---|---|
| CourseRecord | data/course-frameworks | 读时兼容旧框架、对 module job 状态自愈 |
| classroom jobs | data/classroom-jobs | runner 重启中断;记录可供重试/续跑 |
| classroom | data/classrooms | outputDigest 与冻结发布的事实源 |
| courseware history | data/coursewares | append-only metadata,状态可 published/unpublished |
| bundle bytes | data/courseware-bundles | id/version 不可覆盖 |
| manifest history | data/course-manifests | 旧 singleton 可读,新发布升级为历史 envelope |
所有互斥锁都在 Node.js 进程内。当前只支持单写实例;Postgres 事务、 唯一键、私有对象存储 put-if-absent 和持久任务队列是公网生产上线与多实例前的硬前置。
本模式完成了 ops 大课边界,没有完成 learner 身份、classroom/job owner、资源级 授权或成本额度。原 Chat 和生成/TTS/搜索等高成本 API 不应被当作已可安全 向匿名公网开放;该项等待用户身份、owner、授权、权益和限额设计。
12. 验收
核心定向命令:
pnpm exec vitest run \
tests/course-framework \
tests/courseware/server-classroom-publish.test.ts \
tests/bundle/learner-load.test.ts \
tests/server/classroom-outline-mode.test.ts \
tests/server/classroom-generation-retry.test.ts
还应执行:
pnpm exec tsc --noEmit
pnpm exec playwright test e2e/tests/deployment-role-boundary.spec.ts --project=chromium
pnpm build
验收不变式:
- 框架阶段结束于 framework_ready,确认前没有模块 job。
- 每模块有 generationPromptSnapshot、outputDigest,后续模块有精确 continuityInputRefs。
- 失败模块阻断后续生成。
- 每模块至少有一份生成的非空 HTML 互动 Scene。
- frozen bundle 包含 teacher roster、白板、Agent voice、媒体和 speech 音频。
- manifest 精确 pin id/version/hash,旧 manifest version 不随模块重发漂移。
- learner 拒绝 pin/hash/完整性不一致,并在成功后进入原 Stage。
- 本改造不修改 protected Chat。已知 3 个 Pi Chat director 基线断言漂移按 handover.md 记录,不作为顺手改 Chat 的理由。