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