Domain · Architecture

Metis — Architecture

Metis is the education and course-generation domain of the Oshun platform.

10sections11 minread

On this page

Overview#

Metis is the education and course-generation domain of the Oshun platform. Its purpose is to give learners structured, AI-augmented access to a curated curriculum — centred on the core six disciplines of philosophy, religion, psychology, neuroscience, anthropology, and astronomy — while giving instructors the tools to author courses, assess learning, and review AI-generated content before it reaches students.

The real-world problem Metis solves is the gap between raw AI content generation and trustworthy educational delivery. A plain LLM can draft lesson text, but without source-grounded review, item calibration, academic-integrity enforcement, and credential issuance that gap in trust cannot be closed. Metis owns every step of that pipeline: from ingesting reviewed source material, through course authoring and learner progress tracking, to live AI tutoring and the issuance of cryptographically signed credentials.

The production implementation is a Python backend service located at services/metis/. It is built with FastAPI (async REST API), SQLAlchemy 2.0 (async ORM over PostgreSQL), and Celery (background task processing over Redis). The service handles course authoring, adaptive assessment, AI tutoring, learner analytics, source-grounded content generation, credentialing, and LMS interoperability.

A separate, large TypeScript library tree exists at libs/metis/*. This document describes the deployed, runnable Python backend under services/metis/, which is the canonical Metis service.


Process Topology#

Metis is not a single process — it runs as three cooperating process types all built from the same Docker image. This split keeps synchronous HTTP traffic (the API process) decoupled from slow, fallible work like course export and analytics aggregation (the worker and beat processes), so an expensive background job can never block a learner's page load.

text
┌──────────────────────────────────────────────────────────┐
│                      Clients / LMS                        │
└───────────────────────────────┬───────────────────────────┘
                                 │  HTTPS  (bearer JWT)
                       ┌─────────▼─────────┐
                       │     metis-api     │   gunicorn + UvicornWorker ×4
                       │  FastAPI / :8000  │   metis.main:app
                       └────┬─────────┬────┘
              SQLAlchemy 2.0 │         │ Celery enqueue (Redis broker)
                 (asyncpg)   │         │
              ┌──────────────▼──┐   ┌──▼──────────────────┐
              │   PostgreSQL    │   │      Redis          │
              │  (12 tables)    │   │ cache + broker +    │
              └─────────────────┘   │ result backend     │
                                    └──┬───────────┬──────┘
                                       │           │
                            ┌──────────▼──┐   ┌────▼─────────┐
                            │ metis-worker│   │  metis-beat  │
                            │  Celery     │   │ Celery beat  │
                            │  worker     │   │  scheduler   │
                            └──────┬──────┘   └──────────────┘
                                   │
                          ┌────────▼────────┐
                          │  S3 / MinIO     │  exports, uploads,
                          │  object store   │  rendered assets
                          └─────────────────┘

The three process types and their roles:

  • metis-apigunicorn metis.main:app with four UvicornWorker processes. Serves all REST endpoints under /api, plus root /health and /ready.
  • metis-workercelery ... worker consuming six queues (metis.default, metis.export, metis.notifications, metis.analytics, metis.cleanup, metis.ai). Executes all long-running background work.
  • metis-beatcelery ... beat, the periodic-task scheduler. Triggers hourly analytics aggregation and daily cleanup jobs on a cron-style schedule.

The production container (services/metis/Dockerfile) is a two-stage Python 3.11-slim build: uv installs dependencies and gunicorn; the runtime stage adds curl, tesseract-ocr, and tini, runs as a non-root metis user, exposes port 8000, and declares a /health HEALTHCHECK.


Source Layout#

The entire service lives under services/metis/. The internal Python package is at src/metis/ and follows a conventional layered structure — routers depend on schemas, schemas are consumed by services, and services read and write ORM models. Helper modules in services/ hold pure functions that can be tested without touching FastAPI or the database.

text
services/metis/
├── pyproject.toml          # metis-backend package (FastAPI, SQLAlchemy, Celery)
├── Dockerfile / Dockerfile.dev
├── docker-compose.yml      # metis-api / metis-worker / metis-beat
├── alembic.ini / alembic/  # schema migrations (versions/001_initial.py)
├── openapi/                # exported metis.openapi.json (contract artifact)
├── scripts/export_openapi.py
├── tests/                  # pytest suite (contract + helper tests)
└── src/metis/
    ├── main.py             # FastAPI application factory + lifespan
    ├── config.py           # pydantic-settings (METIS_-prefixed env vars)
    ├── deps.py             # DI: db session, Redis, S3 client, auth (CurrentUser)
    ├── openapi_contracts.py
    ├── api/                # FastAPI routers (16 domain routers)
    ├── models/             # SQLAlchemy ORM models (12 tables)
    ├── schemas/            # Pydantic request/response schemas
    ├── services/           # domain service + helper modules
    └── tasks/              # Celery app + task modules

API Routers (src/metis/api/)#

There are sixteen domain routers, each scoped to a distinct feature area. All sixteen are aggregated by api/__init__.py into api_router and mounted at /api. The routers cover: auth, courses, assessments, tutoring, analytics, admin, curriculum, source_ingestion, credentials, agent_runtime, lti, caliper, qti, scorm, xapi, and oneroster.

Layering#

The service follows a conventional layered architecture where each layer has a single, well-defined responsibility. This makes it easy to test domain logic in isolation and to change HTTP concerns (status codes, pagination shapes) without touching business rules.

text
api/        — FastAPI routers: HTTP concerns, auth dependencies, status codes
   │ depends on
schemas/    — Pydantic models: request validation + response serialization
   │ depends on
services/   — domain logic: CourseService, AssessmentService, TutoringService,
   │          AnalyticsService, AdminService, AgentRuntimeService, the source-*
   │          pipeline services, credential/LMS services, and helper modules
   │ depends on
models/     — SQLAlchemy ORM models over PostgreSQL
config / deps — cross-cutting: settings, DB engine, Redis, S3, auth

Routers obtain a service instance, pass a DB session and (where needed) the Settings object, and serialize results through Pydantic schemas. Several feature areas keep service logic split into a *_service.py orchestrator and one or more *_helpers.py modules holding pure, testable functions.


Persistence#

Metis stores all relational data in PostgreSQL via SQLAlchemy 2.0 async with the asyncpg driver. Twelve tables are created by the single Alembic migration alembic/versions/001_initial.py: users, courses, modules, lessons, assessments, questions, submissions, tutoring_sessions, tutoring_messages, enrollments, progress, and achievements. All twelve inherit id, created_at, and updated_at from TimestampMixin. The content hierarchy is three levels: Course → Module → Lesson.

The async engine, session factory, and Redis pool are module-level singletons initialized in the FastAPI lifespan (metis.deps). get_db yields a transactional session that commits on success and rolls back on exception, ensuring no partial writes escape to the database.

Some feature areas — source ingestion, lecture packages, credentials, and agent-runtime artifacts — produce large JSON documents that are better suited to object storage than relational tables. SourceIngestionService writes these to a configurable storage directory and can mint presigned S3 upload URLs against the metis-uploads bucket.


Key Design Patterns#

Application Factory + Lifespan#

metis.main:create_app builds the FastAPI app in a single place, making it easy to test with different settings: CORS middleware, an X-Request-Time timing middleware, structured logging, optional OpenTelemetry instrumentation, exception handlers, health endpoints, and all routers. The async lifespan context manager opens and closes the database engine and Redis pool, so startup failures surface immediately rather than at the first request.

Typed Validation at the Boundary#

Every request and response body is a Pydantic model in src/metis/schemas/. Field constraints (regex patterns, numeric bounds, length limits) and model_validator hooks enforce domain rules at the HTTP boundary, before any code touches the database. RequestValidationError is translated into a structured 422 response with per-field detail, so clients get actionable error messages rather than generic 500s.

Dependency-Injected Auth#

metis.deps exposes three auth dependencies: get_current_user (any authenticated request), get_current_instructor_user (instructor or admin; else 403), and get_current_admin_user (admin only; else 403). They decode a JWT bearer token (python-jose) into a CurrentUser and gate by role. Endpoints additionally enforce resource-level ownership — for example, tutoring sessions are accessible only by the owning learner, and assessment calibration endpoints require the course author or an admin.

Service + Helper Split#

Domain logic lives in services/. Larger feature areas pair a stateful service class (taking a DB session and/or Settings) with pure helper modules — keeping algorithmic logic (e.g. item-calibration formulas, concept-graph traversal) unit-testable in isolation from FastAPI and the database.

Asynchronous Background Work#

Long-running work that should not block an HTTP response runs as Celery tasks routed to named queues. This includes course export, notification delivery, analytics aggregation, and session/upload cleanup. Celery configuration — queue routing, retry policy, and the beat_schedule of periodic tasks — is produced by Settings.get_celery_config() so it stays in the same place as all other service settings.

Direct LLM Provider Calls#

TutoringService calls LLM providers directly through their official SDKs (openai / anthropic), selected by Settings.ai_provider. When no provider key is configured it falls back to a structured pedagogical response. There is no provider-abstraction package and no GPU-dispatch layer — the service keeps its AI dependency surface minimal and explicit.


Correctness Verification & Agentic Media#

Education is a verifiable domain — most of what a lesson asserts can be checked against ground truth — so Metis runs generated content through a verification gate before release and generates teaching media through an author→critic loop rather than emitting it blindly. Both subsystems live in TypeScript libraries (@metis/verification, @metis/agents, @metis/multimedia) with the heavy/dangerous work (Manim rendering) isolated in a Python worker under services/metis.

Verification gate (@metis/verification)#

A VerificationGate composes N independent Verifiers and aggregates their per-claim and per-criterion results into a single release decision — pass | needs-human | block. The gate is fail-loud: a required verifier that is not_configured (no NLI/embedding/PRM backend wired) makes the gate refuse rather than silently pass. composeP0Gate assembles the default P0 panel; runVerifiedGeneration wraps any generator in a generate → verify → (regenerate|refine) → re-gate loop, and runVerifierGuidedGeneration does best-of-N with pessimistic selection. The verifiers:

  • Factuality — atomic claim decomposition (FActScore/SAFE-style) → per-claim evidence retrieval + entailment → aggregation, with claim→source span linking.
  • Faithfulness — TRACe (groundedness / relevance / completeness) over RAG context.
  • Citation sufficiency — every load-bearing statement carries adequate support.
  • STEM correctness — a CAS-lite identity checker for math, a sandboxed JavaScript runner for code, and a worked-solution process verifier that checks each step transition.
  • Pedagogical quality — a judge panel with per-criterion reliability diagnostics (ICC consistency, Spearman alignment, item-total discrimination) and a conformal selective threshold (Clopper–Pearson / Hoeffding / RCPS) that abstains and escalates low-confidence items.
  • Contradiction detection and uncertainty calibration (ECE + histogram calibrator) round out the panel.

Supporting machinery: a hash-bound evidence ledger stamping every verified artifact, an eval harness + gold set with recorded baselines (EVAL_BASELINES.md), a HITL active-learning loop that routes low-confidence / high-disagreement items to humans and feeds labels back into recalibration, telemetry + per-mode compute budgets with a kill-switch, and production hardening — p95 latency/cost ceilings and Welch-z drift + two-proportion-z champion-challenger promotion gating.

Agentic teaching media (@metis/agents + @metis/multimedia)#

Following Code2Video / TheoremExplainAgent, a lesson becomes video through a Planner → Coder → Critic loop (runAgenticMediaLoop): the Planner turns a verified lesson into a temporally coherent scene plan; the Coder compiles each scene to Manim/Python; the scene is rendered; the Critic inspects the rendered layout for overlap/clutter/out-of-frame issues and re-checks any on-screen equation with the Phase-1 math verifier; the loop repairs until a quality+correctness bar or a render budget. Two correctness gates are load-bearing: a block from the lesson verification gate aborts before any media is produced, and the per-scene critic keeps a scene with a wrong on-screen equation from being accepted.

Rendered scenes are assembled into a finished segment by composeMediaSegment: each scene becomes a full-frame background layer on the video/* compositor, synced to a real TTS narration track, with a WebVTT/SRT caption track and a timestamped transcript (and an optional avatar/lip-sync overlay).

Manim render worker (services/metis/src/metis/media/)#

The renderer executes generated Manim/Python, so it is deliberately not mounted in the main API process — arbitrary code execution belongs in an isolated, resource-limited worker. ManimRenderService runs each job in its own working directory via the manim CLI subprocess with a wall-clock timeout + process-group kill, POSIX RLIMIT_CPU/RLIMIT_AS limits, ffprobe duration probing and optional ffmpeg frame extraction, classifying any failure into syntax | runtime | timeout | unknown and failing loud (RendererNotConfiguredError) when no Manim binary is present. It is exposed two interchangeable ways the TS ManimClient can call: a standalone FastAPI job API (manim_render_app, POST /render) and a one-shot stdin/stdout CLI (manim_render_cli, spawned by the multimedia createChildProcessManimTransport). This is distinct from the Yemaya batch-render path used for prerecorded lecture packages — the Manim worker renders agentic teaching animations on the verification path, not GPU-bound lecture batches.


Technology Stack#

The table below lists every significant technology dependency and why it is used. Python 3.11 is required for its asyncio improvements and type-narrowing features that make the strict-mypy configuration practical.

Layer Technology
Language Python 3.11+
Web framework FastAPI + Uvicorn (gunicorn UvicornWorker in prod)
ORM / database SQLAlchemy 2.0 (async, asyncpg) over PostgreSQL
Migrations Alembic
Cache / broker Redis (redis.asyncio)
Task queue Celery (celery[redis])
Object storage S3 / MinIO via boto3
Validation Pydantic 2 + pydantic-settings
Auth JWT via python-jose; bcrypt via passlib
Document parsing pypdf, python-docx, pillow, pytesseract (OCR)
AI providers openai, anthropic SDKs (optional ai extra; also langchain, chromadb)
Observability structlog, OpenTelemetry (API/SDK/OTLP, FastAPI + SQLAlchemy instrumentation)
Email (dev) smtplib → Mailpit
Packaging / build setuptools; uv for fast install in the Docker build
Tooling pytest + pytest-asyncio, ruff, mypy --strict

Deployment#

Metis ships as Docker images. services/metis/docker-compose.yml defines the three process types and wires them to the shared Oshun infrastructure (docker-compose.dev.yml: PostgreSQL, Redis, MinIO, Mailpit). The production container runs gunicorn with four Uvicorn workers behind a /health healthcheck. There is no Metis-specific Terraform, no AWS ECS/CloudFront configuration, and no managed-GPU infrastructure in the repository.


Cross-Domain Integration#

Metis deliberately keeps its external dependencies narrow. It calls LLM providers directly (rather than routing through a shared AI gateway) because the tutoring context window, model selection, and fallback logic are too tightly coupled to pedagogical concerns to be abstracted away. It delegates voice infrastructure, media rendering, and academic-integrity adjudication to specialist domains — Psyche, Yemaya, and Themis respectively — each of which has its own scaling and governance concerns.

  • AI providers — OpenAI / Anthropic, called directly from TutoringService via their SDKs. The boundary exists here because no other Oshun domain shares the same tutoring prompt structure or fallback behaviour.
  • Psyche — provides the live-voice tutoring runtime. Metis provisions a short-lived voice bridge via psyche_* settings and degrades gracefully to text when the runtime is unavailable or breaches latency/fidelity budgets. Psyche owns audio transport and VAD; Metis owns the pedagogical session and the turn-taking state machine.
  • Yemaya — handles batch media rendering. When Metis generates a prerecorded lecture package it labels render batches with the yemaya-batch-render provider and the metis-lecture-render-pipeline pipeline tag. LectureGenerationService produces slide/diagram/notes assets; Yemaya is responsible for the actual rendering workers. The boundary means Metis never runs GPU-bound rendering code directly.
  • Themis — supplies AcademicIntegrityVerdict adjudications for assessment submissions. Metis is the consuming domain: it stores the verdict as submissions.academic_integrity_verdict_json and validates incoming payloads against the canonical Themis contract in schemas/assessment.py (source_of_record = "themis", consuming_domain = "metis"). Themis owns detection and adjudication; Metis owns the learner-facing presentation and appeal routing.

Observability#

Metis is instrumented at three levels so operators can diagnose problems without adding debug logging to production:

  • Structured JSON logging in staging and production (console renderer in development), produced by structlog with request-scoped context vars so a single log line always carries the request ID and user context.
  • Optional OpenTelemetry tracing with an OTLP gRPC exporter and a TraceIdRatioBased sampler, enabled via METIS_OTEL_ENABLED. FastAPI and SQLAlchemy are auto-instrumented; health and readiness endpoints are excluded from traces to avoid noise.
  • Operational endpoints: an X-Request-Time header on every response, /health (liveness check returning service name/version/environment), and /ready (readiness check that verifies PostgreSQL and Redis connectivity, returning 503 if either is unreachable).