Files
openmaic/docs/architecture-3-tier.md
2026-08-16 14:58:47 +08:00

18 KiB
Raw Blame History

麦洛学习三端架构learner desktop / ops / server

事实基线2026-08-15

当前物理形态仍是一份 OpenMAIC Next.js 应用,通过部署角色封住大型课程运营能力; learner desktop、ops、server 尚未拆成三个独立应用。本文区分“已经实现的能力边界” 与“后续机械拆分”,不把目标态写成已上线事实。

1. 核心结论

  1. learner desktop 保留原 OpenMAIC 单课件生成、编辑与互动课堂,不是纯静态播放器。
  2. 大型课程只由 ops 制作:主 Agent 先给出全局框架与每模块 generationPrompt人工确认后 才按顺序复用原单课件生成管线。
  3. 发布不复制或重写课堂内核。server 把持久化 classroom 冻结为完整 bundle learner 严格验证课程清单、版本、哈希与 bundle 完整性后,仍进入原 Stage。
  4. 原课堂的 Agent、ChatArea/Roundtable、Scene/Action、HTML 互动、Quiz、PBL、白板、 讲解音频和媒体都是受保护能力,不在本次三端改造中另造简化版。
  5. 大型课程 manifest 的模块身份是 coursewareId + coursewareVersion + contentHash。 单独重发某模块不会改变已经发布的课程版本。
  6. 当前 courseware、bundle、course framework、course manifest 都是文件仓库, 发布只是单进程内的补偿事务,没有跨进程隔离性,只适合单写 Node.js 实例。 Postgres 事务与私有对象存储仍是公网生产上线前的硬前置。

2. 当前形态与能力矩阵

部署角色取值为 all、ops、server、learner。生产环境未配置角色时默认 learner 开发和测试环境未配置时默认 all。角色边界目前主要保护 manage_courses 并不等同于已经完成物理路由裁剪。

