Metis is the education and course-generation domain of the Oshun platform. The
production implementation is a Python backend service located at
services/metis/. This document specifies what is actually implemented in that
service — every table, every endpoint, every enumeration, and every
configuration variable — grounded in the real source code.
- Language / runtime: Python 3.11+, FastAPI, SQLAlchemy 2.0 (async), Celery
- Package name:
metis-backend(pyproject.toml), version0.1.0 - Application factory:
metis.main:create_app(services/metis/src/metis/main.py) - API mount prefix: all domain routers mount under
/api - OpenAPI artifact:
services/metis/openapi/metis.openapi.json(OpenAPI 3, 149 path items, 174 operations, 519 component schemas at the time of writing)
Note: a separate, large TypeScript library tree exists at
libs/metis/*. This specification documents the deployed Python service underservices/metis/, which is the runnable Metis backend.
1. Service Architecture#
1.1 Process Topology#
Metis runs as three cooperating process types, all built from the same Docker
image (services/metis/docker-compose.yml). The split between API, worker, and
beat ensures that long-running background jobs cannot starve synchronous HTTP
request handling.
| Process | Command | Role |
|---|---|---|
metis-api |
gunicorn metis.main:app with UvicornWorker (×4) |
FastAPI REST API |
metis-worker |
celery -A metis.tasks.celery_app:celery_app worker |
Celery background task worker |
metis-beat |
celery -A metis.tasks.celery_app:celery_app beat |
Celery periodic-task scheduler |
The production container (services/metis/Dockerfile) is a two-stage Python
3.11-slim build: uv installs dependencies plus gunicorn; the runtime stage
adds curl, tesseract-ocr, and tini, runs as a non-root metis user, and
exposes port 8000 with a /health HEALTHCHECK.
1.2 Application Lifecycle#
The FastAPI application is constructed by a factory function rather than at
module level, which makes it easy to instantiate with different settings in
tests. create_app() configures the FastAPI app with: CORS middleware, an
X-Request-Time timing middleware, structured-logging configuration via
structlog, optional OpenTelemetry instrumentation, exception handlers, health
endpoints, and all API routers. The lifespan context manager initializes the
async database engine and the Redis connection pool on startup and closes them
on shutdown (metis.deps).
docs_url / redoc_url / openapi_url are served in non-production
environments only.
1.3 Infrastructure Dependencies#
The table below lists every external service Metis depends on and the library
used to connect to it. All defaults are calibrated for the local
docker-compose stack; production deployments override each variable via
environment.
| Dependency | Library / driver | Local default (docker-compose) |
|---|---|---|
| PostgreSQL | SQLAlchemy 2.0 + asyncpg | postgresql+asyncpg://oshun:oshun_dev@.../oshun_dev |
| Redis | redis.asyncio |
redis://localhost:6379/0 |
| Celery | celery[redis] |
broker redis://.../1, result backend redis://.../2 |
| Object store | boto3 (S3 / MinIO) |
http://localhost:9000 (minioadmin) |
| Email (dev) | smtplib → Mailpit |
development notification delivery |
The root docker-compose.dev.yml supplies PostgreSQL, Redis, MinIO, and
Mailpit. There is no RunPod, no managed GPU fleet, no AWS ECS/CloudFront
infrastructure, and no Metis-specific Terraform in the repository.
2. Persistence — Relational Schema#
SQLAlchemy ORM models live in services/metis/src/metis/models/. The schema is
created by the Alembic migration alembic/versions/001_initial.py
(revision = "001", create date 2026-03-05), which defines 12 tables.
PostgreSQL is used; pgvector is not used by this service.
2.1 TimestampMixin#
Every table inherits from metis.models.base.TimestampMixin, which provides
three universal columns. Using a mixin rather than repeating these columns
ensures consistent types and default values across the entire schema.
| Column | Type | Notes |
|---|---|---|
id |
UUID (PK, indexed) |
default uuid.uuid4 |
created_at |
DateTime(timezone=True) |
set on insert; server default now() |
updated_at |
DateTime(timezone=True) |
set on insert; onupdate on every UPDATE |
2.2 users#
Platform user accounts (metis.models.user.User). The role column controls
which API endpoints a user can reach; preferences stores the full learner
preferences struct as a JSON-encoded string, keeping the schema simple while
still allowing rich, typed preference access in application code.
| Column | Type | Required | Notes |
|---|---|---|---|
email |
String(255) |
yes | unique, indexed |
name |
String(255) |
yes | display name |
hashed_password |
String(512) |
yes | bcrypt hash (passlib) |
role |
String(50) |
yes | default student; one of student/instructor/admin |
avatar_url |
String(1024) |
no | |
bio |
Text |
no | |
is_active |
Boolean |
yes | default true |
is_verified |
Boolean |
yes | default false |
preferences |
Text |
no | JSON-encoded LearnerPreferences (see §6.2) |
Relationships: authored_courses, enrollments, submissions,
tutoring_sessions, achievements.
2.3 courses#
Top-level course container (metis.models.course.Course). The slug field
provides a human-readable, URL-safe identifier for courses. The status column
implements the three-state lifecycle (draft → published → archived) that
controls which courses appear in public listings.
| Column | Type | Required | Notes |
|---|---|---|---|
title |
String(500) |
yes | indexed |
slug |
String(500) |
yes | unique, indexed |
description |
Text |
no | |
short_description |
String(500) |
no | |
category |
String(100) |
yes | default general, indexed |
difficulty |
String(50) |
yes | default beginner; beginner/intermediate/advanced/expert |
status |
String(50) |
yes | default draft; draft/published/archived |
thumbnail_url |
String(1024) |
no | |
tags |
Text |
no | comma-separated tag list |
estimated_hours |
Float |
no | |
is_featured |
Boolean |
yes | default false |
enrollment_count |
Integer |
yes | default 0 |
average_rating |
Float |
no | |
author_id |
UUID FK→users |
yes | ON DELETE CASCADE, indexed |
Relationships: author, modules (ordered by Module.order), assessments,
enrollments. Modules/assessments/enrollments cascade-delete with the course.
2.4 modules#
Ordered section within a course (metis.models.course.Module). Modules exist to
give instructors a way to group related lessons and publish them incrementally —
a module can be unpublished while its lessons are being drafted.
| Column | Type | Required | Notes |
|---|---|---|---|
title |
String(500) |
yes | |
description |
Text |
no | |
order |
Integer |
yes | default 0 |
is_published |
Boolean |
yes | default false |
estimated_minutes |
Integer |
no | |
course_id |
UUID FK→courses |
yes | ON DELETE CASCADE, indexed |
Relationship: lessons (ordered by Lesson.order, cascade delete).
2.5 lessons#
Atomic learning unit within a module (metis.models.course.Lesson). The
content_type column drives how the content field is interpreted by the
client: markdown text for text type, a video URL for video, and JSON for
interactive, quiz, and code_exercise types. The is_free_preview flag
allows a subset of lessons to be publicly visible before enrollment, which is
used for marketing and learner onboarding.
| Column | Type | Required | Notes |
|---|---|---|---|
title |
String(500) |
yes | |
description |
Text |
no | |
content_type |
String(50) |
yes | default text; text/video/interactive/quiz/code_exercise |
content |
Text |
no | markdown, video URL, or JSON per type |
order |
Integer |
yes | default 0 |
duration_minutes |
Integer |
no | |
is_published |
Boolean |
yes | default false |
is_free_preview |
Boolean |
yes | default false; available without enrollment |
resource_urls |
Text |
no | JSON array of supplementary resource URLs |
module_id |
UUID FK→modules |
yes | ON DELETE CASCADE, indexed |
The content hierarchy is three levels: Course → Module → Lesson. There are
no Section or ContentBlock tables.
2.6 assessments#
Evaluation instrument attached to a course
(metis.models.assessment.Assessment). The max_attempts column set to 0
means unlimited attempts, which is the correct default for practice type
assessments used for self-study.
| Column | Type | Required | Notes |
|---|---|---|---|
title |
String(500) |
yes | |
description |
Text |
no | |
assessment_type |
String(50) |
yes | default quiz; quiz/exam/assignment/practice, indexed |
duration_minutes |
Integer |
no | time limit; NULL = untimed |
passing_score |
Float |
yes | default 70.0; percentage 0–100 |
max_attempts |
Integer |
yes | default 3; 0 = unlimited |
is_published |
Boolean |
yes | default false |
shuffle_questions |
Boolean |
yes | default false |
show_correct_answers |
Boolean |
yes | default true |
available_from |
DateTime(tz) |
no | |
available_until |
DateTime(tz) |
no | |
course_id |
UUID FK→courses |
yes | ON DELETE CASCADE, indexed |
Relationships: questions (ordered by Question.order), submissions.
2.7 questions#
Single question within an assessment (metis.models.assessment.Question).
correct_answer is stored as JSON and is NULL for essay and code questions,
which are left to instructor manual grading.
| Column | Type | Required | Notes |
|---|---|---|---|
content |
Text |
yes | question prompt (markdown) |
question_type |
String(50) |
yes | default multiple_choice; multiple_choice/true_false/short_answer/essay/code |
options |
Text |
no | JSON array of options |
correct_answer |
Text |
no | JSON-encoded; NULL for manually-graded types |
explanation |
Text |
no | shown after answering |
points |
Float |
yes | default 1.0 |
order |
Integer |
yes | default 0 |
difficulty |
String(50) |
yes | default medium; easy/medium/hard |
hint |
Text |
no | |
assessment_id |
UUID FK→assessments |
yes | ON DELETE CASCADE, indexed |
2.8 submissions#
A student's attempt at an assessment (metis.models.assessment.Submission). The
academic_integrity_verdict_json column stores a full
AcademicIntegrityVerdict from the Themis domain as a JSON string, enabling
after-the-fact audits without requiring Metis to join against a remote Themis
database.
| Column | Type | Required | Notes |
|---|---|---|---|
answers |
Text |
yes | JSON map of question_id → answer |
score |
Float |
no | percentage 0–100; NULL until graded |
points_earned |
Float |
no | |
points_possible |
Float |
no | |
passed |
Boolean |
no | meets passing threshold |
attempt_number |
Integer |
yes | default 1 |
started_at |
DateTime(tz) |
no | |
submitted_at |
DateTime(tz) |
no | |
graded_at |
DateTime(tz) |
no | |
grader_notes |
Text |
no | instructor manual-grading notes |
academic_integrity_verdict_json |
Text |
no | JSON AcademicIntegrityVerdict from Themis (see §7.4) |
is_auto_graded |
Boolean |
yes | default true |
assessment_id |
UUID FK→assessments |
yes | ON DELETE CASCADE, indexed |
user_id |
UUID FK→users |
yes | ON DELETE CASCADE, indexed |
2.9 tutoring_sessions#
Tutoring conversation (metis.models.tutoring.TutoringSession). The optional
course_id and lesson_id foreign keys allow a tutoring session to be
contextualised within the content hierarchy — the AI tutor can reference the
specific lesson the learner is working on — but they are nullable so that
open-ended tutoring sessions unrelated to any course are also supported.
| Column | Type | Required | Notes |
|---|---|---|---|
topic |
String(500) |
yes | |
status |
String(50) |
yes | default active; active/completed/abandoned/escalated, indexed |
tutor_type |
String(50) |
yes | default ai; ai/human/hybrid |
ai_model |
String(100) |
no | model used (e.g. gpt-4o, claude-sonnet-4-20250514) |
summary |
Text |
no | AI-generated post-session summary |
satisfaction_rating |
Integer |
no | 1–5 |
tokens_used |
Integer |
yes | default 0 |
estimated_cost |
Float |
yes | default 0.0; USD |
started_at |
DateTime(tz) |
no | |
ended_at |
DateTime(tz) |
no | |
course_id |
UUID FK→courses |
no | ON DELETE SET NULL, indexed |
lesson_id |
UUID FK→lessons |
no | ON DELETE SET NULL |
user_id |
UUID FK→users |
yes | ON DELETE CASCADE, indexed |
Relationship: messages (ordered by created_at).
2.10 tutoring_messages#
One message in a tutoring session (metis.models.tutoring.TutoringMessage). The
sender_type distinguishes who produced the message, which is important for
replay and moderation: a human_tutor message must be preserved as-is during an
escalation handoff, while an ai message may be subject to quality evaluation.
| Column | Type | Required | Notes |
|---|---|---|---|
content |
Text |
yes | markdown |
sender_type |
String(50) |
yes | student/ai/system/human_tutor |
sender_name |
String(255) |
no | |
message_type |
String(50) |
yes | default text; text/code/image/suggestion/feedback |
tokens_used |
Integer |
yes | default 0 |
model_used |
String(100) |
no | |
metadata_json |
Text |
no | JSON metadata |
session_id |
UUID FK→tutoring_sessions |
yes | ON DELETE CASCADE, indexed |
2.11 enrollments#
User ↔ course binding (metis.models.progress.Enrollment). The unique
constraint uq_enrollment_user_course prevents a learner from enrolling in the
same course twice, which would produce ambiguous progress records.
| Column | Type | Required | Notes |
|---|---|---|---|
status |
String(50) |
yes | default active; active/completed/dropped/paused, indexed |
progress_percent |
Float |
yes | default 0.0; overall completion 0–100 |
enrolled_at |
DateTime(tz) |
yes | |
completed_at |
DateTime(tz) |
no | |
last_accessed_at |
DateTime(tz) |
no | |
certificate_url |
String(1024) |
no | S3 URL of completion certificate PDF |
rating |
Integer |
no | 1–5 |
review |
Text |
no | |
user_id |
UUID FK→users |
yes | ON DELETE CASCADE, indexed |
course_id |
UUID FK→courses |
yes | ON DELETE CASCADE, indexed |
Unique constraint uq_enrollment_user_course on (user_id, course_id).
2.12 progress#
Per-lesson completion within an enrollment (metis.models.progress.Progress).
last_position stores an opaque bookmark string — a video timestamp, scroll
offset, or similar — that clients interpret. The unique constraint ensures
exactly one progress row exists per (enrollment, lesson) pair.
| Column | Type | Required | Notes |
|---|---|---|---|
completed |
Boolean |
yes | default false |
completed_at |
DateTime(tz) |
no | |
time_spent_seconds |
Integer |
yes | default 0; cumulative |
last_position |
String(255) |
no | bookmark (video timestamp, scroll) |
notes |
Text |
no | learner's private notes |
enrollment_id |
UUID FK→enrollments |
yes | ON DELETE CASCADE, indexed |
lesson_id |
UUID FK→lessons |
yes | ON DELETE CASCADE, indexed |
Unique constraint uq_progress_enrollment_lesson on (enrollment_id,
lesson_id).
2.13 achievements#
Gamification achievement (metis.models.progress.Achievement). The unique
constraint uq_achievement_user_type_name ensures each achievement can only be
earned once — a learner cannot accumulate duplicate "course completion" badges
for the same course.
| Column | Type | Required | Notes |
|---|---|---|---|
achievement_type |
String(100) |
yes | course_completion/streak/perfect_score/first_enrollment/milestone, indexed |
name |
String(255) |
yes | |
description |
Text |
no | |
icon_url |
String(1024) |
no | |
points |
Integer |
yes | default 10 |
earned_at |
DateTime(tz) |
yes | |
metadata_json |
Text |
no | JSON additional context |
user_id |
UUID FK→users |
yes | ON DELETE CASCADE, indexed |
Unique constraint uq_achievement_user_type_name on (user_id,
achievement_type, name).
2.14 File-Backed Persistence#
Several feature areas — source ingestion, lecture packages, credentials, and agent-runtime artifacts — produce large JSON documents that are better stored in object storage rather than relational tables. Relational tables are optimised for querying and joining; these documents are written once, read by ID, and rarely queried by field values.
SourceIngestionService writes to a configurable source_ingestion_storage_dir
(default /tmp/metis-source-ingestion) and can issue presigned S3 upload URLs
against the metis-uploads bucket.
3. API Surface#
All routers are aggregated in metis.api.__init__.api_router and mounted at
/api. Two unprefixed infrastructure endpoints exist on the app root and are
intentionally outside the /api prefix so that health checks can be invoked
without API authentication:
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Liveness; returns service name/version/environment |
| GET | /ready |
Readiness; checks PostgreSQL + Redis connectivity |
The sixteen domain routers and their prefixes are:
| Prefix | Router module | Tag |
|---|---|---|
/api/auth |
api/auth.py |
auth |
/api/courses |
api/courses.py |
courses |
/api/assessments |
api/assessments.py |
assessments |
/api/tutoring |
api/tutoring.py |
tutoring |
/api/analytics |
api/analytics.py |
analytics |
/api/admin |
api/admin.py |
admin |
/api/curriculum |
api/curriculum.py |
curriculum |
/api/source-ingestion |
api/source_ingestion.py |
source-ingestion |
/api/credentials |
api/credentials.py |
credentials |
/api/agent-runtime |
api/agent_runtime.py |
agent-runtime |
/api/lti |
api/lti.py |
lti |
/api/caliper |
api/caliper.py |
caliper |
/api/qti |
api/qti.py |
qti |
/api/scorm |
api/scorm.py |
scorm |
/api/xapi |
api/xapi.py |
xapi |
/api/oneroster |
api/oneroster.py |
oneroster |
3.1 Authentication — /api/auth#
These five endpoints handle the complete authentication lifecycle. Registration
and login both return a token pair; subsequent requests use the access_token
as a bearer header. The refresh endpoint lets clients extend their session
without re-entering credentials.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /register |
none | Register a user; returns a token pair (201) |
| POST | /login |
none | Authenticate; returns a token pair |
| POST | /refresh |
none | Exchange a refresh token for a new pair |
| GET | /me |
bearer | Current user profile with parsed preferences |
| PATCH | /me |
bearer | Update name/bio/avatar/preferences |
3.2 Courses — /api/courses#
The courses router is the core of the Metis content hierarchy. Public listing and detail endpoints require no authentication so that unenrolled visitors can browse the catalog; mutation endpoints require the instructor role.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `` (root) | none | List/filter/search/paginate courses |
| POST | `` (root) | instructor | Create a course (201) |
| GET | /{course_id} |
none | Get a course with nested modules + concept graph |
| PATCH | /{course_id} |
instructor | Update course fields |
| DELETE | /{course_id} |
instructor | Delete a course (204) |
| GET | /{course_id}/concept-graph |
none | Learner-facing course-linked concept graph |
| POST | /{course_id}/lecture-package |
instructor | Generate a prerecorded lecture package (201) |
| GET | /{course_id}/lecture-package |
bearer | Latest lecture package |
| GET | /{course_id}/lecture-package/assets/{asset_path} |
bearer | Fetch a rendered lecture asset file |
| POST | /{course_id}/lecture-package/evaluation |
instructor | Evaluate lecture media quality |
| GET | /{course_id}/lecture-package/evaluation |
instructor | Latest lecture media evaluation |
| POST | /{course_id}/modules |
instructor | Add a module (201) |
| PATCH | /{course_id}/modules/{module_id} |
instructor | Update a module |
| DELETE | /{course_id}/modules/{module_id} |
instructor | Delete a module (204) |
| POST | /{course_id}/modules/{module_id}/lessons |
instructor | Add a lesson (201) |
| PATCH | /{course_id}/modules/{module_id}/lessons/{lesson_id} |
instructor | Update a lesson |
| DELETE | /{course_id}/modules/{module_id}/lessons/{lesson_id} |
instructor | Delete a lesson (204) |
| POST | /{course_id}/enroll |
bearer | Enroll the current user (201) |
| POST | /{course_id}/progress/{lesson_id} |
bearer | Update lesson progress |
3.3 Assessments — /api/assessments#
Assessment endpoints mirror the course pattern: public read, instructor-only mutation, and learner-scoped submission. The calibration and fairness endpoints are further restricted to the course author or an admin because psychometric data about individual learners must not leak to other students.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `` (root) | none | List assessments (filter course/type/published) |
| POST | `` (root) | instructor | Create an assessment (201) |
| GET | /{assessment_id} |
none | Get an assessment with nested questions |
| PATCH | /{assessment_id} |
instructor | Update an assessment |
| DELETE | /{assessment_id} |
instructor | Delete an assessment (204) |
| POST | /{assessment_id}/questions |
instructor | Add a question (201) |
| PATCH | /{assessment_id}/questions/{question_id} |
instructor | Update a question |
| DELETE | /{assessment_id}/questions/{question_id} |
instructor | Delete a question (204) |
| POST | /{assessment_id}/submit |
bearer | Submit answers; auto-grades (201) |
| GET | /{assessment_id}/submissions |
bearer | Current user's submissions |
| GET | /{assessment_id}/calibration |
author/admin | Item-calibration + mastery-threshold report |
| POST | /{assessment_id}/calibration/recompute |
author/admin | Recompute calibration from live data |
| GET | /{assessment_id}/fairness-review |
author/admin | Fairness + DIF review |
| POST | /{assessment_id}/fairness-review/recompute |
author/admin | Recompute fairness/DIF review |
3.4 Tutoring — /api/tutoring#
All tutoring endpoints require at least bearer authentication because tutoring
sessions are private to the learner. The voice-runtime endpoints
(voice-runtime, voice-runtime/events, voice-runtime/resume) interact with
the Psyche live- voice runtime and carry additional Psyche-specific payload
fields.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /sessions |
bearer | List the user's sessions (filter status) |
| POST | /sessions |
bearer | Start a session (201) |
| GET | /sessions/{session_id} |
bearer | Get a session with messages |
| PATCH | /sessions/{session_id} |
bearer | Update status/rating/summary |
| POST | /sessions/{session_id}/messages |
bearer | Send a student message (201) |
| POST | /sessions/{session_id}/generate-response |
bearer | Generate an AI tutor reply (201) |
| POST | /sessions/{session_id}/voice-runtime |
bearer | Provision/refresh a live voice bridge |
| POST | /sessions/{session_id}/voice-runtime/events |
bearer | Record turn-taking/interruption events |
| POST | /sessions/{session_id}/voice-runtime/resume |
bearer | Resume a paused/interrupted voice runtime |
| POST | /sessions/{session_id}/delivery-health |
bearer | Refresh delivery fallback from health signals |
| POST | /sessions/{session_id}/quality-evaluation |
bearer | Evaluate live tutoring embodiment quality |
| GET | /sessions/{session_id}/quality-evaluation |
bearer | Latest tutoring quality evaluation |
3.5 Analytics — /api/analytics#
Analytics endpoints operate on different scopes: the learner dashboard and reminders are scoped to the authenticated user; course analytics are scoped to the course author or admin; platform analytics are admin-only. Redis caching is applied to the two most expensive aggregations (dashboard and platform).
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /dashboard |
bearer | Personalized learner dashboard (Redis-cached) |
| GET | /reminders |
bearer | Learner reminders / continuation notifications |
| GET | /achievements |
bearer | The user's achievements |
| GET | /course/{course_id} |
author/admin | Per-course analytics (enrollment, completion) |
| GET | /platform |
admin | Platform-wide analytics (Redis-cached) |
3.6 Admin — /api/admin#
The admin router contains 30 operations, all requiring the admin role. It
covers user management, course approval, content moderation, review queues,
incident and complaint workspaces, monitoring and alerting, and source-rights
decisions. Operations include: /dashboard, /analytics,
/metis-v1-launch-gate, /audit-trail, /incidents/workspace,
/complaints/workspace, /complaints/{id}/action, /users (list/detail),
/users/{id}/status (PATCH), /users/{id}/role (PATCH),
/users/{id}/learner-actions (POST), /courses (list/detail),
/courses/{id}/approval (POST), /content/flags and
/content/flags/{id}/moderate, /moderation/incidents and
/moderation/incidents/{id}/decision, /monitoring, /operator-inspections,
/alerts and /alerts/{id} (PATCH), /review-queues (overview / list / detail
/ decision / workflow-action), and /source-rights (workspace / decision).
3.7 Curriculum — /api/curriculum#
Read-mostly curriculum-discovery endpoints backed by in-code catalogs. Because
the curriculum taxonomy is versioned and edited deliberately (not generated at
runtime), these endpoints serve static or near-static data and do not need
database writes: /subject-taxonomy and /subject-taxonomy/{subject_id},
/subject-taxonomy/lookup (POST), /subject-routes (POST), /seed-packs
(+/{discipline_id}), /standards-mappings (+/{discipline_id}),
/reviewer-pools (+/{discipline_id}), /gold-sets (+
/release-gate-dashboard, +/{discipline_id}), /cross-core-interlocks
(+/{interlock_id}), /safety-policies
(+/{discipline_id}, +/{discipline_id}/evaluate POST).
3.8 Source Ingestion — /api/source-ingestion#
The source-ingestion router exposes the full lifecycle of a source package —
upload, extract, review, generate, maintain. Each stage builds on the previous
one; generation endpoints reject requests until the package has been extracted
and approved. The full endpoint set: /uploads (POST presigned targets),
package CRUD
(``POST/GET,/{package_id}GET),/{package_id}/extract(POST/GET),/{package_id}/review(POST),/{package_id}/evaluate(POST),/{package_id}/outline(POST/GET),/{package_id}/generate(POST/GET),/{package_id}/concepts/graph(POST/GET),/concepts/explore(POST),/concepts/graph/validate(POST),/concepts/graph/repair(POST),/{package_id}/refresh(POST/GET),/{package_id}/retrieval-benchmark(POST/GET),/{package_id}/connectors/preview(POST/GET),/study-workspace(POST),/retrieval/algorithm-docs
(GET).
3.9 Credentials — /api/credentials#
Credential endpoints issue, verify, and revoke Open Badges 3.0 and CLR
documents. The /verify POST endpoint accepts a full credential document body,
which enables QR-code scan flows where the verifier has the credential but no
persistent URL: /badges/issue (POST, 201), /badges (GET list),
/badges/{credential_id} (GET), /badges/{credential_id}/verify (GET),
/badges/{credential_id}/revoke (POST), /verify (POST a presented
credential), /clr/{learner_id} (GET).
3.10 Agent Runtime — /api/agent-runtime#
All agent-runtime endpoints require the instructor role. The router manages the
full lifecycle of durable agent orchestration plans, from registration and
scheduling through evaluation and safety screening: /registry (GET),
/orchestration-plans (POST/GET by build_id), /events (GET),
/graph-indexes (POST/GET), /output-verifications (POST/GET), /rollouts
(POST/GET), /evaluation/gold-sets (GET), /evaluation/rubric-scores
(POST/GET), /safety-screenings (POST/GET), /research-integrity/adjudications
(POST/GET).
3.11 LMS Interoperability#
These six routers implement the standard protocols that allow Metis to interoperate with institutional LMS platforms. The auth pattern differs by protocol — LTI uses signed JWTs, SCORM uses token-protected runtime state, xAPI and Caliper use learner-scoped bearer tokens.
| Prefix | Endpoints |
|---|---|
/api/lti |
LTI 1.3 / Advantage: /platforms (POST), /launch (POST), /token (POST), /deep-linking/response (POST), /names-and-roles/{context_id} (GET), /assignments/line-items (POST), /assignments/scores (POST) |
/api/qti |
QTI 3 item bank: /item-bank/import (POST), /item-bank/export (POST) |
/api/scorm |
SCORM 1.2/2004 fallback: /packages (POST), /packages/{id}/manifest (GET), /runtime/commit (POST), /runtime/{package_id}/attempts/{learner_id} (GET) |
/api/xapi |
xAPI / cmi5: /statements (POST/GET), /replay (GET) |
/api/caliper |
IMS Caliper: /events (POST/GET), /export (GET) |
/api/oneroster |
OneRoster: /sync (POST, admin) — dry-run or apply a rostering payload |
4. Authentication & Authorization#
Authentication is defined in metis.deps. A new engineer should read this
section alongside the API tables above: every endpoint annotated bearer uses
get_current_user, instructor uses get_current_instructor_user, and admin
uses get_current_admin_user.
- Scheme: HTTP bearer token. JWT decoding uses
python-jose; the algorithm and secret come from settings (jwt_algorithmdefaultHS256). - Token claims read:
sub(user id, required),email,role,name,exp. Missingsubor an expiredexpyields HTTP 401. CurrentUser: lightweight object withid,email,role,nameand helpersis_admin()(roleadmin) andis_instructor()(roleinstructororadmin).- Role dependencies:
get_current_user(any authenticated),get_current_instructor_user(instructor/admin, else 403),get_current_admin_user(admin only, else 403). - Token issuance:
AuthServiceissues an access token (jwt_access_token_expire_minutes, default 30) and refresh token (jwt_refresh_token_expire_days, default 7).TokenResponsereturnsaccess_token,refresh_token,token_type(bearer), andexpires_in. - Resource-level checks: several endpoints additionally enforce ownership (e.g. tutoring sessions are accessible only by the owning user; assessment calibration/fairness require the course author or an admin; credentials/xAPI/Caliper restrict learner scope to the caller unless the caller is an instructor).
5. Background Tasks (Celery)#
Celery configuration is built by Settings.get_celery_config() (metis.config)
and applied in metis.tasks.celery_app. All task modules and their queue
assignments are declared in task_routes so that the worker can be scaled
per-queue independently — for example, running more metis.export workers
during a large course-export batch without adding capacity to the metis.ai
queue.
5.1 Queues#
task_routes map task modules onto named queues: metis.export,
metis.notifications, metis.analytics, metis.cleanup, metis.ai. The
default queue is metis.default. task_default_retry_delay is 60s and
task_max_retries is 3. The metis-worker container consumes all six queues.
5.2 Periodic Tasks (beat_schedule)#
These three tasks run on a fixed schedule driven by metis-beat and require no
HTTP trigger. The hourly analytics aggregation keeps the Redis-cached dashboard
data fresh; the daily cleanup tasks prevent orphaned data from accumulating.
| Task | Interval |
|---|---|
metis.tasks.analytics.aggregate_analytics |
hourly (3600 s) |
metis.tasks.cleanup.cleanup_expired_sessions |
daily (86400 s) |
metis.tasks.cleanup.cleanup_orphaned_uploads |
daily (86400 s) |
5.3 Task Modules#
Each task module is routed to its own named queue to allow independent scaling. The descriptions below summarize the work each module performs:
tasks/export.py—export_courseexports a full course to S3 injsonorscormformat (unknown formats fall back to JSON).tasks/notifications.py—send_notificationdelivers viaemail(SMTP → Mailpit in dev),push, orinappchannels; plussend_enrollment_welcomeandsend_grade_notification.tasks/analytics.py—aggregate_analytics.tasks/cleanup.py—cleanup_expired_sessions,cleanup_orphaned_uploads,cleanup_expired_cache.tasks/celery_app.py— Celery app construction and prerun/postrun/failure signal handlers, including a dead-letter routing handler for permanently failed tasks.
6. Key Domain Objects & Enumerations (Pydantic Schemas)#
Pydantic schemas live in services/metis/src/metis/schemas/. There are 511
BaseModel classes across the package — the full set of request bodies,
response shapes, and domain value objects. The subsections below document the
most important enumerations and structural types. Field-level validation is
expressed via Pydantic Field constraints and regex patterns.
6.1 Validation Patterns (request schemas)#
Every request schema enforces domain constraints at the HTTP boundary using
Pydantic Field constraints and regex patterns, so invalid values are rejected
before any service code runs. The table below lists the most important
constraints:
| Schema field | Constraint |
| ------------------------------------ | ---------------------------------- | ------------- | ------------ | ----------- | ---------------- |
| CourseCreate.slug | regex ^[a-z0-9]+(?:-[a-z0-9]+)*$ |
| CourseCreate.difficulty | regex ^(beginner | intermediate | advanced | expert)$ |
| CourseUpdate.status | regex ^(draft | published | archived)$ |
| LessonCreate.content_type | regex ^(text | video | interactive | quiz | code_exercise)$ |
| AssessmentCreate.assessment_type | regex ^(quiz | exam | assignment | practice)$ |
| AssessmentCreate.passing_score | ge=0, le=100 |
| QuestionCreate.question_type | regex ^(multiple_choice | true_false | short_answer | essay | code)$ |
| QuestionCreate.difficulty | regex ^(easy | medium | hard)$ |
| RegisterRequest.password | min_length=8, max_length=128 |
| RegisterRequest.role | regex ^(student | instructor)$ |
| EnrollmentUpdate.status | regex ^(active | completed | dropped | paused)$ |
| EnrollmentUpdate.rating | ge=1, le=5 |
| TutoringSessionCreate.tutor_type | regex ^(ai | human | hybrid)$ |
| TutoringMessageCreate.message_type | regex ^(text | code | image | suggestion | feedback)$ |
6.2 LearnerPreferences (schemas/user.py)#
LearnerPreferences is serialized to JSON and stored in users.preferences. It
captures every dimension of how a learner wants to be taught and supervised,
from learning pace to oversight context. The model_post_init validator
enforces cross-field invariants: for example, the independent oversight
context automatically clears all oversight roles and sharing flags so a learner
cannot accidentally appear supervised.
The key typed unions within LearnerPreferences are:
LearnerStudyGoal—foundational-mastery,exam-prep,career-advancement,project-delivery,curiosityLearnerPace—light,steady,acceleratedLearnerContentType—video,reading,exercise,quiz,tutoringLearnerTutorStyle—step-by-step,socratic,direct,direct-explanation,scaffolded-hints,exam-practice,mentor-coachingLearnerExplanationDepth—concise,balanced,deepLearnerOversightContext—independent,minor-supervised,managed-programLearnerOversightRole—teacher,guardian,institutionLearnerReminderDay—monday…sunday
Bounded numeric fields: weekly_hours_target (1–40), session_minutes
(10–180), study_reminder_hour_local (0–23), assessment_reminder_lead_hours
(1–168). Field validators normalize aliases and clamp list lengths.
6.3 Tutoring Delivery & Live Voice (schemas/tutoring.py)#
The tutoring schema layer is the most complex in Metis because it models three different delivery channels (text, voice, avatar), a multi-stage fallback state machine, a full voice turn-taking lifecycle, and teacher representation safety governance — all as typed Pydantic unions.
The key literal-union types are:
TutoringIntegrityMode—teach,hint,practice,do-not-complete-for-meTutoringDeliveryMode—text,live_voice,live_avatarTutoringDeliveryFallbackState—native,fallbackTutoringDeliveryFallbackReason— six reasons covering avatar/voice runtime unavailability and latency/fidelity budget failuresTutoringLiveVoiceTransportState—not_requested,provisioning,ready,active,paused,ended,failedTutoringLiveVoiceTurnState—idle,learner_listening,learner_speaking,processing,tutor_speaking,interrupted,resume_readyTutoringLiveVoiceRuntimeEventType— 10 runtime event types (speech start/stop, response start/stop, interruption, connection loss, pause/resume, partial/final transcript)TutoringTeacherRepresentationReleaseStatus—draft,review,approved,restricted,revoked,retiredTutoringTeacherRepresentationConsentStatus—not-required,pending,granted,expired,revoked,blocked
TutoringSessionResponse is a rich response object carrying the session's
delivery_fallback (TutoringDeliveryFallbackResponse), live_voice
(TutoringLiveVoiceRuntimeResponse), teacher_representation
(TutoringTeacherRepresentationSafetyResponse), an append-only consent_log,
oversight context/roles, and optional escalation/handoff fields.
6.4 Source Ingestion (schemas/source_ingestion.py)#
Source ingestion schemas model the four ways a package can be created and the
four scopes to which it can be attached. The discriminated union on
SourceScope uses the level field as the discriminator so that Pydantic can
deserialize the correct subtype without ambiguity.
- Ingestion input kinds:
FileIngestionInput,UrlIngestionInput,FeedIngestionInput(feed_format:rss/atom/jsonfeed/unknown),PackageIngestionInput(package_type:scorm/imscc/lti_export/institutional_archive/zip_bundle). SourceScopeis a discriminated union overCourseSourceScope,WorkspaceSourceScope,NotebookSourceScope,LearnerSessionSourceScope(discriminatorlevel).SourcePackageReviewStateResponse.status—pending,approved,changes_requested,rejected;risk_level—low/medium/high/critical.SourcePackageUsagePolicyResponse.usage—approved,review_only,blocked. Grounded outline/generation endpoints requirehigh_stakes_allowedand review statusapproved.
6.5 Concept Graph (schemas/source_concept_graph.py)#
The concept graph schema models a directed graph of educational concepts extracted from a source package. Nodes are typed by their epistemic role; edges are typed by the pedagogical relationship between concepts. This structure is used for prerequisite chains, graph-aware evidence retrieval, and learning-path generation.
ConceptNodeType—topic,skill,fact,procedure,principleConceptEdgeType—prerequisite,related,part_of,generalizes,specializes,enables,conflicts,complements- Validation issue
severity—warning,critical; concept-graph repair operations are a bounded set of operator-approved mutations.
6.6 Assessment Calibration & Fairness (schemas/assessment.py)#
These schemas surface the psychometric analysis that Metis computes from live learner submission data. They are returned only to course authors and admins because they contain aggregate learner statistics.
AssessmentCalibrationResponse reports per-question calibration
(QuestionCalibrationResponse: difficulty index, IRT-style difficulty,
discrimination index, sample sizes) and a MasteryThresholdCalibrationResponse
(current vs. recommended threshold, balanced accuracy/precision/recall/F1).
AssessmentFairnessReviewResponse reports DIF reviews (ItemDIFReviewResponse,
recommended action monitor/recalibrate/retire) and adaptation-parity
reviews. These are computed from live learner submission data, not from an
external psychometrics service.
6.7 Credentials (schemas/credentials.py)#
Credential schemas implement the Open Badges 3.0 and IMS Global CLR standards as Pydantic models, ensuring that every issued credential is a conformant JSON-LD document that any standards-compliant verifier can check.
CredentialType—open_badge,clr;CredentialStatus—active,expired,revoked;VerificationStatus—valid,invalid,expired,revoked,not_found.CredentialProofuses aDataIntegrityProofwith cryptosuiteHMAC-SHA256-2026, a canonical hash, and aproof_value.- Issued credentials carry Open Badges 3.0 (
open_badge) and optional CLR (clr) JSON-LD documents with IMS Global context URLs.
6.8 Agent Runtime (schemas/agent_runtime.py)#
Agent runtime schemas model the lifecycle metadata of autonomous agents and the
execution plans they follow. The kill-switch fallback behaviors (pause,
skip_stage, text_only, manual_review) are the safety levers that let
operators degrade an agent plan gracefully rather than aborting it entirely.
Agent definitions carry literal-union metadata: AgentLifecycleState
(active/experimental/deprecated/disabled), AgentReleaseChannel
(stable/canary/shadow), AgentCostClass, AgentTrustZone
(public/tenant/restricted/operator), AgentRoleVisibility, and
AgentFamily. Orchestration plans produce a DAG (AgentDagNodeResponse /
AgentDagEdgeResponse), a replay manifest, approval gates, and kill switches
(AgentKillSwitchResponse with fallback behaviors pause/skip_stage/
text_only/manual_review). Safety screenings and research-integrity
adjudications return pass/review/blocked-style statuses.
6.9 Lecture Packages (schemas/lecture.py)#
Lecture package schemas capture both the generation request (what the instructor
wants produced) and the generated result (per-lesson script, captions, slides,
render batches, and export bundles). The subtitle_formats, dubbing_locales,
and pronunciation_overrides fields are what enable accessible, multi-language
lecture delivery.
LectureGenerationRequest controls prerecorded lecture generation:
target_locale, voice_pack_id, presentation_template, caption/transcript
toggles, subtitle_formats (vtt/srt), dubbing_locales,
pronunciation_overrides, and include_scorm_bundle.
CourseLecturePackageResponse returns per-lesson packages with script sections,
caption cues, slides, diagrams, notes, narration variants, render batches, and
export bundles (with provenance and rights metadata).
7. Cross-Domain Integration#
Metis integrates with four external systems. Each integration has a clear owner of the boundary contract: Metis owns the schema mirroring and consuming-side validation; the upstream domain owns adjudication, rendering, and transport.
7.1 AI Providers#
TutoringService (services/tutoring_service.py) calls LLM providers directly
via their official SDKs. Settings.ai_provider selects openai or anthropic;
the corresponding SDK (openai.AsyncOpenAI / anthropic.AsyncAnthropic) is
used with openai_model (default gpt-4o) or anthropic_model (default
claude-sonnet-4-20250514). The ai extras group in pyproject.toml provides
openai, anthropic, langchain, and chromadb. If no provider key is
configured, tutoring falls back to a structured pedagogical response. There is
no provider-abstraction layer and no RunPod/GPU dispatch in this service.
7.2 Psyche (Live Voice Runtime)#
Psyche owns audio transport and voice-activity detection. Metis owns the
tutoring session and the pedagogical turn-taking state machine. The live-voice
tutoring runtime is provisioned through Psyche-namespaced settings
(psyche_voice_runtime_provider default psyche-openai-realtime,
psyche_voice_runtime_public_url, token TTLs, latency/fidelity budgets).
Tutoring sessions can request live_voice / live_avatar delivery and degrade
to text via the TutoringDeliveryFallback mechanism when Psyche is unavailable
or fails to meet latency/fidelity budgets.
7.3 Yemaya (Lecture Rendering)#
Yemaya owns GPU-bound media rendering. Metis orchestrates what to render and
labels the batches with provider yemaya-batch-render and pipeline
metis-lecture-render-pipeline. In the current service the
LectureGenerationService and lecture_render_pipeline_helpers produce
rendered slide/diagram/notes assets and write asset files to local storage; the
course lecture-asset endpoint serves those files back. The labeling convention
is the contract point: Yemaya workers watch for batches with this
provider/pipeline tag to claim and execute them.
7.4 Themis (Academic Integrity)#
Themis owns detection and adjudication; Metis owns the learner-facing
presentation and appeal routing. Assessment submissions can carry an
AcademicIntegrityVerdict produced by Themis adjudication, stored as
submissions.academic_integrity_verdict_json and surfaced as
SubmissionResponse.academic_integrity_verdict. The
AcademicIntegrityVerdictResponse schema (schemas/assessment.py) mirrors the
canonical Themis contract, including detection signals, classifier outputs,
evidence excerpts, a decision with appeal path, policy binding, and governance
metadata. A model_validator enforces audit-completeness invariants (e.g.
violation verdicts require detection signals and evidence excerpts; severe
verdicts require human review; clear verdicts cannot recommend sanctions).
Governance fields fix source_of_record = "themis" and
consuming_domain = "metis".
8. Configuration#
All settings load from METIS_-prefixed environment variables via
pydantic-settings (metis.config.Settings). The sections below list every
variable with its default value. In local development all defaults work with the
docker-compose.dev.yml stack; in staging/production each variable must be
explicitly set.
8.1 Application#
| Variable | Default |
|---|---|
METIS_APP_NAME |
metis-backend |
METIS_APP_VERSION |
0.1.0 |
METIS_ENVIRONMENT |
development (development/staging/production/testing) |
METIS_DEBUG |
false |
METIS_LOG_LEVEL |
INFO |
METIS_HOST / METIS_PORT |
0.0.0.0 / 8000 |
METIS_ALLOWED_ORIGINS |
localhost:3000/4200/5173 |
8.2 Database#
A postgresql:// URL is auto-rewritten to postgresql+asyncpg:// for the async
engine. The sync_database_url property strips the +asyncpg driver suffix so
Alembic (which uses a synchronous connection) receives a compatible URL.
| Variable | Default |
|---|---|
METIS_DATABASE_URL |
postgresql+asyncpg://oshun:oshun_dev@localhost:5432/oshun_dev |
METIS_DATABASE_POOL_SIZE |
20 |
METIS_DATABASE_MAX_OVERFLOW |
10 |
METIS_DATABASE_POOL_TIMEOUT |
30 |
METIS_DATABASE_POOL_RECYCLE |
1800 |
8.3 Redis#
Redis serves three separate purposes in Metis: application cache (database 0), Celery broker (database 1), and Celery result backend (database 2). Keeping them on separate Redis logical databases prevents cache eviction from interfering with task queue state.
| Variable | Default |
|---|---|
METIS_REDIS_URL |
redis://localhost:6379/0 |
METIS_REDIS_PREFIX |
metis: |
METIS_REDIS_DEFAULT_TTL |
3600 |
METIS_REDIS_SESSION_TTL |
86400 |
METIS_REDIS_CACHE_TTL |
900 |
METIS_REDIS_RATE_LIMIT_TTL |
60 |
METIS_REDIS_MAX_CONNECTIONS |
20 |
8.4 Object Storage (S3 / MinIO)#
Metis uses four S3 buckets, each serving a distinct purpose: course content, rendered assets, exported packages, and staged uploads. Presigned URLs are used for both upload staging (source ingestion) and download delivery (lecture assets, exports).
| Variable | Default |
|---|---|
METIS_S3_ENDPOINT |
http://localhost:9000 |
METIS_S3_ACCESS_KEY / SECRET_KEY |
minioadmin / minioadmin |
METIS_S3_REGION |
us-east-1 |
METIS_S3_BUCKET_COURSES |
metis-courses |
METIS_S3_BUCKET_ASSETS |
metis-assets |
METIS_S3_BUCKET_EXPORTS |
metis-exports |
METIS_S3_BUCKET_UPLOADS |
metis-uploads |
METIS_S3_PRESIGNED_URL_EXPIRY |
3600 |
METIS_SOURCE_INGESTION_STORAGE_DIR |
/tmp/metis-source-ingestion |
8.5 Authentication#
The METIS_JWT_SECRET default is intentionally weak and must be replaced in any
non-development environment. The access token expiry of 30 minutes is
deliberately short; the 7-day refresh token allows clients to maintain sessions
without re-authentication.
| Variable | Default |
|---|---|
METIS_JWT_SECRET |
metis-dev-secret-change-in-production |
METIS_JWT_ALGORITHM |
HS256 |
METIS_JWT_ACCESS_TOKEN_EXPIRE_MINUTES |
30 |
METIS_JWT_REFRESH_TOKEN_EXPIRE_DAYS |
7 |
8.6 Celery#
Task soft and hard time limits (300s / 600s) prevent runaway tasks from tying up
workers indefinitely. worker_prefetch_multiplier = 1 ensures each worker
fetches at most one task at a time, which is important for long-running export
and AI tasks that should not starve short notification tasks.
| Variable | Default |
|---|---|
METIS_CELERY_BROKER_URL |
redis://localhost:6379/1 |
METIS_CELERY_RESULT_BACKEND |
redis://localhost:6379/2 |
METIS_CELERY_TASK_SOFT_TIME_LIMIT |
300 |
METIS_CELERY_TASK_TIME_LIMIT |
600 |
METIS_CELERY_WORKER_PREFETCH_MULTIPLIER |
1 |
8.7 AI Providers#
METIS_AI_PROVIDER selects between openai and anthropic. If neither API key
is set the service starts successfully but tutoring sessions fall back to a
structured pedagogical response rather than a live LLM response.
| Variable | Default |
|---|---|
METIS_OPENAI_API_KEY |
(empty) |
METIS_OPENAI_MODEL |
gpt-4o |
METIS_OPENAI_EMBEDDING_MODEL |
text-embedding-3-small |
METIS_ANTHROPIC_API_KEY |
(empty) |
METIS_ANTHROPIC_MODEL |
claude-sonnet-4-20250514 |
METIS_AI_PROVIDER |
openai |
METIS_AI_MAX_TOKENS |
4096 |
METIS_AI_TEMPERATURE |
0.7 |
8.8 Live Voice Runtime#
These variables configure the Psyche live-voice bridge. The latency and fidelity
budget values are the thresholds at which the delivery-fallback mechanism
triggers: if the voice runtime exceeds METIS_PSYCHE_VOICE_LATENCY_BUDGET_MS or
falls below METIS_PSYCHE_VOICE_MIN_FIDELITY_SCORE, tutoring degrades from
voice to text automatically and a learner-visible disclosure is emitted.
| Variable | Default |
|---|---|
METIS_PSYCHE_VOICE_RUNTIME_PROVIDER |
psyche-openai-realtime |
METIS_PSYCHE_VOICE_RUNTIME_PUBLIC_URL |
wss://voice.metis.local/live |
METIS_PSYCHE_VOICE_RUNTIME_TOKEN_TTL_SECONDS |
900 |
METIS_PSYCHE_VOICE_LATENCY_BUDGET_MS |
900 |
METIS_PSYCHE_VOICE_MIN_FIDELITY_SCORE |
0.72 |
METIS_PSYCHE_AVATAR_LATENCY_BUDGET_MS |
1400 |
METIS_PSYCHE_AVATAR_MIN_FIDELITY_SCORE |
0.8 |
8.9 Observability & Rate Limiting#
OpenTelemetry is opt-in (METIS_OTEL_ENABLED = false by default) to keep
development startup fast. METIS_OTEL_TRACES_SAMPLER_RATIO = 1.0 means all
traces are recorded when OTel is enabled — reduce this in high-traffic
production environments to control span volume.
| Variable | Default |
|---|---|
METIS_OTEL_ENABLED |
false |
METIS_OTEL_SERVICE_NAME |
metis-backend |
METIS_OTEL_EXPORTER_ENDPOINT |
http://localhost:4317 |
METIS_OTEL_TRACES_SAMPLER_RATIO |
1.0 |
METIS_RATE_LIMIT_REQUESTS_PER_MINUTE |
60 |
METIS_RATE_LIMIT_BURST |
10 |
8.10 Notifications#
Push notifications are optional. If METIS_PUSH_NOTIFICATION_ENDPOINT_URL is
empty the push channel in send_notification is a no-op; email and in-app
channels still work.
| Variable | Default |
|---|---|
METIS_PUSH_NOTIFICATION_ENDPOINT_URL |
(empty) |
METIS_PUSH_NOTIFICATION_API_KEY |
(empty) |
METIS_PUSH_NOTIFICATION_TIMEOUT_SECONDS |
5.0 |
METIS_PUSH_NOTIFICATION_OUTBOX_TTL_SECONDS |
30 days |
9. Observability#
Metis provides three layers of operational visibility, designed so that a problem can be spotted in Grafana (tracing), diagnosed in log aggregation (structured logs), or checked instantly from a load balancer (health endpoints).
- Structured logging:
structlogwith a console renderer in development/testing and a JSON renderer in staging/production. Context vars are merged so request-scoped fields propagate across log lines. - Tracing: OpenTelemetry is optional (
otel_enabled). When enabled,_setup_opentelemetryconfigures an OTLP gRPC span exporter, aTraceIdRatioBasedsampler, a resource with service name/version/environment, and auto-instruments FastAPI (excludinghealth/ready). SQLAlchemy instrumentation is available via the installedopentelemetry-instrumentation-sqlalchemypackage. - Request timing: every response carries an
X-Request-Timeheader. - Health/readiness:
/health(liveness) and/ready(verifies database and Redis; returns 503 if either is down). - Error handling:
RequestValidationErrorreturns a structured 422 with per-field detail; unhandled exceptions log a full traceback and return a generic 500.
10. Quality Standards#
These standards define the bar that every change to Metis must meet before it is
considered shippable. The OpenAPI contract test is particularly important: it
prevents accidental schema drift between the live service and the exported
metis.openapi.json artifact that downstream consumers depend on.
- Type checking:
mypyinstrictmode with thepydantic.mypyplugin (pyproject.toml);tests/build/dist/alembicare excluded. - Linting:
ruffwith rule setsE,W,F,I,B,C4,UP,ARG,SIM,N, line length 100,target-version = py311. - Testing:
pytest+pytest-asyncio(asyncio_mode = "auto"); test files underservices/metis/tests/. Markers:slow,integration,unit. - Coverage: branch coverage on
src/metis,fail_under = 80. - OpenAPI contract:
scripts/export_openapi.pyandmetis.openapi_contractsrender a deterministic, key-sorted OpenAPI document with a canonical root-key order;tests/test_openapi_contract.pyguards it against drift.
11. Acceptance Criteria#
A change to Metis is acceptance-ready when all six conditions below are satisfied. These criteria exist because each one guards a different class of regression: type errors, logic bugs, schema drift, coverage gaps, auth holes, and cross-domain contract violations.
mypy --strictandruffpass forservices/metis/src.pytestpasses, including the OpenAPI contract test (tests/test_openapi_contract.py) and the export-script test (tests/test_export_openapi_script.py).- New ORM columns are accompanied by an Alembic migration and matching Pydantic request/response schemas.
- Branch coverage stays at or above 80%.
- New endpoints declare explicit auth dependencies (
get_current_user,get_current_instructor_user, orget_current_admin_user) and enforce resource-level ownership where applicable. - Cross-domain payloads (Themis verdicts, Psyche runtime events, Yemaya render
results) conform to the schemas in
services/metis/src/metis/schemas.
12. Correctness Verification Subsystem#
The correctness-verification subsystem lives in the TypeScript library
@metis/verification (with claim/media agents in @metis/agents). It is
consumed by content generation and gates release of generated lessons. Unlike
the Python backend specced above, it is library code; the contract below
describes its types and decision algebra.
12.1 Gate decision algebra#
Verifier.verify(content, context) → VerifierResultreturns a normalizedscore(0–1), a calibratedconfidence, apassedflag, an optionalcritique, and may return{ notConfigured: true }when its backing model is absent.VerificationGatecomposes verifiers (markedrequiredor advisory) and aggregates to aGateDecisionofpass | needs-human | block:- any required verifier that is
notConfiguredor fails →block(fail-loud; the gate never silently passes a missing required check); - low aggregate confidence or high judge disagreement →
needs-human; - otherwise →
pass.
- any required verifier that is
composeP0Gate(config)builds the default panel;runVerifiedGenerationdrivesgenerate → verify → (regenerate|refine) → re-gateup to a bound;runVerifierGuidedGenerationscores N candidates and selects pessimistically.
12.2 Verifiers#
| Verifier | Method |
|---|---|
| Factuality | atomic claim decomposition → evidence retrieval → entailment → aggregate; claim→source span linking |
| Faithfulness (TRACe) | groundedness / relevance / completeness over RAG context |
| Citation sufficiency | per-statement support adequacy |
| Math correctness | CAS-lite expression evaluation + identity testing |
| Code correctness | sandboxed JavaScript execution against declared cases/checks |
| Process (worked steps) | per-transition validity of an equation chain |
| Pedagogical judge panel | rubric scoring + ICC consistency / Spearman alignment / discrimination |
| Selective evaluation | conformal threshold (Clopper–Pearson exact, Hoeffding, RCPS) → abstain |
| Contradiction | negation / value-mismatch heuristic + optional NLI seam |
| Uncertainty calibration | expected calibration error + histogram calibrator |
| Pedagogy (curriculum) | Flesch–Kincaid reading level, prerequisite order, misconception scan |
12.3 Supporting machinery#
- Evidence ledger —
buildEvidenceRecordstamps run id, model, content sha256, per-claim verdicts, judge scores, disagreement, and the gate decision;verifyEvidenceBindingdetects tampering. - Eval harness —
scoreVerifiercomputes precision/recall/F1, Cohen's κ, and calibration error against a versioned JSONL gold set; recorded baselines live inEVAL_BASELINES.md(e.g. the lexical-default factuality baseline: P 0.516 / R 1.0 / F1 0.681 / κ 0.0625 / MAE 0.464, motivating the NLI swap). - HITL —
selectForReviewroutes low-confidence / high-disagreement items to a versionedFeedbackStore; labels feed judge calibration, the selective threshold, and per-criterion reliability. - Budgets & hardening —
ComputeBudgetMeter+MODE_BUDGETSper call;checkBudgetCeilingsasserts p95 latency + total cost againstMODE_CEILINGSand recommends a kill-switch;monitorAgreementDrift(Welch z) andevaluateVerifierPromotion(two-proportion z) gate champion-challenger promotion.
13. Agentic Media & Manim Render Service#
The agentic teaching-media pipeline (@metis/agents Planner/Coder/Critic +
@metis/multimedia loop/compositor) produces verified animations; the rendering
itself runs in a sandboxed Python worker under
services/metis/src/metis/media/.
13.1 Author→critic loop#
runAgenticMediaLoop(lesson, seams, config) executes: verify lesson (a block
aborts before any media) → plan scenes → for each scene
{ code → render → critique → (repair) }* until the critic score ≥
minSceneScore or maxIterationsPerScene / maxTotalRenders is hit → optional
learning-outcome probe → release decision. The renderer, planner, coder, critic,
lesson verifier, and outcome probe are all injected seams.
13.2 Manim render service contract#
ManimRenderService.render(job) executes generated Manim code out-of-process:
- Request (
RenderJob/ wireManimRenderRequest):sceneName,code,resolution {width,height},fps,format(mp4|webm|mov|gif),outputFrames,timeoutMs. - Sandbox: a per-job working directory; the
manimCLI is spawned in its own session with a wall-clock timeout (whole process-group killed on expiry) and POSIXRLIMIT_CPU/ optionalRLIMIT_ASlimits; the timeout is clamped to a service ceiling. - Response (
RenderResult/ wireManimRenderResponse): on success a video path, ffprobe-measureddurationSeconds, and optional ffmpeg-extractedframePaths; on failure a structured error of kindsyntax | runtime | timeout | unknownwith stderr + offending line — never a thrown crash. With no Manim binary the service raisesRendererNotConfiguredError(fail-loud, never a fabricated artifact). - Transports: a standalone FastAPI app (
manim_render_app,POST /render,GET /healthz) and a one-shot stdin/stdout CLI (manim_render_cli); the TSManimClientreaches either throughcreateHttpManimTransportorcreateChildProcessManimTransport. The worker requires therenderextra (manim) plus theffmpeg/ffprobesystem binaries (and a LaTeX toolchain for MathTex).
13.3 Segment composition#
composeMediaSegment(plan, rendered, options) assembles a finished segment: one
full-frame VideoCompositor background layer per rendered scene, laid
back-to-back; one real TTS narration AudioSegment per scene, retimed to its
window; each scene window widened to fit the longest of {planned,
rendered-video, narration} duration so video and speech stay synced; a
WebVTT/SRT caption track and a timestamped transcript; and an optional
avatar/lip-sync picture-in-picture overlay. A planned scene with no rendered
artifact raises MissingSceneRenderError rather than compositing a gap.