# 大型课程模式:主 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 当前顺序语义: 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. 发布门:重新检查每个模块仍有非空 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 原子可见性 课程发布流程: 1. 先验证 CourseRecord 快照; 2. ops 为每个 classroom 生成完整 placeholder-version ZIP,server 在课程锁内运行原发布门禁、 分配真实 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 课堂页仍渲染原 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 验收不变式: 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 的理由。