1. Executive summary
DigiPortal is an organization-first learning platform where educators upload raw material, build structured courses in a block editor with an AI co-pilot, and publish quizzes, 2D widgets and 3D practice scenes that an LLM assembles from natural-language prompts. Each course carries a tutor that answers student questions from the uploaded material with citations. The MVP targets a pilot in 3 to 4 months with a solo or duo team using AI coding tools.
One design decision carries the whole system: every piece of learning content is a versioned JSON Block document, whether it is a text lesson, a quiz, a 2D widget or a 3D scene. The editor edits blocks, the LLM emits blocks under a strict schema, the validator checks blocks, the gradebook scores attempts against a pinned block version, and the runtime renders blocks. The model never writes engine code, which keeps generation cheap, safe and editable.
What the MVP must prove:
- Educators can turn a folder of PDFs, slides and videos into a published course with assessments in under a day.
- Students complete courses and use the tutor, and every tutor answer is traceable to a source page or timestamp.
- An educator with no 3D skills can prompt, test and publish a working practice scene for one safety or physics topic.
- Per-organization token spend is metered, capped and predictable.
Monetization is designed into the schema (seats, orders, subscriptions, payouts) but no payment gateway ships in the MVP.
2. Users, roles and tenancy
The tenant is the Organization. A solo educator is an organization of one, so B2C and B2B share one data model and one permission system. Users have a single global identity and can belong to several organizations with a different role in each.
| Role | Scope | Can do | Cannot do |
|---|---|---|---|
| Platform super-admin | Platform | Manage organizations, plans, quotas, provider policies, support access | Read student answers without an audit record |
| Org admin | Organization | Invite members, assign roles, enroll students, assign courses, set due dates, view completion and score reports, export CSV, see token usage | Edit course content unless also an educator |
| Educator / author | Courses they own or are granted | Upload material, build and publish courses, generate and QA scenes, configure tutor mode, review LLM-graded answers, pin tutor answers, see per-student progress in their courses | Manage seats or billing |
| Student / trainee | Enrollments | Take courses, run scenes on screen or headset, use the tutor, see own progress and certificates | See other students' data |
- Enrollment paths at MVP: invite link, email list, CSV import. SSO/SCIM are designed for (an
identity_providertable per organization) and not built. - A course-scoped grant (educator role limited to one course) covers teaching assistants and co-authors without a fifth role.
- Public catalog / marketplace listing is a later layer on top of organizations; nothing in the MVP assumes it, nothing blocks it.
3. System architecture
The MVP is a modular monolith: one TypeScript application exposing the API and serving the web app, one Python worker service for ML-heavy jobs, one Postgres database, one object store, one queue. Module boundaries are enforced in code (separate packages with explicit interfaces) so any module can become a service later without a rewrite.
| Module | Owns | Calls AI | Key interfaces |
|---|---|---|---|
| Identity & tenancy | users, organizations, memberships, roles, invitations, RLS session | no | withTenant(ctx), role guards |
| Course & blocks | courses, modules, lessons, blocks, block versions, publishing | no | BlockDocument schema registry, publish(blockId) |
| Assessment & gradebook | question banks, rubrics, attempts, scoring, certificates, progression gates | yes (LLM grading) | score(attempt), issueCertificate(enrollment) |
| Ingestion | upload lifecycle, conversion jobs, content blocks, chunks, embeddings, course digest | yes (STT, embed, context lines, digest) | ingest(fileId), reindex(courseId) |
| Tutor | conversations, retrieval, answer generation, citations, FAQ cache, escalation | yes | ask(courseId, userId, text) streaming |
| Generation | planner, asset resolution, compiler, validator, repair loop, JSON patches | yes | generate(spec), patch(blockId, prompt) |
| Asset search | source connectors, license filter, GLB normalization, vision scoring, catalog | yes (vision) | resolve(query, constraints) |
| XR runtime (client) | Scene DSL interpreter, interaction primitives, input mapping, scoring events | no | <SceneRunner doc onEvent /> |
| Widget runtime (client) | Widget DSL renderers, iframe sandbox host, postMessage scoring protocol | no | <BlockRenderer doc /> |
| AI provider layer | adapters, per-tenant routing policy, prompt cache keys, usage events, quotas | is the AI boundary | llm.complete(task, input), embed(), transcribe() |
| Reporting | completion, scores, time on task, certificates, CSV export, usage dashboards | no | read models over attempts and usage events |
| Billing hooks | plans, seats, orders, entitlements, subscriptions, payout ledger (schema only) | no | hasEntitlement(user, course) |
Request path: client → API (auth, tenant resolution, RLS session, rate limit) → module → Postgres. Long jobs (conversion, indexing, multi-second generation, asset normalization) go to the queue and report progress over server-sent events. The four AI-spending modules reach models only through the provider layer, which is the only place that knows which vendor or self-hosted endpoint a tenant uses.
Runtime topology (MVP):
[Browser / Quest browser]
│ HTTPS + SSE
[Next.js app + API routes] ──► [Postgres + pgvector]
│ enqueue [Object storage (R2/S3) + CDN]
[Node job runner] ◄──────────► [Redis: queue + cache]
│ ML jobs (HTTP/gRPC)
[Python worker: Docling · WhisperX · BGE-M3 · bge-reranker]
│
[Provider layer] ──► Anthropic API │ speech API │ self-hosted vLLM (later)
4. Tech stack
| Layer | Choice | Why |
|---|---|---|
| Web app | Next.js (App Router), React, TypeScript | One codebase for studio, player, admin; SSR for public pages; AI coding tools are strongest here |
| 3D / XR | Three.js via React Three Fiber, @react-three/xr, @react-three/rapier, drei |
Largest ecosystem and training corpus; WebXR on Quest, visionOS Safari, Android; declarative React tree maps 1:1 to the Scene DSL |
| UI design system | shadcn/ui (Radix primitives + Tailwind CSS v4, components copied into the repo via the shadcn CLI), lucide icons, shadcn charts (Recharts) | Owner's choice for the product and the website; accessible primitives, CSS-variable theming with dark mode, dashboard and sidebar blocks for Studio and Admin, components are owned code so the AI co-pilot and inspector panels can extend them freely |
| Editor | Block editor (Tiptap/ProseMirror for rich text blocks) + custom block types styled with shadcn/ui | Blocks are JSON documents; AI output lands as editable blocks |
| API | Next.js route handlers / tRPC-style typed procedures, Zod schemas shared with client | End-to-end types; Zod schemas double as LLM structured-output schemas and validators |
| Database | Postgres 16+ with pgvector (HNSW), pg_trgm, full-text search; Row-Level Security |
One store for relational, vector and lexical search; RLS gives tenant isolation at the database, not just the app |
| ORM / migrations | Drizzle ORM + SQL migrations | Thin, SQL-first, works with RLS session variables |
| Queue / cache | Redis (BullMQ) | Job retries, progress, rate limiting, semantic FAQ cache index |
| Object storage | Cloudflare R2 (or S3) + CDN | Zero-egress for GLB and file delivery |
| Video | Bunny Stream or Cloudflare Stream | Transcoding, adaptive bitrate, per-minute pricing; timestamps map to transcript |
| ML worker | Python 3.12, FastAPI, Docling, WhisperX, BGE-M3, bge-reranker-v2-m3, PySceneDetect | Best open-source parsing/transcription/embedding tools are Python; same stack runs self-hosted in a client VPC |
| LLM (cloud default) | Anthropic Claude: Haiku 5.5, Sonnet 5.5, Opus 5.5 via official SDK | Structured outputs, prompt caching, Batch API, 1M context |
| LLM (self-hosted) | vLLM serving open-weight models behind the same adapter interface | Required for tenants that refuse cloud AI; added post-pilot |
| Auth | Auth.js or Lucia with email magic link + OAuth; sessions in Postgres | Global identity now; SSO adapters later |
| Hosting (MVP) | EU region throughout: Vercel (fra1) or Hetzner for web, Fly.io (fra) or Hetzner for job runner and Python worker, Neon or Supabase Postgres in eu-central, Cloudflare R2 with EU jurisdiction | Hosting region is Europe; managed, cheap, no ops for a duo; whole stack is container-portable for VPC deployments |
| Observability | OpenTelemetry traces, Sentry, structured logs, per-call usage events | Token cost is a first-class metric |
| Testing | Vitest, Playwright, schema-based property tests for DSL validators, golden JSON fixtures for generation | Generation is tested against schemas and scene invariants, not exact text |
4.1 Design system rules
- All product surfaces (Studio, Player, Org Admin, public website, docs) use shadcn/ui components and Tailwind tokens; no second component library.
- Theme tokens live in one
globals.css(light + dark) and are shared by the 3D inspector, the quiz runner and embedded widget chrome, so generated blocks inherit the organization's theme. - Organizations get a limited white-label layer later: logo, primary color, font pair, mapped onto the same CSS variables.
- Component additions go through the shadcn registry pattern (
components/ui/*), app-specific composites incomponents/app/*.
5. Core LMS
5.1 Content hierarchy
Organization
└── Course (settings: progression rule, pass threshold, tutor mode, certificate policy, language)
└── Module (gate: none | pass previous module's assessment)
└── Lesson (ordered list of Blocks)
└── Block (kind + JSON document + versions)
5.2 Block kinds at MVP
| Kind | Document schema (summary) | Renderer | Gradeable |
|---|---|---|---|
text |
ProseMirror JSON | rich text | no |
video |
stream asset id, chapters, transcript ref | video player with transcript | no |
file |
file ref, display mode | inline PDF/slides viewer | no |
quiz |
question list, settings (attempts, time limit, shuffle, bank draw) | quiz runner | yes |
widget |
Widget DSL document (type + props + rules) | widget runtime | optional |
scene |
Scene DSL document (entities, interactions, success criteria) | XR runtime | optional |
embed |
iframe URL (allow-listed origin), sandbox flags, scoring expected? | sandboxed iframe host | optional via postMessage |
Every block has draft and published versions. Publishing creates an immutable block_version row; a lesson's published state references specific version ids. Students are pinned to the course publication they started (a course_publication snapshot of version ids), so editing for the next cohort never rewrites a live cohort's history. Rollback = re-publish an earlier snapshot.
5.3 Block editor + AI co-pilot
Studio and player UI ship in English first with Russian as the second locale (i18n from M0; all strings in message catalogs, RTL not required). Course content and the tutor remain multilingual independent of the UI locale.
- Educator Studio renders the lesson as a vertical block list; each block is directly editable.
- The co-pilot side panel accepts prompts scoped to selection, block, lesson, module or course. Output is always one or more blocks inserted or replaced in place, shown as a diff the educator accepts or rejects.
- Grounding toggle on every prompt: Materials only (default; each generated claim or question carries a
sourcereference to a content block) or Materials + general knowledge (output markedunverified_against_materials: trueand shown with a badge). - Course scaffold flow: upload → ingestion runs → "Draft a course outline from these materials" → modules/lessons with text blocks and suggested quiz slots → educator edits.
5.4 Progression, completion, certificates
- Course setting
progression:free|linear|gated(module N+1 unlocks when module N's assessment passes threshold). - Completion rule: all required blocks viewed + all required assessments passed. Required flags are per block.
- Certificate policy:
none|on_completion; optionalvalid_for_daysandrecertify_before_days(recertification creates a new enrollment with a due date and notifies the student and org admin). - Certificates are PDF rendered from a template with a verification code and public verify URL.
5.5 Reporting (standard tier)
Org admin dashboard and CSV export: per student × course: status, progress %, score, attempts, time on task, certificate issued/expiry, last activity. Per course: completion funnel, question difficulty (p-value), most-asked tutor questions, token spend. Attempts are append-only, so the audit-grade tier later only adds views and exports, not schema changes.
6. Assessment engine
6.1 Question types (MVP)
| Type | Grading | Notes |
|---|---|---|
single_choice, multi_choice, true_false |
deterministic | partial credit option for multi |
ordering, matching |
deterministic | exact or partial (Kendall tau / pair match) |
fill_blank |
deterministic with normalization + accepted variants | case/whitespace/diacritics-insensitive; numeric tolerance |
short_answer, essay |
LLM-graded against rubric | see 6.3 |
scene_practical |
success criteria evaluated from scene events | see 6.4 |
widget_scored |
score reported by widget or iframe | postMessage protocol (8.6) |
Deferred to v1.1: hotspot (click the hazard in an image), drag_drop_label, branching_scenario. Recommended early because hotspot is the cheapest high-value HSE assessment.
6.2 Quiz settings
Attempts allowed, time limit, shuffle questions/options, draw N from a question bank with tags, show feedback (immediately / after submit / never), pass threshold, required for progression, weight in course grade.
6.3 LLM-graded open answers
- Educator writes or generates a rubric (criteria with points and descriptors) and approves it; the rubric is versioned with the block.
- Grading call (Haiku 5.5 by default; Sonnet 5.5 for essays): input = question, rubric, model answer if any, student answer; output = structured JSON
{criteria: [{id, points, evidence}], total, confidence, feedback}under a strict schema. - Confidence below threshold (default 0.7) or score within ±5% of pass threshold → routed to the educator's review queue; the student sees "pending review".
- Educator override is stored as a new grading record; the original stays for audit.
- All grading prompts treat the student answer as data (delimited, instructions forbid following it).
6.4 3D practical scoring
A scene block declares successCriteria: a list of named conditions over scene state and events, e.g. all_hazards_found, ppe_sequence_correct, completed_within_seconds: 180, no_forbidden_zone_entered. The runtime emits a signed event stream (started, interaction, criterion_met, criterion_failed, completed, abandoned). The server scores the attempt from the event log, never from a client-reported total, and stores the log for replay.
6.5 Exam lockdown
Any block with assessment.lockdown = true sets the tutor to locked for that student while the attempt is open: tutor refuses content questions and can only restate question wording. Enforced server-side in the tutor module by checking open attempts.
7. Vibe-coding generation pipeline
7.1 Principle
The LLM emits documents, not code. Each block kind has a Zod/JSON Schema. Generation requests are structured-output calls validated before anything is stored or rendered. Edits are JSON Patch operations against the current document, so iteration costs a few hundred output tokens instead of a regeneration.
7.2 Pipeline
educator prompt + context (selected blocks, materials, grounding flag)
→ 1. Planner (Sonnet 5.5): intent → Spec {objectives, target block kinds, objects/assets needed, interactions, success criteria, source refs}
→ 2. Asset resolution (scenes/widgets): for each needed object, Asset Search returns top-N catalog ids with bounding boxes; fallback: primitive composition
→ 3. Compiler (Opus 5.5 for scenes; Sonnet 5.5 for quizzes/widgets): Spec + asset ids → DSL document (structured output, strict schema)
→ 4. Validator: schema → semantic checks (floor exists, no overlaps beyond tolerance, every criterion reachable from declared interactions, every quiz answer key present, every source ref resolves) → on failure: repair call with error list, max 2 passes, then surface to educator
→ 5. Store as draft block version → preview renders
→ 6. Educator loop: chat edit → Patch LLM emits JSON Patch → validator → new draft version; inspector edits are direct patches
→ 7. Publish
7.3 Prompt economics
- Static system prompt per block kind (DSL docs, schema, 3–5 exemplar documents, authoring rules) ≈ 15–20k tokens, cached. Volatile content (spec, assets, materials excerpts) after the cache breakpoint.
- Materials are provided as retrieved chunks (top-k by spec), not whole documents, except for short sources (< 20k tokens) where the whole source is included under a second cache breakpoint.
- Output budgets: quiz ≈ 1–3k tokens, widget ≈ 1–2k, scene ≈ 3–6k, patch ≈ 0.2–0.8k.
- All generation runs are logged as
usage_eventswith task type for cost attribution.
7.4 Widget DSL (summary)
{
"kind": "widget", "type": "flashcards | sorter | timeline | sim_embed | labeled_diagram | step_sequence | parameter_explorer",
"title": "...", "props": { "...type-specific..." },
"rules": [ { "on": "event", "if": "expr", "do": ["action"] } ],
"scoring": { "mode": "none | completion | points", "max": 10 },
"sources": [ { "contentBlockId": "...", "note": "..." } ]
}
Rules use a tiny expression grammar (comparisons, boolean ops, state refs), no arbitrary code. New widget types are added by shipping a renderer + schema; the LLM learns them from the schema registry in the system prompt.
7.5 Scene DSL (summary)
{
"kind": "scene", "version": 1,
"environment": { "preset": "warehouse | workshop | outdoor | lab | empty", "lighting": "day", "floor": true },
"entities": [
{ "id": "ext1", "asset": "cat:fire_extinguisher_co2_v2", "transform": { "p": [2, 0, -1], "r": [0, 90, 0], "s": 1 },
"physics": "static | dynamic | none", "tags": ["hazard_tool"],
"interactions": [ { "type": "grab" }, { "type": "place_into", "socket": "bracket1" } ],
"label": { "text": "CO2 extinguisher", "show": "on_hover" } }
],
"sockets": [ { "id": "bracket1", "p": [0, 1.2, 0], "accepts": ["hazard_tool"] } ],
"zones": [ { "id": "forbidden1", "shape": "box", "p": [0,0,0], "size": [2,2,2], "tags": ["forbidden"] } ],
"steps": [ { "id": "s1", "instruction": "Find and lift the extinguisher", "complete_when": "event:grab ext1" } ],
"rules": [ { "on": "enter_zone forbidden1", "do": ["fail criterion:no_forbidden_zone", "say 'Stay clear of the live panel'"] } ],
"successCriteria": [ { "id": "no_forbidden_zone", "label": "...", "weight": 1 }, { "id": "placed_in_time", "expr": "placed(ext1, bracket1) && elapsed < 180" } ],
"guide": { "avatar": "default", "voice": false },
"assets": [ { "id": "cat:fire_extinguisher_co2_v2", "license": "CC0", "attribution": null, "bounds": [0.2, 0.6, 0.2] } ]
}
Interaction primitives (MVP): look, teleport/walk, point_select, grab, place_into, press, proximity, sequence, timer. Fidelity is "guided interaction, light physics": dynamic bodies only for grabbed/dropped objects.
8. 3D/XR runtime, educator QA and asset search
8.1 Runtime
SceneRunner(React Three Fiber) interprets the Scene DSL into a scene graph; asset ids resolve to CDN GLB URLs (Draco/meshopt, KTX2), loaded with LOD and a poly budget per scene (default 300k tris).- Input mapping layer: mouse/keyboard (desktop), touch + gyro (mobile), WebXR controllers and hands (Quest). DSL interactions are abstract; the mapping table is the only platform-specific code.
- Performance budget: 60 fps on a mid-range laptop in 2D, 72 fps on Quest 3 for MVP scenes; validator warns when asset budgets are exceeded.
- Event stream to server over WebSocket/SSE; events signed with the attempt token.
- Guide avatar: a lightweight glTF avatar placed in the scene that speaks step instructions as text (voice later). Same component becomes the tutor avatar in a later tier.
8.2 Educator QA stage (required before publish)
| Capability | MVP |
|---|---|
| Play-test in browser (2D) | yes |
| Smoke test in headset via preview link | yes (Quest browser) |
| Inspector: move/rotate/scale, replace asset from top-N candidates, edit labels, steps, rules via forms | yes |
| Chat edits → JSON Patch | yes |
| Draft → Published versions, students pinned, rollback | yes |
| Pilot cohort preview link + attempt review | yes |
| AI auto-QA agent (headless play-through, reachability report) | deferred |
8.3 Asset search engine and quality gate
Sources (connectors with their own rate limits and license metadata): Sketchfab (CC-licensed, downloadable), Poly Haven, Smithsonian 3D, NASA 3D, Kenney, Quaternius, Khronos samples; simulations and widgets: PhET, Open Source Physics, Falstad, GeoGebra, H5P. Objaverse metadata for semantic coverage.
Pipeline per candidate: license filter (allow CC0, CC-BY, CC-BY-SA with auto-attribution; reject NC/ND for commercial tenants) → download → normalize GLB (scale to meters, pivot at base, Draco/meshopt, KTX2, LOD0–2, bounds, triangle count) → vision score (Haiku 5.5 vision on thumbnail vs query: fit, quality, orientation) → store in catalog with text + CLIP embeddings → cache. Approved assets are reused across tenants; the catalog grows into a curated library organically.
Educators can upload their own GLB/GLTF/FBX/OBJ; uploads pass the same normalization and a safety scan (size, embedded scripts not applicable to glTF, texture limits).
8.4 Attribution
Every published scene renders an attribution panel from assets[].attribution; the course export includes a credits file. SA-licensed assets are flagged so educators understand redistribution terms.
8.5 Sandboxed iframe embeds
Allowed origins per organization (defaults: phet.colorado.edu, geogebra.org, h5p.org, educator's own domain). Iframe attributes: sandbox="allow-scripts allow-same-origin allow-forms", no top-navigation, CSP frame-src restricted. No platform credentials are ever passed in.
8.6 postMessage scoring protocol (xAPI-lite)
host → embed: { type: "dp:init", attemptId, locale, student: { pseudonymousId } }
embed → host: { type: "dp:started" }
embed → host: { type: "dp:progress", value: 0..1 }
embed → host: { type: "dp:completed", score: { raw, max }, success: bool, duration }
embed → host: { type: "dp:event", name, data } // free-form, stored for replay
Host validates event.origin against the allow-list and ignores anything else. Scores are advisory: the educator chooses whether an embed contributes to the grade.
9. Ingestion pipeline and RAG tutor
9.1 Ingestion (index time)
- Upload to object storage via signed URL; virus scan; job enqueued.
- Convert (Python worker): PDF/DOCX/PPTX → Docling → structured Markdown with headings, tables, page numbers; PPTX also captures per-slide text, notes, and a slide image. Video/audio → WhisperX (language auto-detect, word timestamps, diarization off) + PySceneDetect keyframes; keyframes with visible slides are captioned by a vision model.
- ContentBlocks: one row per logical unit with
{course_id, file_id, kind, heading_path, page|slide|t_start/t_end, text, image_ref}. - Chunk: heading-aware, 400–800 tokens, parent/child (child chunks of ~150 tokens for recall, parent for context).
- Context line: 60–100 token LLM-written prefix per chunk describing where it sits in the document (Haiku 5.5, whole document under a cache breakpoint, run via Batch API).
- Embed: BGE-M3 (multilingual, 1024-d) →
chunk_embeddings(HNSW, cosine). Lexical: Postgres FTS with per-language configs. - Course digest: LLM-generated 5–10k token structured summary (syllabus map, key terms with definitions, module summaries, glossary in source language) regenerated on material change; stored per course publication.
- Status surfaced to the educator per file; failures retryable.
9.2 Tutor (query time)
question → language detect → lockdown/mode gate
→ FAQ semantic cache (pinned answers + prior high-confidence answers, threshold 0.92 cosine, same course) → hit: return
→ query rewrite (Haiku: standalone question + 2 paraphrases, translated to material language if different)
→ hybrid retrieval: vector top-20 + BM25 top-20 → RRF → rerank (bge-reranker-v2-m3) → top-6 parent chunks
→ Tutor LLM (Sonnet 5.5): system = course digest (cached) + mode instructions (Direct | Socratic) + citation rules
tools: search_course(query) up to 3 extra calls for multi-hop
context: chunks with ids; student language
→ stream answer; citations as [c:chunkId] markers → client resolves to page/slide/timestamp deep links
→ log message with usage, retrieval set, confidence
Behaviors:
- Direct: answers with citations. Socratic: hints and questions, never a direct answer to an assessment item; educator sets per course.
- Unknowns: when retrieval confidence is low or the model reports no support, the tutor says the materials do not cover it; if the course enables general knowledge, it gives a clearly labeled general answer; the student can escalate to the educator inbox. Educator answers once and pins → FAQ cache entry visible to the cohort.
- Multilingual: material language and student language may differ; retrieval runs in material language (query translated), answer in student language, citations unchanged.
- Scope: refuses off-course requests; treats retrieved content as data (prompt-injection guard in system prompt and delimiters).
- Voice (tier 2) and 3D avatar (tier 3) reuse this backend; they are UI tiers only.
9.3 Quality loop
Per course: an auto-generated eval set (20–50 Q/A pairs with source refs, educator-editable). Nightly or on reindex: run eval, report retrieval hit rate and answer faithfulness (LLM judge on Haiku) to the educator dashboard. Question logs with thumbs up/down feed the FAQ pinning queue.
10. AI provider layer
10.1 Adapter interface
interface LLMProvider { complete(req: { task: TaskType; system: Part[]; messages: Msg[]; schema?: JSONSchema; tools?: Tool[]; maxTokens: number; cacheKeys?: string[] }): AsyncIterable<Chunk | Final> }
interface EmbeddingProvider { embed(texts: string[], lang?: string): Promise<Float32Array[]> }
interface SpeechProvider { transcribe(file): Promise<Transcript>; synthesize?(text, voice): Stream }
interface VisionProvider { score(image, prompt, schema): Promise<JSON> }
Routing policy per organization: { mode: "cloud" | "self_hosted" | "hybrid", endpoints, allowed_tasks, data_residency }. The router picks the adapter per task from the table below, applies the tenant policy, attaches cache keys, and writes a usage_event on completion. Prompts are provider-neutral; structured output is enforced by schema validation + one repair pass on every provider, with provider-native structured outputs used when available.
10.2 Model routing table
| Task | Cloud default | Self-hosted equivalent | Unit estimate |
|---|---|---|---|
| Chunk context lines, query rewrite, rubric checks, asset fit scoring | Haiku 5.5 | small open-weight (vLLM) | cents per course |
| Tutor answers | Sonnet 5.5 | mid open-weight (vLLM) | ≈ $0.01 / question |
| Quiz and widget compilation, LLM grading of essays | Sonnet 5.5 | mid open-weight | ≈ $0.02–0.05 / block |
| Scene compilation and repair | Opus 5.5 | large open-weight | ≈ $0.10 full, $0.02 / patch |
| Course digest | Sonnet 5.5 | mid open-weight | ≈ $0.05–0.20 / course |
| Embeddings | BGE-M3 (worker) | BGE-M3 | cents per course |
| Transcription | WhisperX (worker) | WhisperX | ≈ $0 self-run; $0.006/min if API |
| Rerank | bge-reranker-v2-m3 (worker) | same | negligible |
Prices from Anthropic list, October 2026 (Opus 5.5 $4/$20, Sonnet 5.5 $2/$10, Haiku 5.5 $0.10/$0.50 per MTok; cache reads ≈ $0.20/MTok). Estimates are order-of-magnitude.
10.3 Metering and quotas
usage_events(id, org_id, user_id, task, provider, model, tokens_in, tokens_cached, tokens_out, cost_cents, latency_ms, ref_type, ref_id, created_at)written by every call.- Plan defines a monthly credit allowance (credits = normalized cost units). Soft cap (80%): admin notification + banner. Hard cap (100%): educator-side generation pauses; student-side tutor degrades in order: Sonnet → Haiku → FAQ cache only → "tutor paused" message. Students are never blocked mid-lesson from content.
- Super-admin can set per-org overrides and per-user rate limits (e.g., 60 tutor questions/hour).
10.4 Cost controls (built in)
Prompt caching on all static prefixes; Batch API for indexing; JSON Patch edits; semantic FAQ cache; top-k ≤ 6 chunks; max 2 repair passes; token budgets per task; nightly cost report per org and per task.
11. Data model
All tenant-scoped tables carry org_id and are covered by RLS policies keyed on current_setting('app.org_id') and the user's role. Ids are UUIDv7. Timestamps in UTC.
11.1 Identity and tenancy
users(id, email, name, locale, created_at)organizations(id, name, slug, type: solo|company|school, settings jsonb, plan_id, created_at)memberships(id, org_id, user_id, role: owner|admin|educator|student, status, invited_by, created_at)course_grants(id, org_id, course_id, user_id, role: educator|ta)invitations(id, org_id, email, role, token, expires_at, accepted_at)identity_providers(id, org_id, type, config jsonb)(SSO later)
11.2 Content
courses(id, org_id, title, description, language, progression: free|linear|gated, pass_threshold, tutor_mode: direct|socratic, allow_general_knowledge bool, certificate_policy jsonb, status, created_by)modules(id, course_id, title, position, gate_on_module_id null)lessons(id, module_id, title, position)blocks(id, lesson_id, kind, position, required bool, assessment jsonb null, current_draft_version_id)block_versions(id, block_id, version_no, document jsonb, schema_version, status: draft|published, created_by, created_at, published_at)— immutable once publishedcourse_publications(id, course_id, version_no, snapshot jsonb {lesson→[block_version_id]}, published_at, published_by)files(id, org_id, course_id, name, mime, size, storage_key, status: uploaded|processing|ready|failed, meta jsonb)content_blocks(id, course_id, file_id, kind, heading_path text[], page int, slide int, t_start, t_end, text, image_key)chunks(id, course_id, content_block_id, parent_chunk_id, text, context_line, token_count, lang)chunk_embeddings(chunk_id, embedding vector(1024), model)+ HNSW index; FTS column onchunkscourse_digests(id, course_publication_id, text, token_count, model, created_at)question_banks(id, course_id, name),questions(id, bank_id, type, document jsonb, tags text[], stats jsonb)rubrics(id, org_id, block_version_id, document jsonb, approved_by)
11.3 Learning records
enrollments(id, org_id, course_id, user_id, course_publication_id, status: active|completed|expired, due_at, started_at, completed_at, progress jsonb)attempts(id, enrollment_id, block_version_id, started_at, submitted_at, score, max_score, passed bool, grading jsonb, state jsonb)— append-only; corrections are new rows referencingsupersedes_attempt_idattempt_events(id, attempt_id, seq, type, payload jsonb, at)— scene and widget event loggrading_reviews(id, attempt_id, reviewer_id, override jsonb, note, at)block_views(id, enrollment_id, block_version_id, first_at, last_at, seconds)certificates(id, enrollment_id, code, issued_at, expires_at, pdf_key, revoked_at)
11.4 Tutor
conversations(id, org_id, course_id, user_id, enrollment_id, created_at)messages(id, conversation_id, role, text, lang, retrieval jsonb, citations jsonb, usage_event_id, feedback int, created_at)faq_entries(id, course_id, question, answer, embedding vector(1024), source: pinned|auto, pinned_by, visible bool)escalations(id, course_id, message_id, status: open|answered, answered_by, answer_text, pinned_faq_id)
11.5 Generation and assets
generation_runs(id, org_id, course_id, block_id, kind, spec jsonb, status, passes int, usage_event_ids uuid[], created_at)block_patches(id, block_version_from, block_version_to, patch jsonb, source: chat|inspector, prompt text)asset_sources(id, name, base_url, license_policy jsonb)assets(id, source_id, external_id, title, license, attribution, glb_key, thumb_key, bounds float[3], tris int, lods int, text_embedding vector(1024), clip_embedding vector(512), vision_score jsonb, status: candidate|approved|rejected, org_id null for shared)scene_asset_refs(block_version_id, asset_id)
11.6 AI and billing
provider_policies(org_id, mode, endpoints jsonb, allowed_tasks text[], data_residency)usage_events(...)as in 10.3plans(id, name, credits_monthly, seat_limit, features jsonb, price_cents, interval)subscriptions(id, org_id|user_id, plan_id, status, period_start, period_end, external_ref)seat_licenses(id, org_id, course_id null, seats_total, seats_used, valid_from, valid_to)orders(id, org_id|user_id, items jsonb, total_cents, currency, status, external_ref)entitlements(id, user_id, course_id, source: seat|order|subscription|grant, valid_to)payout_ledger(id, educator_user_id, order_id, gross_cents, fee_cents, net_cents, status)audit_log(id, org_id, actor_id, action, target_type, target_id, at, meta jsonb)
12. Security, isolation and privacy
- Tenant isolation: RLS on every tenant table; the API sets
app.org_idandapp.user_idper transaction; service-role access only in workers with explicit org scoping. Cross-org reads are impossible by construction; shared catalog assets haveorg_id nulland are read-only. - Content sandboxing: no arbitrary code from educators or LLMs runs on the server. DSL rules use a bounded expression grammar. Iframe embeds are origin-allow-listed and sandboxed; postMessage origin is validated.
- Prompt injection: uploaded material, student answers and retrieved chunks are always placed as delimited data with instructions that they carry no authority; tool-calling tutors cannot take actions beyond
search_course. - Files: signed upload URLs, MIME sniffing, size caps, antivirus scan, image re-encoding; GLB uploads validated by a glTF validator.
- PII: student pseudonymous ids are what embeds and analytics see; exports are role-gated and logged in
audit_log. - Secrets and keys: provider keys per environment; per-org self-hosted endpoints stored encrypted.
- Rate limits: per user and per org on tutor, generation and uploads; abuse signals feed the quota ladder.
- Data residency hook:
provider_policies.data_residencyplus a deployment profile allow a full-stack VPC install; no component in the MVP depends on a vendor that cannot be replaced by the open-source worker path. - Retention: conversations and attempt events retained per org policy (default 24 months); deletion requests cascade through enrollment.
13. Billing hooks (schema now, gateway later)
Supported models, all expressed as entitlements: seat licensing per organization, per-course purchase by a student, subscription plans for organizations and students, educator revenue share for a future marketplace.
hasEntitlement(user, course)is the single access check used by the player and tutor. At MVP entitlements are created by enrollment within an organization (seat source) or by manual grant.- Plans and credits already gate AI usage (10.3), so a payment gateway later only needs to create subscriptions and orders and flip statuses; no code path in the learning flow changes.
- Gateway adapter interface (
createCheckout,handleWebhook,syncSubscription) is defined and stubbed; Stripe is the likely first implementation. - Payout ledger rows are written when an order references a course owned by an educator on a marketplace plan; payouts themselves are out of scope.
14. MVP roadmap (solo/duo, pilot in 3–4 months)
Assumes one or two engineers working with AI coding tools, managed hosting, cloud models only. Each milestone ends with a demoable gate.
| Milestone | Weeks | Scope | Gate |
|---|---|---|---|
| M0 Foundation | 1–2 | Monorepo, Next.js app, Postgres + RLS, auth with global identity, organizations and memberships, invitations, object storage, queue, provider layer skeleton with usage events, CI | Two orgs with isolated data; a usage event per LLM call |
| M1 Course core | 3–5 | Course/module/lesson/block model, block editor (text, video, file, quiz), versions and publications, student player, progression rules, deterministic quiz grading, attempts, progress, org admin reports + CSV | An educator builds and publishes a course; a student completes it; admin exports results |
| M2 Ingestion + tutor | 6–8 | Python worker (Docling, WhisperX, BGE-M3, reranker), content blocks, chunking, contextual lines, hybrid search, course digest, tutor chat with citations, modes, lockdown, unknown handling, escalation and pinning, FAQ cache, multilingual | Tutor answers 20 eval questions on a real course with correct citations; cost per question logged |
| M3 AI co-pilot + assessments | 9–10 | Co-pilot panel, grounded course outline and quiz generation with source refs, LLM-graded short answers with rubric and review queue, question banks, certificates, quotas and caps | Educator generates a module with quizzes from uploads in under an hour; grading review queue works |
| M4 Scenes | 11–14 | Scene DSL + validator, R3F runtime with interaction primitives, input mapping for desktop/mobile, WebXR smoke test on Quest, asset search with 3 sources + quality gate, planner/compiler/repair pipeline, inspector, chat patches, practical scoring from events, pilot preview links | Educator prompts a hazard-hunt scene, QA's it, publishes; student completes it on laptop and Quest; attempt is scored server-side |
| M5 Pilot hardening | 15–16 | Observability, cost dashboard, error budgets, backups, onboarding flow, docs, pilot org setup | Pilot organization live with 1–3 courses and 20+ students |
Out of MVP (next): interactive 2D question types (hotspot, drag-drop, branching), voice tutor, 3D avatar lip-sync, AI auto-QA agent, first self-hosted adapter + VPC deployment profile, SSO/SCIM, audit-grade reporting, public catalog, payment gateway, text-to-3D fallback.
15. Risks and open questions
| Risk | Impact | Mitigation |
|---|---|---|
| Scene generation quality is uneven across subjects (subject-agnostic choice) | Educators lose trust in 3D | Validator invariants, top-N asset choice in QA, environment presets, 5–10 exemplar scenes in the cached prompt; measure first-pass acceptance rate |
| Asset licensing mistakes | Legal exposure for tenants | License filter is allow-list only; attribution rendered automatically; SA flagged; educator confirms on publish |
| Tutor hallucination in compliance content | Wrong training delivered | Grounded by default, citations required, unknown handling, educator pinning, per-course eval set and faithfulness score |
| Token cost spikes | Margin loss | Caching, routing, caps, degradation ladder, per-org dashboards from M0 |
| Two runtimes (TS + Python) for a duo | Ops load | Python worker is a single container with a small HTTP surface; everything else is TS; managed hosting |
| WebXR fragmentation | VR demos fail | Quest browser is the only MVP target; XR is a smoke test, not a gate, until M4 proves 2D |
| Self-hosted model quality lag | B2B deals blocked | Provider abstraction from day one; evaluate open-weight models on the same eval sets before committing to a tenant |
| Solo/duo timeline slips | Pilot delay | Milestones are gated and independently demoable; M4 scope can shrink to one environment preset and three interactions |
Decisions taken 2026-10-17 on the former open questions:
- UI language: English primary, Russian secondary. i18n scaffolding in M0, Russian catalog by M5.
- Hosting region: Europe. All managed services pinned to EU regions (see section 4); no data leaves the EU except model API calls to Anthropic, which the pilot client accepts.
- Cloud models: accepted by the pilot client. The self-hosted adapter stays post-MVP.
- Subscriptions: the owner wants to try subscription models, so subscription plans become the first billing model implemented when the gateway lands (after M5), ahead of per-course orders. The plan and credit tables already gate AI usage in the MVP.
- Pilot course topic: not yet chosen. Proposed default, pending confirmation: Workplace fire safety and evacuation. Reasons: materials are abundant and public (national fire-safety guidance, employer procedures), it applies to any office, warehouse or housing company, it exercises every MVP feature (document and video ingestion, quizzes, an LLM-graded "what would you do" answer, and a hazard-hunt plus extinguisher-selection 3D scene), and it is recertified yearly, which tests the certificate expiry flow. Alternatives considered: electrical safety and lockout-tagout for maintenance staff (stronger 3D, narrower audience); working at height and ladder safety (good 3D, thinner materials).
Appendix A. Generation prompt layout (per block kind)
[cached] system: role + authoring rules + DSL reference + JSON Schema + 3–5 exemplars + validator error vocabulary
[cached] system: organization style guide (optional)
[volatile] user: Spec JSON + resolved assets + retrieved material chunks (with ids) + grounding flag
→ structured output: BlockDocument
on validation failure → user: error list → structured output: BlockDocument (max 2 passes)
Appendix B. Minimal API surface (MVP)
- Auth:
POST /auth/magic-link,GET /auth/callback,POST /orgs/:id/invitations - Courses: CRUD for courses/modules/lessons/blocks;
POST /blocks/:id/versions,POST /courses/:id/publish,POST /courses/:id/rollback - Files & ingestion:
POST /files/sign,POST /files/:id/ingest,GET /files/:id/status(SSE) - Generation:
POST /generate(SSE progress → block version),POST /blocks/:id/patch - Assets:
GET /assets/search?q=&kind=&license=,POST /assets/upload - Learning:
POST /enrollments,POST /attempts,POST /attempts/:id/events,POST /attempts/:id/submit,GET /me/progress - Tutor:
POST /courses/:id/tutor/ask(SSE),POST /messages/:id/feedback,POST /escalations/:id/answer - Reports:
GET /orgs/:id/reports/completion.csv,GET /orgs/:id/usage