Files
openmaic/docs/large-course-mode.md
2026-08-16 14:58:47 +08:00

16 KiB
Raw Permalink Blame History

大型课程模式:主 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 38 个可观察整课目标
continuityContract.terminology 跨模块统一术语
continuityContract.teachingStyle 稳定讲解、示例和互动风格
continuityContract.difficultyProgression 难度递进
continuityContract.assessmentStrategy 形成性检查与综合考核连续性
modules 415 个有序完整教学单元

每个 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

当前顺序语义:

  1. 同一课程只允许一个活动 run整课、框架和单模块重试互斥。
  2. 模块按 index 串行,不并发。
  3. 成功模块保存 classroomId、generationPromptSnapshot、outputDigest 和 continuityInputRefs。
  4. 任一模块失败会停止后续模块,避免在缺失前序实际产出的情况下继续生成。
  5. 单模块重试成功后,可从后续未成功模块继续按序生成。
  6. 已成功模块不会被整课 resume 无条件重做。
  7. 取消会中止当前 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、研究和媒体开关。

有两层门禁:

  1. 生成门Interactive Mode 在 persistClassroom 前要求至少一个 interactive Scene 且 content.html 非空;否则整个 job 失败且不落半成品。
  2. 发布门:重新检查每个模块仍有非空 HTMLpackager 要求 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 原子可见性

课程发布流程:

  1. 先验证 CourseRecord 快照;
  2. ops 为每个 classroom 生成完整 placeholder-version ZIPserver 在课程锁内运行原发布门禁、 分配真实 version 并冻结为 unpublished
  3. 所有模块完成后再次验证来源快照未变化;
  4. 把本批模块精确版本切为 published大课 registry 私有 courseId/moduleIndex 让公共读 API 在 manifest exact pin 出现前继续隐藏这些版本;
  5. 写入下一 CourseManifest version
  6. 若 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 的检查顺序:

  1. version 与 expectedContentHash 必须成对出现且格式合法;
  2. registry 必须返回同一 coursewareId、精确 version 与合法 hash
  3. registry hash 必须等于 manifest pin
  4. 下载 bundle
  5. bundle 内 coursewareId 必须一致;
  6. bundle 声明 hash、registry hash 与重新计算 hash 必须三者一致;
  7. completeness.complete 必须为 true
  8. Agent、音频和媒体全部物化后才原子写入 document。

缓存 stage id 包含 id、version 和完整 hash所以不同版本或相同版本的异常不同内容不会 共用缓存。失败时回滚文档、媒体、音频和资产池。

物化文档不带 outlines随后跳转

/classroom/{learn_id_v_version_hash}?learner=1

课堂页仍渲染原 Stagelearner 参数只控制返回课程导航,不选择另一套播放器或助教。

9. 学习进度的当前边界

课程目录的 started/completed 标记当前保存在 localStoragekey 包含 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 + 全部 ZIPserver 分配真实版本、暂存、 提交 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

验收不变式:

  1. 框架阶段结束于 framework_ready确认前没有模块 job。
  2. 每模块有 generationPromptSnapshot、outputDigest后续模块有精确 continuityInputRefs。
  3. 失败模块阻断后续生成。
  4. 每模块至少有一份生成的非空 HTML 互动 Scene。
  5. frozen bundle 包含 teacher roster、白板、Agent voice、媒体和 speech 音频。
  6. manifest 精确 pin id/version/hash旧 manifest version 不随模块重发漂移。
  7. learner 拒绝 pin/hash/完整性不一致,并在成功后进入原 Stage。
  8. 本改造不修改 protected Chat。已知 3 个 Pi Chat director 基线断言漂移按 handover.md 记录,不作为顺手改 Chat 的理由。