能力 learner desktop ops server 当前事实
普通文本生成单课件 主入口 同构应用内仍可用 承载当前后台 job POST /api/generate-classroom
材料、Interactive、职教单课件 主入口 同构应用内仍可用 提供生成 API 经 generationSession 进入原 /generation-preview
单课件编辑、播放 用于审阅模块 均复用原 /classroom/{id} 与 Stage
原课堂 Agent/讨论/白板/PBL/音频 审阅时是 提供模型和数据 API 没有 learner 专用简化内核
创建、重生成、确认大型课程 /courses 与 /api/courses/** 受角色和会话保护
主 Agent 课程框架 运行时当前仍同进程 stage 为 course-framework
classroom 转 frozen bundle 发起并审阅 接收整批冻结 ZIP 并事务发布 lib/server/classroom-courseware-publish.ts + /api/internal/course-publish
courseware 注册、精确版本下载 只读 写入 主责 /api/coursewares/**
course manifest 读取 只读 发布 主责 /api/learn/courses/**
学习大型课程 可验收 提供静态清单与 bundle /learn/course/{id} → /learn/{coursewareId} → Stage
发布凭据 ACCESS_CODE 会话;跨实例使用服务端 token 校验 Bearer token 浏览器不再读取发布 token

已实现的安全边界

  • OPENMAIC_DEPLOYMENT_ROLE 和 NEXT_PUBLIC_OPENMAIC_DEPLOYMENT_ROLE 共同决定服务端能力与 客户端入口显示;公开变量只承载非敏感角色名。
  • 生产 ops 必须配置 ACCESS_CODE。middleware 保护运营路径, 每个 /api/courses/** route 还会再次执行 requireOpsAccess校验 HMAC cookie。
  • ACCESS_CODE 会话带签发时间和 TTL默认 7 天cookie Max-Age 与 TTL 一致, 中间件与 Node route 都会拒绝过期或未来时间的 token。登录只接受同源 application/json实际请求体上限 8 KiB失败尝试受有界进程内限流。
  • 用 cookie 授权的 ops 写请求还必须通过 Origin 同源校验。反向代理部署应配置 OPS_PUBLIC_ORIGIN 作为 canonical origin该校验不信任 X-Forwarded-Host。
  • 同源 ops 发布课件时使用已认证会话;独立 server 接收 POST /api/coursewares 时才校验 COURSEWARE_PUBLISH_TOKEN Bearer token。
  • COURSEWARE_PUBLISH_TOKEN 是服务端变量。旧的 NEXT_PUBLIC_COURSEWARE_PUBLISH_TOKEN 不再是配置项,也不得重新引入浏览器。
  • learner 角色隐藏大型课程入口,直接访问运营 API 返回 403访问运营页面会回到首页。
  • server 角色是无头数据/API 服务:所有页面返回 404且不暴露 /api/courses/、/api/ops/ 和 /api/access-code/**未鉴权的媒体代理、provider 验证、视频导出、开发态 persistence、usage 与全局 job 列表也会 404。 内部整课发布仍只认 Bearer token。
  • server 对全局 job 列表同时拒绝 GET/HEAD并在规范化尾斜杠后对内部发布 执行 POST-only 方法边界。
  • 远程发布后ops 以当前 publication receipt 补足本地 registry 的 source 反查; 发布与 job/source 删除共用单进程互斥并在冲突时返回 409。该回执不是历史索引 重生后旧 source 的永久保留和多副本互斥仍是后续持久化契约。
  • SSRF 边界已拦截 RFC6598 100.64.0.0/10(含阿里云 metadata 地址); 专用 server 不暴露 /api/proxy-media。若未来在公网 learner/all 重新开放, 必须先实现 DNS 解析 pin 和逐跳 redirect 复核。

尚未实现的隔离

  • 三种角色仍从同一 Next.js 构建产物启动,生成、课堂和多数 API 文件仍共处一个进程。
  • deployment role 已对明确非课堂必需端点做部分 deny但它仍不是完整 learner identity/capability allowlist。
  • learner desktop、ops、server 的独立域名和独立包尚未机械拆出ops → server 的整批 frozen bundle + schema-v2 manifest 提交接口已封装为 server-only HTTP 契约。
  • 因 NEXT_PUBLIC_* 在构建期内联,严格的 ops/learner UI 隔离应分别构建。
  • learner 用户身份、classroom/job 的 owner、资源级授权与课程权益尚未实现。 因此不得把当前匿名访问原 Chat、生成、TTS、搜索或其他高成本 API 描述为已安全开放公网部署必须先完成身份、owner、授权和额度设计。

3. 受保护课堂内核

本改造的边界是“外围编排、权限、冻结发布、目录和存储”,不是重写课堂。以下代码和契约 应视为受保护基线:

  • components/stage.tsx
  • components/edit/PlaybackChromeRoot.tsx
  • components/chat/**
  • components/roundtable/**
  • components/scene-renderers/**
  • lib/playback/**
  • lib/chat/**
  • lib/action/**
  • lib/whiteboard/**
  • lib/pbl/**
  • 原 Stage / Scene / Action DSL 与 /classroom/{id} 播放链路

必须保持的运行时不变式:

  1. learner 打开的模块仍是 /classroom → Stage → PlaybackChromeRoot → SceneRenderer。
  2. Stage 内原 ChatArea/Roundtable 是唯一课堂问答入口learner 外层不再挂第二套 AssistantPanel。
  3. 冻结包保留完整 Agent roster、persona、priority、voiceConfig、voiceDesign 以及 discussion/multiAgent 引用。
  4. Stage 初始白板、场景白板、四类 Scene、教学 Action、Quiz、PBL 设计模板、 HTML iframe、媒体和 speech 音频均随包保存。
  5. 静态目录与 bundle 下载是零 LLM 热路径;进入原课堂后,原教师/助教、动态评分和 PBL 教学运行时仍可按原设计调用模型。不得再把 learner 描述为“整个学习过程零 LLM”。

4. 生成数据流

4.1 learner desktop 单课件双路径

首页需求
  ├─ 纯文本普通模式
  │    └─ POST /api/generate-classroom
  │         └─ 持久化 classroom job
  │              └─ 原 generateClassroom 管线
  │                   └─ 原子写入 classroom
  └─ 有材料,或开启 Interactive Mode / 职教模式
       └─ 文件写入浏览器 document store
            └─ sessionStorage.generationSession
                 └─ /generation-preview
                      └─ 原前台单课件生成管线

分流条件位于 app/page.tsx

  • courseMaterials 非空、interactiveMode 为 true 或 vocationalTestMode 为 true 时走前台;
  • 其他普通需求走可关闭页面的后台 job
  • 前台适配器只保存浏览器拥有的文件并恢复原生成会话,不复制场景生成逻辑。

4.2 ops 大型课程生成

POST /api/courses
  → 主 Agent 生成 CourseFramework
  → status = framework_ready
  → 运营人工审查和确认
  → POST /api/courses/{id}/start
  → 模块 1 调用原 classroom job
  → outputDigest + semanticHash
  → 模块 2 注入模块 1 的实际产出与 continuityInputRefs
  → 按序继续,直到全部成功

主 Agent 的框架契约包括:

  • targetAudience、courseGoals、languageDirective
  • terminology、teachingStyle、difficultyProgression、assessmentStrategy
  • 每模块 generationPrompt、learningObjectives、incomingKnowledge、 outgoingKnowledge、excludedTopics、prerequisites 和 estimatedMinutes。

第二层生成具有以下约束:

  • 每个模块都复用 createClassroomGenerationJob + runClassroomGenerationJob
  • 同一课程同一时刻只有一个 framework、整课或单模块 run
  • generationPromptSnapshot 保存实际使用的主 Agent 提示词;
  • outputDigest 从持久化 classroom 的四类 Scene 与 Action 中确定性提取, semanticHash 标识有界语义产出digest v2 同时保存 sourceRevisionHash对完整 持久化 classroom JSON 做规范化哈希,覆盖 HTML 脚本、样式、布局和媒体等变化;
  • 后续模块使用前序实际 outputDigest而不是只相信原计划
  • 任一模块失败即暂停后续模块,修复或重试成功后才顺序续跑;
  • 大课模块强制 interactiveMode=true、enableTTS=true
  • Interactive Mode 若没有至少一个非空 HTML 的 interactive Scene会在原子持久化前失败。

5. 发布数据流

独立部署时ops 在本地冻结完全部模块后,用一个 multipart 请求把 metadata 与全部 ZIP 发往固定的 /api/internal/course-publish。server 只接受 server-only COURSEWARE_PUBLISH_TOKEN Bearer 认证;公开 bundle URL 由 COURSEWARE_PUBLIC_BASE_URL 派生,不使用请求 Host。未配置远程 origin 时的同源 fallback 走同一批量发布契约。

已完成 CourseRecord
  → validateCoursePublishSnapshot
     - 模块顺序、成功状态、classroom 可读
     - outputDigest 语义哈希与 sourceRevisionHash 都未漂移
     - continuityInputRefs 精确匹配前序实际输出
     - 每模块至少一个非空互动 HTML
  → 每个 persisted classroom 冻结为 unpublished courseware
     - Agent/persona/voice
     - Stage/Scene whiteboards
     - Scene/Action/Quiz/PBL
     - 内联 HTML 与媒体
     - speech audioRef 指向包内音频
  → 再次校验整个来源快照未变化
  → 本批精确模块版本切为 published
  → 提交 schemaVersion=2 的 CourseManifest

classroom → frozen bundle 的服务端适配器会:

  • 把本地媒体或经过 SSRF 检查的远端媒体转成内容稳定引用;
  • 拒绝 blob URL、未解析生成占位符、空媒体、缺失讲解音频和外链互动资产
  • 把 PBL 学习者运行态剥离,只发布可重复开始的设计模板;
  • 把 speech 的 audioId/audioUrl 改写成 bundle 内 audioRef
  • 要求可携带且至少有 teacher 的 Agent roster
  • 用确定性 SHA-256 内容哈希登记不可变版本。

发布期间模块先以 unpublished 暂存。每条大课 registry record 私有记录 courseId/moduleIndex 即使状态已 promotion 为 published公共 catalog/detail/download 仍要求某个已提交 schema-v2 manifest 精确 pin 该 id/version/hash。因此 manifest 前不存在 learner 可见窗口。若来源二次 校验或 manifest 提交失败,本批版本回到 unpublished旧的已发布版本不受影响。当前这是 单进程事务式补偿与可见性门禁,不是数据库事务。

幂等判断不只比较 latest manifest 与 incoming contentHash还会读取对应存储对象并复用完整 单课件发布门禁检查内部 id/version/hash、重算 hash、complete 与 portable 资源。文件缺失或 bundle.json 被篡改时不会返回幂等成功。contentHash v2 明确忽略 manifest.json 中的 exportedAt所以远程和同源 fallback 对相同源重复发布都返回同一 manifest无 contentHashVersion 标记的历史包仍按 v1 原始字节哈希验证, learner 仅对能通过精确 registry 和 v1 哈希校验的真正旧包启用兼容路径。 当前没有隐式“强制新版本”语义。

ops 只在 server 回执的模块 id/version/contentHash 与本地冻结包逐项一致后 写入 publication receipt。回执还固定每模块 sourceRevisionHash查询发布状态时会 重读当前 classroom 并比较该哈希,源课堂任何完整修改都使回执 fail closed。

6. 学习数据流与精确版本

GET /api/learn/courses/{courseId}?version={manifestVersion}
  → 只接受 schemaVersion=2 且每模块有 id + version + hash
  → /learn/{coursewareId}?version={v}&hash={h}
  → 查询 registry 的精确版本
  → 核对 registry id/version/hash
  → 下载 bundle
  → 核对 bundle 内 id、声明 hash、重新计算 hash、complete=true
  → 物化 Agent/音频/媒体/课堂文档
  → 本地 stage id = learn_{id}_v{version}_{hash}
  → /classroom/{stageId}?learner=1
  → 原 Stage

物化后的发布课堂没有 outlines因此课堂页不会恢复场景或媒体生成若物化中途失败 文档、音频、媒体和资产池会补偿回滚。独立单课件入口可以先解析 latest但缓存身份同样 包含最终解析出的 version 与 contentHash大型课程入口始终使用 manifest 精确 pin。

7. legacy manifest 重发策略

旧 singleton manifest 可以只读并在仓库中保留,但它没有可靠的模块版本和内容哈希:

  • learner API 对 schemaVersion 非 2 或任一模块缺少 pin 的记录返回 409
  • 禁止把“当前 latest”回填成“历史上当时使用的版本”因为无法证明它是原始事实
  • 运营端应从原 CourseRecord 重新执行发布校验并重发课程;
  • 如旧模块没有 continuityInputRefs、outputDigest、互动 HTML 或持久化音频, 从最早不满足门禁的模块开始顺序重生成,再发布;
  • 新发布生成下一 manifest 版本并精确 pin 每个模块;旧版本继续保留用于审计, 但仍不可供 learner 打开。

8. 当前存储与扩展边界

数据 当前实现 当前限制 目标替换
classroom data/classrooms + 本地 media/audio 本机文件 数据库元数据 + 对象存储
classroom jobs data/classroom-jobs + 内存 runner 重启中断,靠记录自愈后续跑 持久队列/worker
CourseRecord data/course-frameworks 进程内锁 Postgres 行锁/事务
courseware metadata data/coursewares 历史 JSON 进程内版本锁 Postgres 唯一键 (id, version)
frozen bundle bytes data/courseware-bundles 本地文件 对象存储 put-if-absent + manifest exact-pin 后 CDN
CourseManifest data/course-manifests 历史 JSON 进程内版本锁 Postgres 唯一键 (course_id, version)
learner 物化缓存 浏览器 document store / IndexedDB 单设备 可保留缓存,进度另走用户存储

文件写入使用临时文件 + rename/link并有进程内 mutex它只能防同一 Node.js 进程并发。 多个 server 实例、多个容器或共享卷不能依靠这些 Map 锁分配唯一版本,因此当前服务端必须 单实例写入。失败时的 unpublished 回退是应用层补偿,并不提供数据库事务的 隔离性或崩溃一致性。DATABASE_URL 和 ASSET_S3_BUCKET 已服务于通用 document/runtime/asset 持久化抽象,但尚未替换 courseware/manifest/bundle 这组仓库,不能据此宣称发布层已上 Postgres/S3数据库事务、唯一约束和私有对象存储是扩容或公网上线前的硬前置。

9. 后续机械拆分顺序

  1. 先冻结契约和测试bundle、manifest v2、CourseRecord、protected Stage 回归保持不变。
  2. 抽共享契约包:仅移动纯类型、哈希、序列化和 import 适配器,不改 Scene/Action 语义。
  3. 先拆 server搬 courseware/manifest repo、下载与只读 API复用已有内部整批提交接口 让现有 ops 通过 HTTP 适配器调用。
  4. 把文件 repo 替换为 Postgres + 对象存储后,再允许 server 多实例。
  5. 再拆 learner desktop整体搬首页双路径、generation-preview、tasks、learn、classroom 以及受保护课堂依赖;只改 import 和 API base URL不复制 Stage。
  6. 最后拆 ops搬 courses UI、course-framework runner、发布校验与 classroom 冻结发起端; 审阅仍复用同一受保护课堂包。
  7. 最后删除 all 兼容角色和同源直写分支,启用按应用路由 allowlist。

详细执行与回滚门槛见 deployment-3-tier.md。

10. 验收边界与已知 Chat 基线漂移

三端改造相关的广泛受保护课堂回归曾执行 130 个文件、1,294 个测试: 128 个文件、1,291 个测试通过。稳定复现的 3 个失败位于既有 Pi Chat director 契约:

  1. tests/lib/chat/pi/prompts.test.ts keeps concept and mechanism questions teacher-led before student reactions
  2. tests/lib/chat/pi/route-cue-user.test.ts hands successful web evidence to one child and clears it before later delegations
  3. tests/lib/chat/pi/route-cue-user.test.ts does not leak consumed web evidence after the selected child fails

第一项是当前 prompt 的 teacher + user 规则与旧 assistant fallback 断言漂移;后两项是 route fixture 对 URL 出现在 child prompt 字符串中的旧断言,与当前 evidence attachment 路径漂移。本轮大型课程、冻结发布和三端文档未修改 lib/chat/** 或 /api/chat/pi。 这些失败只作为受保护基线记录,不应在本改造中修改 Chat 内核来“顺手修绿”。