Technical Specification & System Architecture

v0.3 · 2026-10-17

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_provider table 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 in components/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 source reference to a content block) or Materials + general knowledge (output marked unverified_against_materials: true and 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; optional valid_for_days and recertify_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

  1. Educator writes or generates a rubric (criteria with points and descriptors) and approves it; the rubric is versioned with the block.
  2. 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.
  3. 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".
  4. Educator override is stored as a new grading record; the original stays for audit.
  5. 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_events with 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.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)

  1. Upload to object storage via signed URL; virus scan; job enqueued.
  2. 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.
  3. ContentBlocks: one row per logical unit with {course_id, file_id, kind, heading_path, page|slide|t_start/t_end, text, image_ref}.
  4. Chunk: heading-aware, 400–800 tokens, parent/child (child chunks of ~150 tokens for recall, parent for context).
  5. 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).
  6. Embed: BGE-M3 (multilingual, 1024-d) → chunk_embeddings (HNSW, cosine). Lexical: Postgres FTS with per-language configs.
  7. 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.
  8. 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 published
  • course_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 on chunks
  • course_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 referencing supersedes_attempt_id
  • attempt_events(id, attempt_id, seq, type, payload jsonb, at) — scene and widget event log
  • grading_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.3
  • plans(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_id and app.user_id per transaction; service-role access only in workers with explicit org scoping. Cross-org reads are impossible by construction; shared catalog assets have org_id null and 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_residency plus 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