4. Backend API Architecture
Status: v1. Complete backend structure for
apps/skillnet-api/. Aligns with data-model.md, screens.md, and architecture.md.
4.1 Project Layout
apps/skillnet-api/
├── pyproject.toml # uv project, dependencies
├── alembic.ini # DB migrations config
├── alembic/
│ ├── env.py
│ └── versions/ # Migration files
│
├── src/
│ ├── __init__.py
│ ├── main.py # FastAPI app factory, lifespan, middleware
│ ├── config.py # Pydantic Settings (env vars)
│ │
│ ├── auth/ # Authentication (fastapi-users)
│ │ ├── __init__.py
│ │ ├── backend.py # CookieTransport + session strategy
│ │ ├── manager.py # UserManager (create, verify, etc.)
│ │ ├── schemas.py # UserRead, UserCreate, UserUpdate
│ │ └── router.py # /auth/login, /auth/logout, /auth/me
│ │
│ ├── models/ # SQLAlchemy ORM models (1 file per table)
│ │ ├── __init__.py # Re-exports all models
│ │ ├── base.py # DeclarativeBase, common mixins (TimestampMixin, UUIDMixin)
│ │ ├── organization.py
│ │ ├── user.py # Extends fastapi-users SQLAlchemy model
│ │ ├── document.py
│ │ ├── document_chunk.py
│ │ ├── skill.py
│ │ ├── skill_category.py
│ │ ├── course.py
│ │ ├── module.py
│ │ ├── lesson.py
│ │ ├── exercise.py
│ │ ├── skill_checkpoint.py
│ │ ├── manual.py
│ │ ├── enrollment.py
│ │ ├── exercise_attempt.py
│ │ ├── user_skill.py
│ │ ├── spaced_repetition.py
│ │ ├── generation_job.py
│ │ ├── course_feedback.py
│ │ ├── chat_session.py
│ │ ├── chat_message.py
│ │ ├── background_job.py
│ │ ├── user_session.py
│ │ ├── audit_log.py
│ │ ├── api_key.py
│ │ ├── webhook.py
│ │ └── webhook_delivery.py
│ │
│ ├── repositories/ # Data access layer (async SQLAlchemy queries)
│ │ ├── __init__.py
│ │ ├── base.py # BaseRepository[T] — generic CRUD
│ │ ├── user_repo.py
│ │ ├── document_repo.py
│ │ ├── course_repo.py # Includes nested module/lesson/exercise loading
│ │ ├── enrollment_repo.py
│ │ ├── exercise_attempt_repo.py
│ │ ├── skill_repo.py # Skills + categories + user_skills + matrix query
│ │ ├── manual_repo.py
│ │ ├── spaced_repetition_repo.py
│ │ ├── generation_job_repo.py
│ │ ├── course_feedback_repo.py
│ │ ├── document_chunk_repo.py # Vector similarity search
│ │ ├── chat_session_repo.py
│ │ ├── chat_message_repo.py
│ │ ├── background_job_repo.py
│ │ ├── user_session_repo.py
│ │ ├── audit_log_repo.py
│ │ ├── api_key_repo.py
│ │ ├── webhook_repo.py
│ │ └── webhook_delivery_repo.py
│ │
│ ├── services/ # Business logic (no DB or HTTP awareness)
│ │ ├── __init__.py
│ │ ├── user_service.py # Invite, bulk invite, deactivate
│ │ ├── document_service.py # Upload to disk, trigger processing
│ │ ├── course_service.py # CRUD, publish/archive lifecycle
│ │ ├── enrollment_service.py # Assign, progress calc, complete
│ │ ├── exercise_service.py # Grade attempt, auto-check, AI-graded
│ │ ├── skill_service.py # Matrix, mentorship suggestions, verify_skill
│ │ ├── manual_service.py # CRUD, linked to course
│ │ ├── spaced_repetition_service.py # HLR algorithm, due reviews, submit review
│ │ ├── generation_service.py # Orchestrate generation pipeline
│ │ ├── chat_service.py # Tutor + admin chat (RAG + streaming)
│ │ ├── alert_service.py # Compute alerts from progress data
│ │ ├── stats_service.py # Dashboard aggregations
│ │ └── feedback_service.py # Submit + revision report generation
│ │
│ ├── routes/ # FastAPI routers (thin — validate, call service, return)
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── users.py
│ │ ├── documents.py
│ │ ├── courses.py # Includes /generate, /publish, /archive actions
│ │ ├── modules.py # Nested under courses
│ │ ├── lessons.py # Nested under modules
│ │ ├── exercises.py # Includes /attempt action
│ │ ├── enrollments.py
│ │ ├── skills.py # Categories, skills, matrix, mentorship
│ │ ├── manuals.py
│ │ ├── chat.py # SSE endpoints (tutor + admin)
│ │ ├── spaced_repetition.py
│ │ ├── generation_jobs.py
│ │ ├── stats.py # /stats, /alerts
│ │ ├── settings.py # Org settings, LLM config
│ │ └── feedback.py
│ │
│ ├── schemas/ # Pydantic models for request/response
│ │ ├── __init__.py
│ │ ├── common.py # Pagination, ErrorResponse, SuccessMessage
│ │ ├── user.py
│ │ ├── document.py
│ │ ├── course.py # Includes nested module/lesson/exercise schemas
│ │ ├── enrollment.py
│ │ ├── exercise.py # AttemptRequest, AttemptResponse, by exercise type
│ │ ├── skill.py # SkillMatrix, MentorshipSuggestion
│ │ ├── manual.py
│ │ ├── chat.py # ChatMessage, ChatEvent (SSE)
│ │ ├── spaced_repetition.py
│ │ ├── generation_job.py
│ │ ├── stats.py # DashboardStats, Alert
│ │ ├── settings.py
│ │ └── feedback.py
│ │
│ ├── deps/ # FastAPI dependency injection
│ │ ├── __init__.py
│ │ ├── db.py # get_async_session
│ │ ├── auth.py # current_user, current_active_user, require_admin
│ │ ├── llm.py # get_llm_client
│ │ └── embedding.py # get_embedding_service
│ │
│ ├── llm/ # LLM integration (provider-agnostic)
│ │ ├── __init__.py
│ │ ├── client.py # AsyncOpenAI wrapper, reads env vars
│ │ ├── prompts/ # Prompt templates as .py or .txt
│ │ │ ├── tutor_system.py
│ │ │ ├── admin_system.py
│ │ │ ├── grading.py # Exercise evaluation prompts
│ │ │ └── generation.py # Course/manual generation prompts
│ │ └── embedding.py # Embedding service (API or local model)
│ │
│ ├── agents/ # LangGraph agent definitions (deferred — Phase 2+)
│ │ ├── __init__.py
│ │ ├── content_agent.py # Multi-step generation pipeline
│ │ └── tutor_agent.py # Conversational RAG tutor
│ │
│ └── core/ # Shared utilities
│ ├── __init__.py
│ ├── exceptions.py # App-specific exception classes
│ ├── security.py # Password hashing, cookie config
│ └── pagination.py # Offset pagination helper
│
├── tests/
│ ├── conftest.py # Fixtures: async client, test DB, test user
│ ├── factories.py # Factory Boy factories for test data
│ ├── test_auth.py
│ ├── test_users.py
│ ├── test_courses.py
│ ├── test_enrollments.py
│ ├── test_exercises.py
│ ├── test_skills.py
│ ├── test_chat.py
│ └── test_spaced_repetition.py
│
├── uploads/ # Uploaded documents (Docker volume mount)
└── Dockerfile
Layer responsibilities:
| Layer | What it does | What it does NOT do |
|---|---|---|
| routes/ | Parse HTTP, validate input (Pydantic), call service, return response | Business logic, DB queries |
| services/ | Business rules, orchestration, algorithm logic | SQL, HTTP concerns |
| repositories/ | SQL queries via SQLAlchemy, return model instances | Business logic, HTTP |
| models/ | ORM table mapping, relationships | Business logic, validation |
| schemas/ | Request/response validation, serialization | DB awareness |
| deps/ | Inject shared resources into route handlers | Business logic |
Data flow: HTTP request -> route -> service -> repository -> database and back. Each layer only talks to its immediate neighbor.
4.2 API Endpoints
All endpoints prefixed with /api/v1. Authentication via session cookie on every request (except login).
Auth
| Method | Path | Role | Description |
|---|---|---|---|
POST |
/auth/login |
public | Email + password. Sets httpOnly session cookie (7-day expiry). Returns user data + role |
POST |
/auth/logout |
authenticated | Deletes session. Clears cookie |
GET |
/auth/me |
authenticated | Returns current user (id, email, full_name, role, learning_profile, accessibility) plus the deployment’s workspace_mode |
Login response redirects by role: employee -> /dashboard, admin -> /admin. The frontend reads the role from /auth/me on page load.
Workspace mode. /auth/me (and /settings) also carry workspace_mode
(organization | individual; see audience-modes.md). In an
individual deployment the collective, organization-only endpoints — employees
(list/create/reset), talent, /stats, course assignment (POST/DELETE /enrollments, folder assign) and the skills catalogue — return 404 via the
require_organization_workspace dependency: those concepts do not exist in a
personal workspace. This is server-side enforcement; the SPA additionally hides
the sections as UX.
Users
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/users |
admin | List all users in org. Supports ?search=, ?role=, ?is_active=. Returns summary stats (active courses count, skill coverage %) |
GET |
/users/{id} |
admin | Single user detail with skills, enrollments, activity |
POST |
/users/invite |
admin | Create employee account. Body: {email, full_name}. Generates temp password or invite link |
POST |
/users/invite/bulk |
admin | CSV upload (name, email columns). Returns created count + errors |
PUT |
/users/{id} |
admin | Update user (name, role, is_active). Admin can deactivate |
GET |
/users/me |
authenticated | Current user profile |
PUT |
/users/me |
authenticated | Update own profile (full_name, learning_profile, accessibility). Cannot change role or email |
PUT |
/users/me/password |
authenticated | Change own password. Body: {current_password, new_password} |
GET |
/users/me/today |
employee | “What’s due today”: spaced repetition reviews, next course action, recommendation. Max 3 items |
GET |
/users/me/skills |
employee | Own skill levels grouped by category |
GET |
/users/me/activity |
employee | Recent activity (exercise attempts, lessons completed). Paginated, default last 20 |
Learner profile (personalization)
Router src/routes/learner_profile.py, prefix /users/me/learner-profile. See
personalization.md.
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/users/me/learner-profile |
authenticated | Read the learner profile, incl. learning_note and learning_preferences |
PATCH |
/users/me/learner-profile |
authenticated | Update editable fields incl. the free-text learning_note (max 500 chars, normalized; steers form not facts). Writing it drops that learner’s render pins |
DELETE |
/users/me/learner-profile |
authenticated | Clear the profile |
Media artifacts
Router src/routes/media.py, prefix /media. See media-artifacts.md.
| Method | Path | Role | Description |
|---|---|---|---|
POST |
/media/artifacts |
generator | Enqueue one media job. Body MediaArtifactCreate incl. kind, scope (node|course|standalone) and a personalization note. Returns 202 {artifact_id, status}. Permission via can_generate_artifacts |
GET |
/media/artifacts |
authenticated | List artefacts. Query course_id (required), node_id, include_nodes. Three shapes: one node / all course / course-level only |
GET |
/media/artifacts/{id} |
authenticated | Single artefact |
GET |
/media/artifacts/{id}/stream |
authenticated | SSE on channel media:{id}: media_step events then terminal media_done/media_error |
GET |
/media/artifacts/{id}/asset |
authenticated | Rendered asset bytes, or 404 |
GET |
/media/artifacts/{id}/asset/{ref} |
authenticated | One sub-asset by content-hash (ref must be a sha256 the spec lists) |
Course schema (create flow)
Router src/routes/course_schema.py. Admin course-schema lifecycle: propose -> PUT (spawn
packs) -> review -> validate -> prewarm. See create-course-flow
and learning-experience-architecture.md.
| Method | Path | Role | Description |
|---|---|---|---|
POST |
/courses/{course_id}/schema/propose |
admin | 202 + job_id. Propose a schema draft |
PUT |
/courses/{course_id}/schema |
admin | Persist the edited schema; spawns knowledge packs (bounded retry max_attempts=3); bumping schema_version supersedes any in-flight run for an earlier version |
POST |
/courses/{course_id}/schema/review |
admin | Bulk-mark every non-archived node human-reviewed (the wizard calls it right before validate) |
POST |
/courses/{course_id}/schema/nodes/{node_id}/review |
admin | Mark one node reviewed. reviewed_at is a serving precondition; render returns 409 node_not_reviewed otherwise |
POST |
/courses/{course_id}/schema/validate |
admin | Validate the schema; on commit spawns background prewarm of the first nodes’ shared renders |
Documents
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/documents |
admin | List uploaded documents. Supports ?status= filter |
POST |
/documents |
admin | Upload file (multipart/form-data). Saves to disk, creates DB record with status=pending |
GET |
/documents/{id} |
admin | Document metadata + processing status |
DELETE |
/documents/{id} |
admin | Delete document and its chunks (CASCADE) |
POST |
/documents/{id}/process |
admin | Trigger ingestion pipeline: parse, chunk, embed. Updates status through processing -> ready or error |
Courses
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/courses |
admin | List all courses. Supports ?status=draft,published,archived |
POST |
/courses |
admin | Create empty course shell. Body: {title, description, outcome, source_document_id?} |
GET |
/courses/{id} |
authenticated | Full course with modules, lessons, exercises (nested). Employee sees only if enrolled |
PUT |
/courses/{id} |
admin | Update course metadata (title, description, outcome) |
DELETE |
/courses/{id} |
admin | Delete course (only if status=draft and no enrollments) |
POST |
/courses/{id}/generate |
admin | Trigger AI generation from source document. Creates generation_job. Returns job_id for polling |
POST |
/courses/{id}/publish |
admin | Set status=published. Validates: title, outcome, at least 1 module with 1 lesson |
POST |
/courses/{id}/archive |
admin | Set status=archived. Active enrollments marked completed |
Modules
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/courses/{course_id}/modules |
authenticated | List modules for course, ordered by position |
POST |
/courses/{course_id}/modules |
admin | Create module. Body: {title, summary, position} |
PUT |
/courses/{course_id}/modules/{id} |
admin | Update module (title, summary, position) |
DELETE |
/courses/{course_id}/modules/{id} |
admin | Delete module (CASCADE deletes lessons + exercises) |
PUT |
/courses/{course_id}/modules/reorder |
admin | Batch reorder. Body: {module_ids: [uuid, uuid, ...]} |
Lessons
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/courses/{course_id}/modules/{module_id}/lessons |
authenticated | List lessons in module, ordered by position |
POST |
/courses/{course_id}/modules/{module_id}/lessons |
admin | Create lesson. Body: {title, content, position} |
PUT |
/courses/{course_id}/modules/{module_id}/lessons/{id} |
admin | Update lesson |
DELETE |
/courses/{course_id}/modules/{module_id}/lessons/{id} |
admin | Delete lesson (CASCADE deletes exercises) |
Exercises
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/courses/{cid}/modules/{mid}/lessons/{lid}/exercises |
authenticated | List exercises in lesson |
POST |
/courses/{cid}/modules/{mid}/lessons/{lid}/exercises |
admin | Create exercise. Body: {type, content, position} |
PUT |
/exercises/{id} |
admin | Update exercise content or type |
DELETE |
/exercises/{id} |
admin | Delete exercise |
POST |
/exercises/{id}/attempt |
employee | Submit answer. Body varies by type (see below). Returns {score, passed, feedback, explanation} |
GET |
/exercises/{id}/attempts |
authenticated | Attempt history for this exercise by current user. Admin can add ?user_id= to see any user |
Attempt request body by exercise type:
test: { "selected": 1 }
true_false: { "answer": true }
fill_blank: { "answers": ["unused", "tags"] }
order_steps: { "order": [0, 2, 1, 3] }
practical_case: { "response": "I would explain the 30-day policy..." }
dialogue: { "messages": [{"role": "user", "content": "..."}] }
For test, true_false, fill_blank, order_steps: server grades deterministically (compare against correct answer in exercise content).
For practical_case: server sends response + rubric to LLM for evaluation. Returns score + per-criteria feedback.
For dialogue: server runs multi-turn conversation via LLM with the system_prompt from exercise content. Evaluation after max_turns reached.
Enrollments
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/enrollments |
authenticated | Employee: own enrollments. Admin: all enrollments. Supports ?status=, ?user_id= (admin), ?course_id= |
POST |
/enrollments |
admin | Assign course to user(s). Body: {user_ids: [uuid], course_id, deadline?} |
GET |
/enrollments/{id} |
authenticated | Enrollment detail with progress: modules completed, current position, score |
DELETE |
/enrollments/{id} |
admin | Remove enrollment (only if status=assigned, not started) |
POST |
/enrollments/{id}/complete |
system | Mark enrollment completed. Auto-triggered when all modules done. Updates score, completed_at |
Progress calculation: completed_modules / total_modules. A module is complete when all its lessons’ exercises have at least one passing attempt.
Skills
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/skills/categories |
admin | List skill categories with skills |
POST |
/skills/categories |
admin | Create category. Body: {name, position} |
PUT |
/skills/categories/{id} |
admin | Update category (name, position) |
DELETE |
/skills/categories/{id} |
admin | Delete category (only if no skills assigned) |
GET |
/skills |
admin | List all skills. Supports ?category_id= |
POST |
/skills |
admin | Create skill. Body: {name, description, category_id?} |
PUT |
/skills/{id} |
admin | Update skill |
DELETE |
/skills/{id} |
admin | Delete skill (only if no user_skills or checkpoints reference it) |
GET |
/skills/matrix |
admin | Full skills matrix: rows=employees, columns=skills, cells=level. Supports ?category_id= filter |
GET |
/skills/mentorship-suggestions |
admin | Auto-detected pairs: user with high level + user with low level on same skill |
POST |
/skills/verify |
admin | Manually verify a skill. Body: {user_id, skill_id, level, verifier_id?}. Sets source=manual |
GET |
/skills/{id}/users |
admin | Users who have this skill and their levels |
GET |
/skills/gaps |
admin | Skills where no one (or too few) has high level. Body: ?threshold= for minimum coverage |
Skill Checkpoints
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/courses/{course_id}/checkpoints |
admin | List skill checkpoints for course |
POST |
/courses/{course_id}/checkpoints |
admin | Create checkpoint. Body: {skill_id, module_id, target_level} |
PUT |
/courses/{course_id}/checkpoints/{id} |
admin | Update checkpoint |
DELETE |
/courses/{course_id}/checkpoints/{id} |
admin | Delete checkpoint |
When a module is completed, the system checks for checkpoints and updates user_skills accordingly. Level never decreases from checkpoints (only admin override can lower it).
Chat (SSE)
| Method | Path | Role | Description |
|---|---|---|---|
POST |
/chat |
employee | Send message to tutor. Body: {message, context?: {course_id?, lesson_id?}}. Returns SSE stream |
POST |
/chat/admin |
admin | Send message to admin assistant. Body: {message}. Returns SSE stream |
GET |
/chat/sessions |
authenticated | List current user’s chat sessions. Paginated. Returns {id, title, created_at, last_message_at} |
GET |
/chat/sessions/{id}/messages |
authenticated | Get messages for a session. Paginated. Returns [{role, content, created_at, citations?}] |
DELETE |
/chat/sessions/{id} |
authenticated | Delete a chat session and its messages |
SSE protocol:
- Client sends
POST /chatwith message body - Server returns
Content-Type: text/event-stream - Server streams events:
event: token
data: {"content": "The"}
event: token
data: {"content": " return"}
event: citations
data: {"citations": [{"document": "Manual_Devoluciones.pdf", "section": "Plazos", "page": 3}]}
event: suggestions
data: {"prompts": ["What is the return window?", "How do I process a refund?"]}
event: done
data: {"message_id": "uuid"}
event: error
data: {"message": "Model unavailable"}
The tutor chat uses RAG: query is embedded, top-k chunks retrieved from document_chunks, included in the LLM context alongside the employee’s current course/lesson context.
The admin chat has access to organization-wide data (employees, skills, enrollments) for operational queries.
Manuals
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/manuals |
authenticated | Employee: manuals from enrolled courses + standalone published manuals. Admin: all manuals |
POST |
/manuals |
admin | Create manual. Body: {title, content, source_document_id?, course_id?} |
GET |
/manuals/{id} |
authenticated | Full manual content. Employee must have access (enrolled in linked course or manual is standalone + published) |
PUT |
/manuals/{id} |
admin | Update manual content |
DELETE |
/manuals/{id} |
admin | Delete manual (only if status=draft) |
POST |
/manuals/{id}/publish |
admin | Publish manual |
GET |
/manuals/{id}/search |
authenticated | Search within manual content. Query param: ?q= |
Spaced Repetition
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/spaced-repetition/due |
employee | Exercises due for review. Returns max 5, ordered by urgency (most overdue first) |
POST |
/spaced-repetition/review |
employee | Submit review answer. Body: {exercise_id, answer}. Grades answer, updates half-life, schedules next review |
GET |
/spaced-repetition/stats |
employee | Review stats: total reviews, streak, next review date |
The HLR algorithm runs on submit:
- Correct:
half_life *= 2,review_count += 1 - Incorrect:
half_life /= 2,review_count += 1 next_review_at= now + time untilP(forget) > 0.3
Generation Jobs
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/generation-jobs |
admin | List generation jobs. Supports ?status= |
GET |
/generation-jobs/{id} |
admin | Job detail with current step, timestamps, error message if failed |
POST |
/generation-jobs/{id}/retry |
admin | Retry failed job from last successful step |
DELETE |
/generation-jobs/{id} |
admin | Cancel pending/running job |
GET |
/generation-jobs/{id}/review |
admin | Get pending review data (generated content awaiting approval) |
POST |
/generation-jobs/{id}/review |
admin | Submit review decision. Body: `{action: “approve” |
GET |
/generation-jobs/{id}/progress |
admin | SSE stream of real-time generation progress events |
PUT |
/generation-jobs/{id}/content |
admin | Inline content editing. Body: {modules: [...]}. Updates generated content before approval |
POST |
/generation-jobs/{id}/regenerate-module/{idx} |
admin | Regenerate a single module by index. Body: {feedback?} |
Generation pipeline steps: pending -> extracting -> structuring -> generating -> reviewing -> published or failed.
Each step updates the job status. The frontend polls GET /generation-jobs/{id} to show progress.
Course Feedback
| Method | Path | Role | Description |
|---|---|---|---|
POST |
/courses/{id}/feedback |
employee | Submit post-course feedback. Body: `{hardest_section, free_text, difficulty: “easy” |
GET |
/courses/{id}/feedback |
admin | All feedback for course. Returns individual responses + aggregated report |
GET |
/courses/{id}/feedback/report |
admin | AI-generated revision report: problematic sections, user quotes, difficulty stats, suggested changes |
Stats & Alerts
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/stats |
admin | Dashboard summary: total employees, active courses, critical gaps count, employees needing attention |
GET |
/alerts |
admin | Active alerts (max 10). Types: deadline approaching with 0% progress, consecutive failures (3+), certificate expiring, new employee with no courses, skill decay |
Each alert includes:
{
"type": "deadline_risk",
"severity": "high",
"message": "Carlos has 0% progress on 'Returns' — deadline in 3 days",
"action_url": "/admin/users/{user_id}",
"related_ids": {"user_id": "...", "enrollment_id": "..."}
}
Alerts are computed on request (not stored). The service queries enrollments, attempts, and spaced_repetition to detect conditions.
Organization Settings
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/settings |
admin | Current org settings (name, self-registration flag, LLM config status) |
PUT |
/settings |
admin | Update org settings. Body: {name?, self_registration_enabled?} |
PUT |
/settings/llm |
admin | Update LLM config. Body: {base_url, api_key, model}. Validates connection before saving |
POST |
/settings/llm/test |
admin | Test LLM connection without saving. Returns success/error |
Users — Invitations
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/users/invitations |
admin | List pending invitations. Supports ?status=pending,accepted,expired |
Health
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/health |
public | System health check. Returns `{status: “ok”, version, database: “connected” |
Admin — Background Jobs
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/admin/jobs |
admin | List background jobs. Supports ?status=, ?type=. Returns {id, type, status, started_at, completed_at, error?} |
LLM settings are stored in organizations.settings jsonb. Env vars (LLM_BASE_URL, LLM_API_KEY, LLM_MODEL) are the defaults; org-level settings override them.
4.3 Dependency Injection
FastAPI’s Depends() system provides shared resources to route handlers. All dependencies are defined in src/deps/.
Database Session
# src/deps/db.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from src.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
yield session
# Type alias for route signatures
DBSession = Annotated[AsyncSession, Depends(get_async_session)]
Current User (from session cookie)
fastapi-users provides the session cookie backend. The dependency chain extracts the user from the cookie automatically.
# src/deps/auth.py
from fastapi_users import FastAPIUsers
from src.auth.backend import auth_backend
from src.auth.manager import get_user_manager
from src.models.user import User
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
# Base dependencies (provided by fastapi-users)
current_user = fastapi_users.current_user(active=True)
current_optional_user = fastapi_users.current_user(active=True, optional=True)
# Type aliases
CurrentUser = Annotated[User, Depends(current_user)]
Role-Based Access
# src/deps/auth.py (continued)
from fastapi import HTTPException, status
def require_admin(user: CurrentUser) -> User:
if user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return user
def require_employee(user: CurrentUser) -> User:
if user.role != "employee":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Employee access required",
)
return user
# Type aliases for route signatures
AdminUser = Annotated[User, Depends(require_admin)]
EmployeeUser = Annotated[User, Depends(require_employee)]
Usage in routes:
# src/routes/courses.py
@router.get("/courses")
async def list_courses(user: AdminUser, db: DBSession):
...
@router.post("/exercises/{id}/attempt")
async def attempt_exercise(user: EmployeeUser, db: DBSession, ...):
...
@router.get("/courses/{id}")
async def get_course(user: CurrentUser, db: DBSession, ...):
# Both roles can access, but employee sees only if enrolled
...
LLM Client
# src/deps/llm.py
from openai import AsyncOpenAI
from src.config import settings
async def get_llm_client(db: DBSession) -> AsyncOpenAI:
"""Returns an AsyncOpenAI client configured from org settings or env vars."""
# Check org-level override first
org = await db.execute(select(Organization).limit(1))
org_settings = org.scalar_one().settings
base_url = org_settings.get("llm_base_url") or settings.LLM_BASE_URL
api_key = org_settings.get("llm_api_key") or settings.LLM_API_KEY
return AsyncOpenAI(base_url=base_url, api_key=api_key)
LLMClient = Annotated[AsyncOpenAI, Depends(get_llm_client)]
The AsyncOpenAI client works with any OpenAI-compatible API. The model name is resolved similarly (org setting > env var) and passed per-call, not at client creation.
Embedding Service
# src/deps/embedding.py
from src.llm.embedding import EmbeddingService
async def get_embedding_service(db: DBSession) -> EmbeddingService:
"""Returns embedding service (API-based or local model)."""
org = await db.execute(select(Organization).limit(1))
org_settings = org.scalar_one().settings
return EmbeddingService(
base_url=org_settings.get("embedding_base_url") or settings.EMBEDDING_BASE_URL,
api_key=org_settings.get("embedding_api_key") or settings.EMBEDDING_API_KEY,
model=org_settings.get("embedding_model") or settings.EMBEDDING_MODEL,
)
EmbeddingSvc = Annotated[EmbeddingService, Depends(get_embedding_service)]
Dependency Composition Example
A route handler composes what it needs:
@router.post("/chat")
async def tutor_chat(
user: EmployeeUser,
db: DBSession,
llm: LLMClient,
embeddings: EmbeddingSvc,
body: ChatMessageRequest,
):
service = ChatService(db, llm, embeddings)
return StreamingResponse(
service.tutor_stream(user, body.message, body.context),
media_type="text/event-stream",
)
Services receive their dependencies through constructor injection (from the route handler), not through global state.
4.4 Error Handling
Error Response Format
Every error returns the same JSON shape:
{
"detail": "Course not found",
"code": "NOT_FOUND",
"field": null
}
For validation errors (422):
{
"detail": "Validation failed",
"code": "VALIDATION_ERROR",
"errors": [
{"field": "email", "message": "Invalid email format"},
{"field": "full_name", "message": "Required field"}
]
}
Application Exceptions
# src/core/exceptions.py
class AppError(Exception):
"""Base application error."""
def __init__(self, message: str, code: str, status_code: int = 400):
self.message = message
self.code = code
self.status_code = status_code
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} not found", "NOT_FOUND", 404)
class ForbiddenError(AppError):
def __init__(self, message: str = "Access denied"):
super().__init__(message, "FORBIDDEN", 403)
class ConflictError(AppError):
def __init__(self, message: str):
super().__init__(message, "CONFLICT", 409)
class LLMError(AppError):
def __init__(self, message: str = "LLM service unavailable"):
super().__init__(message, "LLM_ERROR", 502)
Global Exception Handler
# src/main.py
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message, "code": exc.code, "field": None},
)
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
errors = []
for error in exc.errors():
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
errors.append({"field": field, "message": error["msg"]})
return JSONResponse(
status_code=422,
content={"detail": "Validation failed", "code": "VALIDATION_ERROR", "errors": errors},
)
HTTP Status Code Usage
| Status | When |
|---|---|
200 |
Success with response body |
201 |
Resource created (POST that creates) |
204 |
Success, no body (DELETE) |
400 |
Bad request (business rule violation: “Cannot delete published course”) |
401 |
Not authenticated (no cookie or expired session) |
403 |
Authenticated but wrong role |
404 |
Resource not found |
409 |
Conflict (duplicate email, enrollment already exists) |
413 |
File too large (document upload) |
422 |
Validation error (Pydantic) |
429 |
Rate limited (future, for LLM endpoints) |
502 |
LLM provider error (upstream failure) |
Validation Approach
- Pydantic schemas validate request shape and types at the route layer
- Service layer validates business rules (e.g., “course must have at least 1 module to publish”)
- Database constraints are the last line of defense (unique, FK, check constraints)
Services raise AppError subclasses. Routes never catch exceptions — the global handler does.
4.5 Database Access Layer
Engine and Session Setup
# src/deps/db.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
engine = create_async_engine(
settings.DATABASE_URL, # "postgresql+asyncpg://..."
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # Avoid lazy-load issues after commit
)
Base Model
# src/models/base.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import text
from datetime import datetime
import uuid
class Base(DeclarativeBase):
pass
class UUIDMixin:
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True,
server_default=text("gen_random_uuid()"),
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
server_default=text("now()"),
)
updated_at: Mapped[datetime] = mapped_column(
server_default=text("now()"),
onupdate=text("now()"),
)
Model Example
# src/models/course.py
from sqlalchemy import ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, UUIDMixin, TimestampMixin
import uuid
import enum
class ContentStatus(str, enum.Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class Course(UUIDMixin, TimestampMixin, Base):
__tablename__ = "courses"
org_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("organizations.id"), nullable=False)
created_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
source_document_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("documents.id"))
title: Mapped[str]
description: Mapped[str | None]
outcome: Mapped[str | None]
status: Mapped[ContentStatus] = mapped_column(
SAEnum(ContentStatus, name="content_status"),
default=ContentStatus.DRAFT,
)
# Relationships
modules: Mapped[list["Module"]] = relationship(
back_populates="course",
cascade="all, delete-orphan",
order_by="Module.position",
)
enrollments: Mapped[list["Enrollment"]] = relationship(back_populates="course")
manual: Mapped["Manual | None"] = relationship(back_populates="course", uselist=False)
checkpoints: Mapped[list["SkillCheckpoint"]] = relationship(
back_populates="course",
cascade="all, delete-orphan",
)
Repository Pattern
# src/repositories/base.py
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.base import Base
from typing import TypeVar, Generic, Sequence
T = TypeVar("T", bound=Base)
class BaseRepository(Generic[T]):
def __init__(self, session: AsyncSession, model: type[T]):
self.session = session
self.model = model
async def get_by_id(self, id: uuid.UUID) -> T | None:
return await self.session.get(self.model, id)
async def get_or_404(self, id: uuid.UUID) -> T:
obj = await self.get_by_id(id)
if not obj:
raise NotFoundError(self.model.__tablename__, str(id))
return obj
async def list(
self,
*,
filters: list | None = None,
order_by=None,
offset: int = 0,
limit: int = 50,
) -> tuple[Sequence[T], int]:
query = select(self.model)
count_query = select(func.count()).select_from(self.model)
if filters:
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
if order_by is not None:
query = query.order_by(order_by)
total = (await self.session.execute(count_query)).scalar_one()
result = await self.session.execute(query.offset(offset).limit(limit))
return result.scalars().all(), total
async def create(self, **kwargs) -> T:
obj = self.model(**kwargs)
self.session.add(obj)
await self.session.flush()
return obj
async def update(self, obj: T, **kwargs) -> T:
for key, value in kwargs.items():
setattr(obj, key, value)
await self.session.flush()
return obj
async def delete(self, obj: T) -> None:
await self.session.delete(obj)
await self.session.flush()
Specialized Repository Example
# src/repositories/skill_repo.py
class SkillRepository(BaseRepository[Skill]):
def __init__(self, session: AsyncSession):
super().__init__(session, Skill)
async def get_matrix(self, org_id: uuid.UUID, category_id: uuid.UUID | None = None):
"""Returns the full skills matrix: [{user, skill, level}, ...]"""
query = (
select(
User.id.label("user_id"),
User.full_name,
Skill.id.label("skill_id"),
Skill.name.label("skill_name"),
UserSkill.level,
)
.select_from(User)
.outerjoin(UserSkill, User.id == UserSkill.user_id)
.outerjoin(Skill, UserSkill.skill_id == Skill.id)
.where(User.org_id == org_id, User.is_active == True)
)
if category_id:
query = query.where(Skill.category_id == category_id)
result = await self.session.execute(query.order_by(User.full_name, Skill.name))
return result.all()
async def get_mentorship_suggestions(self, org_id: uuid.UUID):
"""Finds high-low skill pairs for mentorship."""
query = (
select(
User.full_name.label("mentor_name"),
User.id.label("mentor_id"),
func.array_agg(
func.json_build_object(
"mentee_name", mentee.full_name,
"mentee_id", mentee.id,
"skill", Skill.name,
)
).label("matches"),
)
.select_from(UserSkill)
.join(User, User.id == UserSkill.user_id)
.join(
us_low := aliased(UserSkill),
and_(
us_low.skill_id == UserSkill.skill_id,
us_low.level == "low",
us_low.user_id != UserSkill.user_id,
),
)
.join(mentee := aliased(User), mentee.id == us_low.user_id)
.join(Skill, Skill.id == UserSkill.skill_id)
.where(UserSkill.level == "high", User.org_id == org_id)
.group_by(User.id, User.full_name)
)
result = await self.session.execute(query)
return result.all()
Vector Search (Document Chunks)
# src/repositories/document_chunk_repo.py
from pgvector.sqlalchemy import Vector
class DocumentChunkRepository(BaseRepository[DocumentChunk]):
def __init__(self, session: AsyncSession):
super().__init__(session, DocumentChunk)
async def similarity_search(
self,
org_id: uuid.UUID,
query_embedding: list[float],
top_k: int = 5,
) -> list[dict]:
"""Semantic search across all org documents."""
query = (
select(
DocumentChunk.content,
DocumentChunk.metadata,
(1 - DocumentChunk.embedding.cosine_distance(query_embedding)).label("similarity"),
)
.join(Document, Document.id == DocumentChunk.document_id)
.where(Document.org_id == org_id)
.order_by(DocumentChunk.embedding.cosine_distance(query_embedding))
.limit(top_k)
)
result = await self.session.execute(query)
return [
{"content": row.content, "metadata": row.metadata, "similarity": row.similarity}
for row in result.all()
]
Session Management in Routes
Routes get a session from Depends, instantiate repositories and services, and the session auto-commits or rolls back:
# src/routes/courses.py
@router.post("/courses", status_code=201)
async def create_course(user: AdminUser, db: DBSession, body: CourseCreate):
repo = CourseRepository(db)
service = CourseService(repo)
course = await service.create(
org_id=user.org_id,
created_by=user.id,
**body.model_dump(),
)
await db.commit()
return CourseRead.model_validate(course)
The commit() call is in the route handler, not in the repository or service. This keeps the transaction boundary visible and explicit. If anything raises before commit, the session rolls back automatically when the context manager exits.
Model-to-Table Mapping Summary
| Model class | Table | Key relationships |
|---|---|---|
Organization |
organizations |
Has many: users, documents, skills, courses |
User |
users |
Belongs to: organization. Has many: enrollments, attempts, user_skills |
Document |
documents |
Belongs to: organization, uploaded_by user. Has many: chunks |
DocumentChunk |
document_chunks |
Belongs to: document. Has: embedding vector(384) |
SkillCategory |
skill_categories |
Belongs to: organization. Has many: skills |
Skill |
skills |
Belongs to: organization, category. Has many: user_skills, checkpoints |
Course |
courses |
Belongs to: organization, created_by user, source document. Has many: modules, enrollments, checkpoints. Has one: manual |
Module |
modules |
Belongs to: course. Has many: lessons |
Lesson |
lessons |
Belongs to: module. Has many: exercises |
Exercise |
exercises |
Belongs to: lesson. Has many: attempts, spaced_repetition entries |
SkillCheckpoint |
skill_checkpoints |
Belongs to: course, skill, module |
Manual |
manuals |
Belongs to: organization, created_by user, source document, course (optional) |
Enrollment |
enrollments |
Belongs to: user, course, assigned_by user |
ExerciseAttempt |
exercise_attempts |
Belongs to: user, exercise |
UserSkill |
user_skills |
Belongs to: user, skill |
SpacedRepetition |
spaced_repetition |
Belongs to: user, exercise |
GenerationJob |
generation_jobs |
Belongs to: organization, triggered_by user, source document. Links to: result course, result manual |
CourseFeedback |
course_feedback |
Belongs to: user, course |
ChatSession |
chat_sessions |
Belongs to: user. Has many: chat_messages |
ChatMessage |
chat_messages |
Belongs to: chat_session |
BackgroundJob |
background_jobs |
Belongs to: organization. Tracks async task execution |
UserSession |
user_sessions |
Belongs to: user. Tracks active auth sessions |
AuditLog |
audit_logs |
Belongs to: user, organization. Records admin actions |
ApiKey |
api_keys |
Belongs to: organization. API key management |
Webhook |
webhooks |
Belongs to: organization. Has many: webhook_deliveries |
WebhookDelivery |
webhook_deliveries |
Belongs to: webhook. Tracks delivery attempts |
Migrations
Alembic with async support. One migration per schema change.
# Generate migration from model changes
uv run alembic revision --autogenerate -m "add_courses_table"
# Apply migrations
uv run alembic upgrade head
# Rollback
uv run alembic downgrade -1
The initial migration creates all 27 tables + enums + indexes + extensions (pgcrypto, vector).
4.6 Configuration
# src/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Database
DATABASE_URL: str = "postgresql+asyncpg://skillnet:skillnet@localhost:5432/skillnet"
# Auth
SECRET_KEY: str # Required, no default
SESSION_LIFETIME_SECONDS: int = 604800 # 7 days
COOKIE_NAME: str = "skillnet_session"
COOKIE_SECURE: bool = True # Set False for local dev without HTTPS
# LLM (defaults, overridable per org)
LLM_BASE_URL: str = "https://api.openai.com/v1"
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o-mini"
# Embeddings
EMBEDDING_BASE_URL: str = "https://api.openai.com/v1"
EMBEDDING_API_KEY: str = ""
EMBEDDING_MODEL: str = "multilingual-e5-small"
EMBEDDING_DIMENSIONS: int = 384
# File uploads
UPLOAD_DIR: str = "./uploads"
MAX_UPLOAD_SIZE_MB: int = 50
# App
DEBUG: bool = False
CORS_ORIGINS: list[str] = ["http://localhost:5173"]
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
4.7 Application Factory
# src/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create upload dir, verify DB connection
Path(settings.UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
yield
# Shutdown: dispose engine
await engine.dispose()
def create_app() -> FastAPI:
app = FastAPI(
title="SkillNet API",
version="0.1.0",
lifespan=lifespan,
docs_url="/api/docs" if settings.DEBUG else None,
redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True, # Required for cookies
allow_methods=["*"],
allow_headers=["*"],
)
# Register exception handlers
app.add_exception_handler(AppError, app_error_handler)
app.add_exception_handler(RequestValidationError, validation_error_handler)
# Mount routers under /api/v1
from src.routes import (
auth, users, documents, courses, modules, lessons,
exercises, enrollments, skills, chat, manuals,
spaced_repetition, generation_jobs, stats, settings as settings_routes,
feedback,
)
prefix = "/api/v1"
app.include_router(auth.router, prefix=prefix, tags=["Auth"])
app.include_router(users.router, prefix=prefix, tags=["Users"])
app.include_router(documents.router, prefix=prefix, tags=["Documents"])
app.include_router(courses.router, prefix=prefix, tags=["Courses"])
app.include_router(modules.router, prefix=prefix, tags=["Modules"])
app.include_router(lessons.router, prefix=prefix, tags=["Lessons"])
app.include_router(exercises.router, prefix=prefix, tags=["Exercises"])
app.include_router(enrollments.router, prefix=prefix, tags=["Enrollments"])
app.include_router(skills.router, prefix=prefix, tags=["Skills"])
app.include_router(chat.router, prefix=prefix, tags=["Chat"])
app.include_router(manuals.router, prefix=prefix, tags=["Manuals"])
app.include_router(spaced_repetition.router, prefix=prefix, tags=["Spaced Repetition"])
app.include_router(generation_jobs.router, prefix=prefix, tags=["Generation Jobs"])
app.include_router(stats.router, prefix=prefix, tags=["Stats"])
app.include_router(settings_routes.router, prefix=prefix, tags=["Settings"])
app.include_router(feedback.router, prefix=prefix, tags=["Feedback"])
return app
app = create_app()
4.8 Key Design Decisions
| Decision | Rationale |
|---|---|
| Repository per domain, not per table | SkillRepository handles skills, skill_categories, user_skills, and matrix queries. Avoids 23 tiny repos |
| No unit of work abstraction | db.commit() in route handler is explicit enough for this scale. Adding UoW adds indirection without benefit |
| Services receive deps via constructor | Route handler creates CourseService(repo) — no service locator, no global state. Easy to test with fakes |
| Commit in route, not in service | Transaction boundary is visible. Service methods are composable (one route can call multiple service methods in one transaction) |
| No background task queue for MVP | Generation jobs run in-process with asyncio.create_task(). If a request triggers generation, it starts the task and returns the job_id immediately. The client polls for status. Celery/Dramatiq deferred to when needed |
| Alerts computed on request | No alert table, no cron. AlertService.get_alerts() runs queries against enrollment/attempt/spaced_repetition data. At MVP scale (dozens of employees), this is instant |
| Flat route files, not nested routers | modules.py handles /courses/{cid}/modules/... directly. The nesting is in the URL, not in the code structure. Keeps imports simple |
| SSE over WebSocket | Unidirectional streaming is all we need. SSE reconnects automatically, works with proxies, needs zero client-side library |
| Pydantic schemas separate from ORM models | ORM models map to tables. Schemas define API contracts. They look similar but evolve independently (e.g., CourseRead excludes internal fields, adds computed module_count) |