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

375 lines
16 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.

# 大型课程模式:主 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 的理由。