Spaces:
Sleeping
Sleeping
Upload 39 files
Browse files- .dockerignore +11 -0
- Dockerfile +16 -0
- README.md +100 -12
- api/openapi.yaml +433 -0
- backend/.env.example +9 -0
- backend/README.md +85 -0
- backend/alembic.ini +36 -0
- backend/alembic/env.py +50 -0
- backend/alembic/script.py.mako +24 -0
- backend/alembic/versions/.gitkeep +1 -0
- backend/app/__init__.py +1 -0
- backend/app/api/__init__.py +1 -0
- backend/app/api/deps.py +1 -0
- backend/app/api/router.py +11 -0
- backend/app/api/routes/__init__.py +1 -0
- backend/app/api/routes/assessments.py +46 -0
- backend/app/api/routes/auth.py +35 -0
- backend/app/api/routes/learners.py +42 -0
- backend/app/api/routes/modules.py +20 -0
- backend/app/api/routes/progress.py +45 -0
- backend/app/api/routes/recommendations.py +19 -0
- backend/app/core/__init__.py +1 -0
- backend/app/core/config.py +28 -0
- backend/app/db/__init__.py +1 -0
- backend/app/db/base.py +5 -0
- backend/app/db/session.py +32 -0
- backend/app/main.py +47 -0
- backend/app/models/__init__.py +1 -0
- backend/app/models/orm.py +212 -0
- backend/app/models/schemas.py +307 -0
- backend/app/services/__init__.py +1 -0
- backend/app/services/bootstrap.py +141 -0
- backend/app/services/engines.py +171 -0
- backend/app/services/repository.py +471 -0
- backend/pyproject.toml +22 -0
- backend/requirements.txt +10 -0
- docs/algorithms.md +149 -0
- docs/architecture.md +114 -0
- schemas/schema.sql +271 -0
.dockerignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
**/__pycache__
|
| 4 |
+
**/.pytest_cache
|
| 5 |
+
**/.mypy_cache
|
| 6 |
+
**/.ruff_cache
|
| 7 |
+
**/*.pyc
|
| 8 |
+
**/*.pyo
|
| 9 |
+
backend/.env
|
| 10 |
+
backend/aididact.db
|
| 11 |
+
backend/alembic/versions/*.pyc
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
ENV PORT=7860
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
COPY backend/requirements.txt /app/requirements.txt
|
| 10 |
+
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r /app/requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY backend /app
|
| 13 |
+
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
|
| 16 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
README.md
CHANGED
|
@@ -1,12 +1,100 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AIDidact
|
| 2 |
+
|
| 3 |
+
AIDidact is a scalable AI-powered microlearning ecosystem for personalized, self-directed learning. The platform is organized around modular 5 ECTS learning units, hybrid recommendations, cheating-resistant assessments, and analytics-driven learner support.
|
| 4 |
+
|
| 5 |
+
## Included Deliverables
|
| 6 |
+
|
| 7 |
+
- [Architecture blueprint](C:\Users\user\Desktop\AI Flow\AIDidact\docs\architecture.md)
|
| 8 |
+
- [Core algorithms](C:\Users\user\Desktop\AI Flow\AIDidact\docs\algorithms.md)
|
| 9 |
+
- [Relational data schema](C:\Users\user\Desktop\AI Flow\AIDidact\schemas\schema.sql)
|
| 10 |
+
- [REST API specification](C:\Users\user\Desktop\AI Flow\AIDidact\api\openapi.yaml)
|
| 11 |
+
- [FastAPI backend scaffold](C:\Users\user\Desktop\AI Flow\AIDidact\backend\README.md)
|
| 12 |
+
|
| 13 |
+
## System Overview
|
| 14 |
+
|
| 15 |
+
AIDidact supports millions of learners across web and mobile channels. At registration, learners provide prior learning, goals, educational level, readiness signals, and optional learning preferences. That information is transformed into a learner profile vector used to personalize module recommendations, pacing, and feedback.
|
| 16 |
+
|
| 17 |
+
Each microlearning module is worth 5 ECTS credits and includes Bloom-aligned objectives, chunked 10 to 15 minute content units, interactive activities, formative assessments, and explicit completion criteria. Modules are self-paced, measurable, and adaptive where possible.
|
| 18 |
+
|
| 19 |
+
## Textual Architecture Diagram
|
| 20 |
+
|
| 21 |
+
```mermaid
|
| 22 |
+
flowchart LR
|
| 23 |
+
A["Web App"] --> G["API Gateway"]
|
| 24 |
+
B["Mobile App"] --> G
|
| 25 |
+
C["Admin Console"] --> G
|
| 26 |
+
|
| 27 |
+
G --> U["Auth and Consent Service"]
|
| 28 |
+
G --> P["Profile Service"]
|
| 29 |
+
G --> M["Module Service"]
|
| 30 |
+
G --> R["Recommendation Service"]
|
| 31 |
+
G --> S["Assessment Service"]
|
| 32 |
+
G --> T["Progress Service"]
|
| 33 |
+
G --> D["Dashboard Service"]
|
| 34 |
+
|
| 35 |
+
U --> PG["PostgreSQL"]
|
| 36 |
+
P --> PG
|
| 37 |
+
M --> PG
|
| 38 |
+
S --> PG
|
| 39 |
+
T --> PG
|
| 40 |
+
|
| 41 |
+
P --> K["Event Bus"]
|
| 42 |
+
M --> K
|
| 43 |
+
S --> K
|
| 44 |
+
T --> K
|
| 45 |
+
|
| 46 |
+
K --> L["Analytics Processor"]
|
| 47 |
+
L --> NS["NoSQL Log Store"]
|
| 48 |
+
L --> WH["Analytics Warehouse"]
|
| 49 |
+
L --> FS["Feature Store"]
|
| 50 |
+
|
| 51 |
+
R --> FS
|
| 52 |
+
R --> VX["Vector Index"]
|
| 53 |
+
R --> RM["Hybrid Recommender Models"]
|
| 54 |
+
|
| 55 |
+
S --> Q["Question Generation"]
|
| 56 |
+
S --> E["AI Evaluation"]
|
| 57 |
+
S --> I["Integrity Engine"]
|
| 58 |
+
|
| 59 |
+
D --> WH
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Key Design Decisions
|
| 63 |
+
|
| 64 |
+
- Transactional data lives in PostgreSQL; high-volume telemetry flows to NoSQL and warehouse systems.
|
| 65 |
+
- Recommendation uses hybrid content-based plus collaborative filtering and refreshes from learner progress.
|
| 66 |
+
- Assessment uses blueprint-based randomized questions, AI-assisted open-ended grading, and anomaly detection.
|
| 67 |
+
- GDPR compliance is built in through consent management, auditability, pseudonymization, retention rules, and human review paths.
|
| 68 |
+
|
| 69 |
+
## Hugging Face Spaces
|
| 70 |
+
|
| 71 |
+
This repository is now prepared for Hugging Face Spaces using `Docker`.
|
| 72 |
+
|
| 73 |
+
Recommended Space setup:
|
| 74 |
+
|
| 75 |
+
- SDK: `Docker`
|
| 76 |
+
- Template: `Blank`
|
| 77 |
+
- Hardware: `CPU Basic`
|
| 78 |
+
|
| 79 |
+
For the easiest deployment path on Spaces:
|
| 80 |
+
|
| 81 |
+
- use the included `Dockerfile`
|
| 82 |
+
- keep `DATABASE_URL` as SQLite for demo use
|
| 83 |
+
- optionally switch to an external PostgreSQL instance later via Space Secrets
|
| 84 |
+
|
| 85 |
+
Main deployment files:
|
| 86 |
+
|
| 87 |
+
- [Dockerfile](C:\Users\user\Desktop\AI Flow\AIDidact\Dockerfile)
|
| 88 |
+
- [backend README](C:\Users\user\Desktop\AI Flow\AIDidact\backend\README.md)
|
| 89 |
+
- [environment example](C:\Users\user\Desktop\AI Flow\AIDidact\backend\.env.example)
|
| 90 |
+
|
| 91 |
+
## Example User Flow
|
| 92 |
+
|
| 93 |
+
1. Learner registers and grants required consents.
|
| 94 |
+
2. Diagnostic assessment establishes readiness and baseline mastery.
|
| 95 |
+
3. Profile vector is generated from prior learning, goals, level, readiness, and preferences.
|
| 96 |
+
4. Recommendation service ranks starter modules.
|
| 97 |
+
5. Learner completes chunked units, activities, and formative checks.
|
| 98 |
+
6. Events stream to analytics and update mastery, dashboards, and next recommendations.
|
| 99 |
+
7. Learner completes a unique summative assessment with integrity controls.
|
| 100 |
+
8. Module is marked complete only if score, engagement, and integrity thresholds are satisfied.
|
api/openapi.yaml
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openapi: 3.1.0
|
| 2 |
+
info:
|
| 3 |
+
title: AIDidact API
|
| 4 |
+
version: 1.0.0
|
| 5 |
+
description: API contract for the AIDidact AI-powered microlearning ecosystem.
|
| 6 |
+
servers:
|
| 7 |
+
- url: https://api.aididact.example.com
|
| 8 |
+
paths:
|
| 9 |
+
/v1/auth/register:
|
| 10 |
+
post:
|
| 11 |
+
summary: Register a learner
|
| 12 |
+
requestBody:
|
| 13 |
+
required: true
|
| 14 |
+
content:
|
| 15 |
+
application/json:
|
| 16 |
+
schema:
|
| 17 |
+
$ref: '#/components/schemas/RegisterLearnerRequest'
|
| 18 |
+
responses:
|
| 19 |
+
'201':
|
| 20 |
+
description: Registered
|
| 21 |
+
content:
|
| 22 |
+
application/json:
|
| 23 |
+
schema:
|
| 24 |
+
$ref: '#/components/schemas/LearnerRegistrationResponse'
|
| 25 |
+
/v1/learners/{learnerId}/profile:
|
| 26 |
+
get:
|
| 27 |
+
summary: Get learner profile
|
| 28 |
+
parameters:
|
| 29 |
+
- $ref: '#/components/parameters/LearnerId'
|
| 30 |
+
responses:
|
| 31 |
+
'200':
|
| 32 |
+
description: Learner profile
|
| 33 |
+
content:
|
| 34 |
+
application/json:
|
| 35 |
+
schema:
|
| 36 |
+
$ref: '#/components/schemas/LearnerProfile'
|
| 37 |
+
patch:
|
| 38 |
+
summary: Update learner goals or preferences
|
| 39 |
+
parameters:
|
| 40 |
+
- $ref: '#/components/parameters/LearnerId'
|
| 41 |
+
requestBody:
|
| 42 |
+
required: true
|
| 43 |
+
content:
|
| 44 |
+
application/json:
|
| 45 |
+
schema:
|
| 46 |
+
$ref: '#/components/schemas/UpdateLearnerProfileRequest'
|
| 47 |
+
responses:
|
| 48 |
+
'200':
|
| 49 |
+
description: Updated
|
| 50 |
+
/v1/learners/{learnerId}/diagnostic-assessment:
|
| 51 |
+
post:
|
| 52 |
+
summary: Submit readiness assessment
|
| 53 |
+
parameters:
|
| 54 |
+
- $ref: '#/components/parameters/LearnerId'
|
| 55 |
+
requestBody:
|
| 56 |
+
required: true
|
| 57 |
+
content:
|
| 58 |
+
application/json:
|
| 59 |
+
schema:
|
| 60 |
+
$ref: '#/components/schemas/DiagnosticSubmission'
|
| 61 |
+
responses:
|
| 62 |
+
'202':
|
| 63 |
+
description: Accepted
|
| 64 |
+
/v1/modules:
|
| 65 |
+
get:
|
| 66 |
+
summary: List modules
|
| 67 |
+
responses:
|
| 68 |
+
'200':
|
| 69 |
+
description: Module list
|
| 70 |
+
/v1/modules/{moduleId}:
|
| 71 |
+
get:
|
| 72 |
+
summary: Get module detail
|
| 73 |
+
parameters:
|
| 74 |
+
- $ref: '#/components/parameters/ModuleId'
|
| 75 |
+
responses:
|
| 76 |
+
'200':
|
| 77 |
+
description: Module detail
|
| 78 |
+
content:
|
| 79 |
+
application/json:
|
| 80 |
+
schema:
|
| 81 |
+
$ref: '#/components/schemas/ModuleDetail'
|
| 82 |
+
/v1/learners/{learnerId}/recommendations:
|
| 83 |
+
get:
|
| 84 |
+
summary: Get ranked module recommendations
|
| 85 |
+
parameters:
|
| 86 |
+
- $ref: '#/components/parameters/LearnerId'
|
| 87 |
+
responses:
|
| 88 |
+
'200':
|
| 89 |
+
description: Recommendations
|
| 90 |
+
content:
|
| 91 |
+
application/json:
|
| 92 |
+
schema:
|
| 93 |
+
type: object
|
| 94 |
+
properties:
|
| 95 |
+
items:
|
| 96 |
+
type: array
|
| 97 |
+
items:
|
| 98 |
+
$ref: '#/components/schemas/RecommendationItem'
|
| 99 |
+
/v1/enrollments:
|
| 100 |
+
post:
|
| 101 |
+
summary: Enroll learner in module
|
| 102 |
+
requestBody:
|
| 103 |
+
required: true
|
| 104 |
+
content:
|
| 105 |
+
application/json:
|
| 106 |
+
schema:
|
| 107 |
+
type: object
|
| 108 |
+
required: [learnerId, moduleId]
|
| 109 |
+
properties:
|
| 110 |
+
learnerId:
|
| 111 |
+
type: string
|
| 112 |
+
format: uuid
|
| 113 |
+
moduleId:
|
| 114 |
+
type: string
|
| 115 |
+
format: uuid
|
| 116 |
+
responses:
|
| 117 |
+
'201':
|
| 118 |
+
description: Enrolled
|
| 119 |
+
/v1/interactions:
|
| 120 |
+
post:
|
| 121 |
+
summary: Ingest interaction event
|
| 122 |
+
requestBody:
|
| 123 |
+
required: true
|
| 124 |
+
content:
|
| 125 |
+
application/json:
|
| 126 |
+
schema:
|
| 127 |
+
$ref: '#/components/schemas/InteractionEvent'
|
| 128 |
+
responses:
|
| 129 |
+
'202':
|
| 130 |
+
description: Accepted
|
| 131 |
+
/v1/assessments/{assessmentId}/attempts:
|
| 132 |
+
post:
|
| 133 |
+
summary: Create unique assessment attempt
|
| 134 |
+
parameters:
|
| 135 |
+
- $ref: '#/components/parameters/AssessmentId'
|
| 136 |
+
requestBody:
|
| 137 |
+
required: true
|
| 138 |
+
content:
|
| 139 |
+
application/json:
|
| 140 |
+
schema:
|
| 141 |
+
type: object
|
| 142 |
+
required: [learnerId]
|
| 143 |
+
properties:
|
| 144 |
+
learnerId:
|
| 145 |
+
type: string
|
| 146 |
+
format: uuid
|
| 147 |
+
responses:
|
| 148 |
+
'201':
|
| 149 |
+
description: Attempt created
|
| 150 |
+
content:
|
| 151 |
+
application/json:
|
| 152 |
+
schema:
|
| 153 |
+
$ref: '#/components/schemas/AssessmentAttempt'
|
| 154 |
+
/v1/assessments/attempts/{attemptId}/responses:
|
| 155 |
+
post:
|
| 156 |
+
summary: Submit responses
|
| 157 |
+
parameters:
|
| 158 |
+
- $ref: '#/components/parameters/AttemptId'
|
| 159 |
+
requestBody:
|
| 160 |
+
required: true
|
| 161 |
+
content:
|
| 162 |
+
application/json:
|
| 163 |
+
schema:
|
| 164 |
+
$ref: '#/components/schemas/AssessmentResponseSubmission'
|
| 165 |
+
responses:
|
| 166 |
+
'202':
|
| 167 |
+
description: Accepted for grading
|
| 168 |
+
/v1/assessments/attempts/{attemptId}:
|
| 169 |
+
get:
|
| 170 |
+
summary: Get assessment result
|
| 171 |
+
parameters:
|
| 172 |
+
- $ref: '#/components/parameters/AttemptId'
|
| 173 |
+
responses:
|
| 174 |
+
'200':
|
| 175 |
+
description: Result
|
| 176 |
+
content:
|
| 177 |
+
application/json:
|
| 178 |
+
schema:
|
| 179 |
+
$ref: '#/components/schemas/AssessmentAttemptResult'
|
| 180 |
+
/v1/learners/{learnerId}/dashboard:
|
| 181 |
+
get:
|
| 182 |
+
summary: Get learner dashboard
|
| 183 |
+
parameters:
|
| 184 |
+
- $ref: '#/components/parameters/LearnerId'
|
| 185 |
+
responses:
|
| 186 |
+
'200':
|
| 187 |
+
description: Dashboard
|
| 188 |
+
content:
|
| 189 |
+
application/json:
|
| 190 |
+
schema:
|
| 191 |
+
$ref: '#/components/schemas/LearnerDashboard'
|
| 192 |
+
components:
|
| 193 |
+
parameters:
|
| 194 |
+
LearnerId:
|
| 195 |
+
in: path
|
| 196 |
+
name: learnerId
|
| 197 |
+
required: true
|
| 198 |
+
schema:
|
| 199 |
+
type: string
|
| 200 |
+
format: uuid
|
| 201 |
+
ModuleId:
|
| 202 |
+
in: path
|
| 203 |
+
name: moduleId
|
| 204 |
+
required: true
|
| 205 |
+
schema:
|
| 206 |
+
type: string
|
| 207 |
+
format: uuid
|
| 208 |
+
AssessmentId:
|
| 209 |
+
in: path
|
| 210 |
+
name: assessmentId
|
| 211 |
+
required: true
|
| 212 |
+
schema:
|
| 213 |
+
type: string
|
| 214 |
+
format: uuid
|
| 215 |
+
AttemptId:
|
| 216 |
+
in: path
|
| 217 |
+
name: attemptId
|
| 218 |
+
required: true
|
| 219 |
+
schema:
|
| 220 |
+
type: string
|
| 221 |
+
format: uuid
|
| 222 |
+
schemas:
|
| 223 |
+
RegisterLearnerRequest:
|
| 224 |
+
type: object
|
| 225 |
+
required: [email, firstName, lastName, timezone, educationalLevel, goals, priorLearning]
|
| 226 |
+
properties:
|
| 227 |
+
email:
|
| 228 |
+
type: string
|
| 229 |
+
format: email
|
| 230 |
+
firstName:
|
| 231 |
+
type: string
|
| 232 |
+
lastName:
|
| 233 |
+
type: string
|
| 234 |
+
timezone:
|
| 235 |
+
type: string
|
| 236 |
+
educationalLevel:
|
| 237 |
+
type: string
|
| 238 |
+
priorLearning:
|
| 239 |
+
type: array
|
| 240 |
+
items:
|
| 241 |
+
$ref: '#/components/schemas/PriorLearningItem'
|
| 242 |
+
goals:
|
| 243 |
+
type: array
|
| 244 |
+
items:
|
| 245 |
+
$ref: '#/components/schemas/LearnerGoal'
|
| 246 |
+
preferences:
|
| 247 |
+
$ref: '#/components/schemas/LearnerPreferences'
|
| 248 |
+
LearnerRegistrationResponse:
|
| 249 |
+
type: object
|
| 250 |
+
properties:
|
| 251 |
+
learnerId:
|
| 252 |
+
type: string
|
| 253 |
+
format: uuid
|
| 254 |
+
nextStep:
|
| 255 |
+
type: string
|
| 256 |
+
PriorLearningItem:
|
| 257 |
+
type: object
|
| 258 |
+
properties:
|
| 259 |
+
learningType:
|
| 260 |
+
type: string
|
| 261 |
+
enum: [formal, non_formal, informal]
|
| 262 |
+
title:
|
| 263 |
+
type: string
|
| 264 |
+
provider:
|
| 265 |
+
type: string
|
| 266 |
+
description:
|
| 267 |
+
type: string
|
| 268 |
+
LearnerGoal:
|
| 269 |
+
type: object
|
| 270 |
+
properties:
|
| 271 |
+
goalHorizon:
|
| 272 |
+
type: string
|
| 273 |
+
enum: [short_term, long_term]
|
| 274 |
+
goalText:
|
| 275 |
+
type: string
|
| 276 |
+
targetDate:
|
| 277 |
+
type: string
|
| 278 |
+
format: date
|
| 279 |
+
LearnerPreferences:
|
| 280 |
+
type: object
|
| 281 |
+
properties:
|
| 282 |
+
preferredModalities:
|
| 283 |
+
type: array
|
| 284 |
+
items:
|
| 285 |
+
type: string
|
| 286 |
+
preferredSessionLengthMinutes:
|
| 287 |
+
type: integer
|
| 288 |
+
DiagnosticSubmission:
|
| 289 |
+
type: object
|
| 290 |
+
properties:
|
| 291 |
+
responses:
|
| 292 |
+
type: array
|
| 293 |
+
items:
|
| 294 |
+
type: object
|
| 295 |
+
additionalProperties: true
|
| 296 |
+
LearnerProfile:
|
| 297 |
+
type: object
|
| 298 |
+
properties:
|
| 299 |
+
learnerId:
|
| 300 |
+
type: string
|
| 301 |
+
format: uuid
|
| 302 |
+
readinessLevel:
|
| 303 |
+
type: string
|
| 304 |
+
readinessScore:
|
| 305 |
+
type: number
|
| 306 |
+
masteryMap:
|
| 307 |
+
type: object
|
| 308 |
+
additionalProperties: true
|
| 309 |
+
UpdateLearnerProfileRequest:
|
| 310 |
+
type: object
|
| 311 |
+
properties:
|
| 312 |
+
goals:
|
| 313 |
+
type: array
|
| 314 |
+
items:
|
| 315 |
+
$ref: '#/components/schemas/LearnerGoal'
|
| 316 |
+
preferences:
|
| 317 |
+
$ref: '#/components/schemas/LearnerPreferences'
|
| 318 |
+
ModuleDetail:
|
| 319 |
+
type: object
|
| 320 |
+
properties:
|
| 321 |
+
id:
|
| 322 |
+
type: string
|
| 323 |
+
format: uuid
|
| 324 |
+
title:
|
| 325 |
+
type: string
|
| 326 |
+
description:
|
| 327 |
+
type: string
|
| 328 |
+
objectives:
|
| 329 |
+
type: array
|
| 330 |
+
items:
|
| 331 |
+
type: object
|
| 332 |
+
additionalProperties: true
|
| 333 |
+
units:
|
| 334 |
+
type: array
|
| 335 |
+
items:
|
| 336 |
+
type: object
|
| 337 |
+
additionalProperties: true
|
| 338 |
+
completionCriteria:
|
| 339 |
+
type: object
|
| 340 |
+
additionalProperties: true
|
| 341 |
+
RecommendationItem:
|
| 342 |
+
type: object
|
| 343 |
+
properties:
|
| 344 |
+
moduleId:
|
| 345 |
+
type: string
|
| 346 |
+
format: uuid
|
| 347 |
+
rank:
|
| 348 |
+
type: integer
|
| 349 |
+
score:
|
| 350 |
+
type: number
|
| 351 |
+
reasons:
|
| 352 |
+
type: array
|
| 353 |
+
items:
|
| 354 |
+
type: string
|
| 355 |
+
InteractionEvent:
|
| 356 |
+
type: object
|
| 357 |
+
properties:
|
| 358 |
+
learnerId:
|
| 359 |
+
type: string
|
| 360 |
+
format: uuid
|
| 361 |
+
moduleId:
|
| 362 |
+
type: string
|
| 363 |
+
format: uuid
|
| 364 |
+
unitId:
|
| 365 |
+
type: string
|
| 366 |
+
format: uuid
|
| 367 |
+
activityId:
|
| 368 |
+
type: string
|
| 369 |
+
format: uuid
|
| 370 |
+
eventType:
|
| 371 |
+
type: string
|
| 372 |
+
eventTimestamp:
|
| 373 |
+
type: string
|
| 374 |
+
format: date-time
|
| 375 |
+
durationMs:
|
| 376 |
+
type: integer
|
| 377 |
+
payload:
|
| 378 |
+
type: object
|
| 379 |
+
additionalProperties: true
|
| 380 |
+
AssessmentAttempt:
|
| 381 |
+
type: object
|
| 382 |
+
properties:
|
| 383 |
+
attemptId:
|
| 384 |
+
type: string
|
| 385 |
+
format: uuid
|
| 386 |
+
assessmentId:
|
| 387 |
+
type: string
|
| 388 |
+
format: uuid
|
| 389 |
+
timeLimitMinutes:
|
| 390 |
+
type: integer
|
| 391 |
+
items:
|
| 392 |
+
type: array
|
| 393 |
+
items:
|
| 394 |
+
type: object
|
| 395 |
+
additionalProperties: true
|
| 396 |
+
AssessmentResponseSubmission:
|
| 397 |
+
type: object
|
| 398 |
+
properties:
|
| 399 |
+
responses:
|
| 400 |
+
type: array
|
| 401 |
+
items:
|
| 402 |
+
type: object
|
| 403 |
+
additionalProperties: true
|
| 404 |
+
AssessmentAttemptResult:
|
| 405 |
+
type: object
|
| 406 |
+
properties:
|
| 407 |
+
attemptId:
|
| 408 |
+
type: string
|
| 409 |
+
format: uuid
|
| 410 |
+
score:
|
| 411 |
+
type: number
|
| 412 |
+
passStatus:
|
| 413 |
+
type: string
|
| 414 |
+
integrityStatus:
|
| 415 |
+
type: string
|
| 416 |
+
humanReviewRequired:
|
| 417 |
+
type: boolean
|
| 418 |
+
LearnerDashboard:
|
| 419 |
+
type: object
|
| 420 |
+
properties:
|
| 421 |
+
learnerId:
|
| 422 |
+
type: string
|
| 423 |
+
format: uuid
|
| 424 |
+
timeOnTaskMinutes7d:
|
| 425 |
+
type: number
|
| 426 |
+
dropoutRisk:
|
| 427 |
+
type: number
|
| 428 |
+
successProbability:
|
| 429 |
+
type: number
|
| 430 |
+
adaptiveFeedback:
|
| 431 |
+
type: array
|
| 432 |
+
items:
|
| 433 |
+
type: string
|
backend/.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
APP_NAME=AIDidact API
|
| 2 |
+
APP_VERSION=0.1.0
|
| 3 |
+
API_PREFIX=/v1
|
| 4 |
+
ENVIRONMENT=development
|
| 5 |
+
DEBUG=true
|
| 6 |
+
DATABASE_URL=sqlite+aiosqlite:///./aididact.db
|
| 7 |
+
SQL_ECHO=false
|
| 8 |
+
AUTO_CREATE_TABLES=true
|
| 9 |
+
APP_PORT=7860
|
backend/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AIDidact FastAPI Backend
|
| 2 |
+
|
| 3 |
+
This is a production-oriented FastAPI scaffold for the AIDidact microlearning platform. It now uses async SQLAlchemy with a PostgreSQL-first configuration, plus Alembic scaffolding for schema migrations.
|
| 4 |
+
|
| 5 |
+
## Included
|
| 6 |
+
|
| 7 |
+
- FastAPI app entrypoint
|
| 8 |
+
- modular routers aligned with the platform domains
|
| 9 |
+
- Pydantic request and response models
|
| 10 |
+
- async SQLAlchemy models and repositories
|
| 11 |
+
- Alembic migration scaffold
|
| 12 |
+
- lightweight recommendation, profile, analytics, and assessment service logic
|
| 13 |
+
- seeded demo modules and one summative assessment
|
| 14 |
+
|
| 15 |
+
## Project Structure
|
| 16 |
+
|
| 17 |
+
```text
|
| 18 |
+
backend/
|
| 19 |
+
app/
|
| 20 |
+
api/
|
| 21 |
+
core/
|
| 22 |
+
db/
|
| 23 |
+
models/
|
| 24 |
+
services/
|
| 25 |
+
main.py
|
| 26 |
+
alembic/
|
| 27 |
+
alembic.ini
|
| 28 |
+
requirements.txt
|
| 29 |
+
pyproject.toml
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
## Run Locally
|
| 33 |
+
|
| 34 |
+
1. Install Python 3.11 or newer.
|
| 35 |
+
2. Create a virtual environment.
|
| 36 |
+
3. Install dependencies:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
pip install -r requirements.txt
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
4. Copy `.env.example` to `.env` and set `DATABASE_URL`.
|
| 43 |
+
|
| 44 |
+
5. Run migrations:
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
alembic revision --autogenerate -m "initial schema"
|
| 48 |
+
alembic upgrade head
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
For quick local development, `AUTO_CREATE_TABLES=true` will also create tables on startup.
|
| 52 |
+
|
| 53 |
+
6. Start the API:
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
uvicorn app.main:app --reload
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
7. Open:
|
| 60 |
+
|
| 61 |
+
- `http://127.0.0.1:8000/docs`
|
| 62 |
+
- `http://127.0.0.1:8000/redoc`
|
| 63 |
+
|
| 64 |
+
## Next Production Steps
|
| 65 |
+
|
| 66 |
+
- add JWT authentication and consent middleware
|
| 67 |
+
- connect recommendation features to a vector index and analytics feature store
|
| 68 |
+
- introduce background workers for grading, anomaly detection, and recommendation refreshes
|
| 69 |
+
- split module, assessment, and analytics domains into separate deployable services as traffic grows
|
| 70 |
+
|
| 71 |
+
## Hugging Face Spaces
|
| 72 |
+
|
| 73 |
+
For Hugging Face Spaces, choose:
|
| 74 |
+
|
| 75 |
+
- `Docker`
|
| 76 |
+
- `Blank`
|
| 77 |
+
- `CPU Basic`
|
| 78 |
+
|
| 79 |
+
Recommended first deployment setup:
|
| 80 |
+
|
| 81 |
+
- keep `DATABASE_URL=sqlite+aiosqlite:///./aididact.db`
|
| 82 |
+
- keep `AUTO_CREATE_TABLES=true`
|
| 83 |
+
- do not run Alembic in the first demo deployment
|
| 84 |
+
|
| 85 |
+
If you later use an external PostgreSQL database, add `DATABASE_URL` as a Hugging Face Space Secret and optionally disable automatic table creation after migrations are in place.
|
backend/alembic.ini
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[alembic]
|
| 2 |
+
script_location = alembic
|
| 3 |
+
prepend_sys_path = .
|
| 4 |
+
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/aididact
|
| 5 |
+
|
| 6 |
+
[loggers]
|
| 7 |
+
keys = root,sqlalchemy,alembic
|
| 8 |
+
|
| 9 |
+
[handlers]
|
| 10 |
+
keys = console
|
| 11 |
+
|
| 12 |
+
[formatters]
|
| 13 |
+
keys = generic
|
| 14 |
+
|
| 15 |
+
[logger_root]
|
| 16 |
+
level = WARN
|
| 17 |
+
handlers = console
|
| 18 |
+
|
| 19 |
+
[logger_sqlalchemy]
|
| 20 |
+
level = WARN
|
| 21 |
+
handlers =
|
| 22 |
+
qualname = sqlalchemy.engine
|
| 23 |
+
|
| 24 |
+
[logger_alembic]
|
| 25 |
+
level = INFO
|
| 26 |
+
handlers = console
|
| 27 |
+
qualname = alembic
|
| 28 |
+
|
| 29 |
+
[handler_console]
|
| 30 |
+
class = StreamHandler
|
| 31 |
+
args = (sys.stderr,)
|
| 32 |
+
level = NOTSET
|
| 33 |
+
formatter = generic
|
| 34 |
+
|
| 35 |
+
[formatter_generic]
|
| 36 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
backend/alembic/env.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from logging.config import fileConfig
|
| 4 |
+
|
| 5 |
+
from alembic import context
|
| 6 |
+
from sqlalchemy import engine_from_config, pool
|
| 7 |
+
|
| 8 |
+
from app.core.config import get_settings
|
| 9 |
+
from app.db.base import Base
|
| 10 |
+
from app.models import orm # noqa: F401
|
| 11 |
+
|
| 12 |
+
config = context.config
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
config.set_main_option(
|
| 15 |
+
"sqlalchemy.url",
|
| 16 |
+
settings.database_url.replace("+asyncpg", "").replace("+aiosqlite", ""),
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
if config.config_file_name is not None:
|
| 20 |
+
fileConfig(config.config_file_name)
|
| 21 |
+
|
| 22 |
+
target_metadata = Base.metadata
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_migrations_offline() -> None:
|
| 26 |
+
url = config.get_main_option("sqlalchemy.url")
|
| 27 |
+
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True)
|
| 28 |
+
|
| 29 |
+
with context.begin_transaction():
|
| 30 |
+
context.run_migrations()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def run_migrations_online() -> None:
|
| 34 |
+
connectable = engine_from_config(
|
| 35 |
+
config.get_section(config.config_ini_section, {}),
|
| 36 |
+
prefix="sqlalchemy.",
|
| 37 |
+
poolclass=pool.NullPool,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
with connectable.connect() as connection:
|
| 41 |
+
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
| 42 |
+
|
| 43 |
+
with context.begin_transaction():
|
| 44 |
+
context.run_migrations()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
if context.is_offline_mode():
|
| 48 |
+
run_migrations_offline()
|
| 49 |
+
else:
|
| 50 |
+
run_migrations_online()
|
backend/alembic/script.py.mako
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
"""
|
| 7 |
+
from alembic import op
|
| 8 |
+
import sqlalchemy as sa
|
| 9 |
+
|
| 10 |
+
${imports if imports else ""}
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = ${repr(up_revision)}
|
| 14 |
+
down_revision = ${repr(down_revision)}
|
| 15 |
+
branch_labels = ${repr(branch_labels)}
|
| 16 |
+
depends_on = ${repr(depends_on)}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
${upgrades if upgrades else "pass"}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def downgrade() -> None:
|
| 24 |
+
${downgrades if downgrades else "pass"}
|
backend/alembic/versions/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""AIDidact FastAPI backend package."""
|
backend/app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API router package."""
|
backend/app/api/deps.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from app.db.session import get_db_session
|
backend/app/api/router.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
from app.api.routes import assessments, auth, learners, modules, progress, recommendations
|
| 4 |
+
|
| 5 |
+
api_router = APIRouter()
|
| 6 |
+
api_router.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
| 7 |
+
api_router.include_router(learners.router, prefix="/learners", tags=["Learners"])
|
| 8 |
+
api_router.include_router(modules.router, prefix="/modules", tags=["Modules"])
|
| 9 |
+
api_router.include_router(recommendations.router, prefix="/learners", tags=["Recommendations"])
|
| 10 |
+
api_router.include_router(progress.router, tags=["Progress"])
|
| 11 |
+
api_router.include_router(assessments.router, prefix="/assessments", tags=["Assessments"])
|
backend/app/api/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Route modules for AIDidact."""
|
backend/app/api/routes/assessments.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import UUID
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, status
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.api.deps import get_db_session
|
| 7 |
+
from app.models.schemas import (
|
| 8 |
+
AssessmentAttempt,
|
| 9 |
+
AssessmentAttemptCreate,
|
| 10 |
+
AssessmentAttemptResult,
|
| 11 |
+
AssessmentResponseSubmission,
|
| 12 |
+
)
|
| 13 |
+
from app.services import engines, repository
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.post("/{assessment_id}/attempts", response_model=AssessmentAttempt, status_code=status.HTTP_201_CREATED)
|
| 19 |
+
async def create_attempt(
|
| 20 |
+
assessment_id: UUID,
|
| 21 |
+
payload: AssessmentAttemptCreate,
|
| 22 |
+
session: AsyncSession = Depends(get_db_session),
|
| 23 |
+
) -> AssessmentAttempt:
|
| 24 |
+
await repository.get_learner(session, payload.learner_id)
|
| 25 |
+
assessment = await repository.get_assessment(session, assessment_id)
|
| 26 |
+
attempt = engines.generate_assessment_attempt(assessment, payload.learner_id)
|
| 27 |
+
return await repository.create_attempt(session, attempt)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.post("/attempts/{attempt_id}/responses", status_code=status.HTTP_202_ACCEPTED)
|
| 31 |
+
async def submit_responses(
|
| 32 |
+
attempt_id: UUID,
|
| 33 |
+
payload: AssessmentResponseSubmission,
|
| 34 |
+
session: AsyncSession = Depends(get_db_session),
|
| 35 |
+
) -> dict[str, str]:
|
| 36 |
+
attempt = await repository.get_attempt(session, attempt_id)
|
| 37 |
+
result = engines.evaluate_attempt(attempt, len(payload.responses))
|
| 38 |
+
await repository.save_result(session, result)
|
| 39 |
+
return {"status": "accepted"}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.get("/attempts/{attempt_id}", response_model=AssessmentAttemptResult)
|
| 43 |
+
async def get_attempt_result(
|
| 44 |
+
attempt_id: UUID, session: AsyncSession = Depends(get_db_session)
|
| 45 |
+
) -> AssessmentAttemptResult:
|
| 46 |
+
return await repository.get_result(session, attempt_id)
|
backend/app/api/routes/auth.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
|
| 4 |
+
from app.api.deps import get_db_session
|
| 5 |
+
from app.models.schemas import (
|
| 6 |
+
LearnerPreferences,
|
| 7 |
+
LearnerRecord,
|
| 8 |
+
LearnerRegistrationResponse,
|
| 9 |
+
RegisterLearnerRequest,
|
| 10 |
+
)
|
| 11 |
+
from app.services import engines, repository
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/register", response_model=LearnerRegistrationResponse, status_code=status.HTTP_201_CREATED)
|
| 17 |
+
async def register_learner(
|
| 18 |
+
payload: RegisterLearnerRequest, session: AsyncSession = Depends(get_db_session)
|
| 19 |
+
) -> LearnerRegistrationResponse:
|
| 20 |
+
learner = LearnerRecord(
|
| 21 |
+
email=payload.email,
|
| 22 |
+
first_name=payload.first_name,
|
| 23 |
+
last_name=payload.last_name,
|
| 24 |
+
locale=payload.locale,
|
| 25 |
+
timezone=payload.timezone,
|
| 26 |
+
educational_level=payload.educational_level,
|
| 27 |
+
consent_personalization=payload.consent_personalization,
|
| 28 |
+
consent_analytics=payload.consent_analytics,
|
| 29 |
+
consent_version=payload.consent_version,
|
| 30 |
+
prior_learning=payload.prior_learning,
|
| 31 |
+
goals=payload.goals,
|
| 32 |
+
preferences=payload.preferences or LearnerPreferences(),
|
| 33 |
+
)
|
| 34 |
+
await repository.create_learner(session, engines.build_profile(learner))
|
| 35 |
+
return LearnerRegistrationResponse(learner_id=learner.id)
|
backend/app/api/routes/learners.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import UUID
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.api.deps import get_db_session
|
| 7 |
+
from app.models.schemas import DiagnosticSubmission, LearnerProfile, UpdateLearnerProfileRequest
|
| 8 |
+
from app.services import engines, repository
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/{learner_id}/profile", response_model=LearnerProfile)
|
| 14 |
+
async def get_profile(learner_id: UUID, session: AsyncSession = Depends(get_db_session)) -> LearnerProfile:
|
| 15 |
+
learner = await repository.get_learner(session, learner_id)
|
| 16 |
+
return engines.learner_to_profile(learner)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.patch("/{learner_id}/profile", response_model=LearnerProfile)
|
| 20 |
+
async def update_profile(
|
| 21 |
+
learner_id: UUID,
|
| 22 |
+
payload: UpdateLearnerProfileRequest,
|
| 23 |
+
session: AsyncSession = Depends(get_db_session),
|
| 24 |
+
) -> LearnerProfile:
|
| 25 |
+
learner = await repository.get_learner(session, learner_id)
|
| 26 |
+
if payload.goals is not None:
|
| 27 |
+
learner.goals = payload.goals
|
| 28 |
+
if payload.preferences is not None:
|
| 29 |
+
learner.preferences = payload.preferences
|
| 30 |
+
await repository.update_learner(session, engines.build_profile(learner))
|
| 31 |
+
return engines.learner_to_profile(learner)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("/{learner_id}/diagnostic-assessment", response_model=LearnerProfile)
|
| 35 |
+
async def submit_diagnostic(
|
| 36 |
+
learner_id: UUID,
|
| 37 |
+
payload: DiagnosticSubmission,
|
| 38 |
+
session: AsyncSession = Depends(get_db_session),
|
| 39 |
+
) -> LearnerProfile:
|
| 40 |
+
learner = await repository.get_learner(session, learner_id)
|
| 41 |
+
learner = await repository.update_learner(session, engines.build_profile(learner, payload))
|
| 42 |
+
return engines.learner_to_profile(learner)
|
backend/app/api/routes/modules.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import UUID
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.api.deps import get_db_session
|
| 7 |
+
from app.models.schemas import ModuleDetail
|
| 8 |
+
from app.services import repository
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("", response_model=list[ModuleDetail])
|
| 14 |
+
async def list_modules(session: AsyncSession = Depends(get_db_session)) -> list[ModuleDetail]:
|
| 15 |
+
return await repository.list_modules(session)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.get("/{module_id}", response_model=ModuleDetail)
|
| 19 |
+
async def get_module(module_id: UUID, session: AsyncSession = Depends(get_db_session)) -> ModuleDetail:
|
| 20 |
+
return await repository.get_module(session, module_id)
|
backend/app/api/routes/progress.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import UUID
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, status
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.api.deps import get_db_session
|
| 7 |
+
from app.models.schemas import Enrollment, EnrollmentCreate, InteractionEvent, LearnerDashboard, ProgressSummary
|
| 8 |
+
from app.services import engines, repository
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post("/enrollments", response_model=Enrollment, status_code=status.HTTP_201_CREATED)
|
| 14 |
+
async def create_enrollment(
|
| 15 |
+
payload: EnrollmentCreate, session: AsyncSession = Depends(get_db_session)
|
| 16 |
+
) -> Enrollment:
|
| 17 |
+
await repository.get_learner(session, payload.learner_id)
|
| 18 |
+
await repository.get_module(session, payload.module_id)
|
| 19 |
+
enrollment = Enrollment(learner_id=payload.learner_id, module_id=payload.module_id)
|
| 20 |
+
return await repository.create_enrollment(session, enrollment)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/learners/{learner_id}/progress", response_model=ProgressSummary)
|
| 24 |
+
async def get_progress(
|
| 25 |
+
learner_id: UUID, session: AsyncSession = Depends(get_db_session)
|
| 26 |
+
) -> ProgressSummary:
|
| 27 |
+
await repository.get_learner(session, learner_id)
|
| 28 |
+
return await engines.build_progress_summary(session, learner_id)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("/interactions", status_code=status.HTTP_202_ACCEPTED)
|
| 32 |
+
async def create_interaction(
|
| 33 |
+
payload: InteractionEvent, session: AsyncSession = Depends(get_db_session)
|
| 34 |
+
) -> dict[str, str]:
|
| 35 |
+
await repository.get_learner(session, payload.learner_id)
|
| 36 |
+
await repository.create_interaction(session, payload)
|
| 37 |
+
return {"status": "accepted"}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/learners/{learner_id}/dashboard", response_model=LearnerDashboard)
|
| 41 |
+
async def get_dashboard(
|
| 42 |
+
learner_id: UUID, session: AsyncSession = Depends(get_db_session)
|
| 43 |
+
) -> LearnerDashboard:
|
| 44 |
+
learner = await repository.get_learner(session, learner_id)
|
| 45 |
+
return await engines.build_dashboard(session, learner)
|
backend/app/api/routes/recommendations.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from uuid import UUID
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.api.deps import get_db_session
|
| 7 |
+
from app.models.schemas import RecommendationResponse
|
| 8 |
+
from app.services import engines, repository
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/{learner_id}/recommendations", response_model=RecommendationResponse)
|
| 14 |
+
async def get_recommendations(
|
| 15 |
+
learner_id: UUID, session: AsyncSession = Depends(get_db_session)
|
| 16 |
+
) -> RecommendationResponse:
|
| 17 |
+
learner = await repository.get_learner(session, learner_id)
|
| 18 |
+
modules = await repository.list_modules(session)
|
| 19 |
+
return engines.rank_modules(learner, modules)
|
backend/app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Core configuration and shared utilities."""
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
|
| 3 |
+
from pydantic import Field
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
app_name: str = "AIDidact API"
|
| 9 |
+
app_version: str = "0.1.0"
|
| 10 |
+
api_prefix: str = "/v1"
|
| 11 |
+
environment: str = Field(default="development")
|
| 12 |
+
debug: bool = Field(default=True)
|
| 13 |
+
allowed_origins: list[str] = Field(default_factory=lambda: ["*"])
|
| 14 |
+
database_url: str = Field(default="sqlite+aiosqlite:///./aididact.db")
|
| 15 |
+
sql_echo: bool = Field(default=False)
|
| 16 |
+
auto_create_tables: bool = Field(default=True)
|
| 17 |
+
app_port: int = Field(default=7860)
|
| 18 |
+
|
| 19 |
+
model_config = SettingsConfigDict(
|
| 20 |
+
env_file=".env",
|
| 21 |
+
env_file_encoding="utf-8",
|
| 22 |
+
case_sensitive=False,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@lru_cache
|
| 27 |
+
def get_settings() -> Settings:
|
| 28 |
+
return Settings()
|
backend/app/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Storage backends for the AIDidact FastAPI scaffold."""
|
backend/app/db/base.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Base(DeclarativeBase):
|
| 5 |
+
pass
|
backend/app/db/session.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import AsyncGenerator
|
| 2 |
+
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 4 |
+
|
| 5 |
+
from app.core.config import get_settings
|
| 6 |
+
|
| 7 |
+
settings = get_settings()
|
| 8 |
+
|
| 9 |
+
engine_kwargs: dict = {
|
| 10 |
+
"echo": settings.sql_echo,
|
| 11 |
+
"future": True,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
if settings.database_url.startswith("sqlite+aiosqlite"):
|
| 15 |
+
engine_kwargs["connect_args"] = {"check_same_thread": False}
|
| 16 |
+
|
| 17 |
+
engine = create_async_engine(
|
| 18 |
+
settings.database_url,
|
| 19 |
+
**engine_kwargs,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
AsyncSessionLocal = async_sessionmaker(
|
| 23 |
+
bind=engine,
|
| 24 |
+
class_=AsyncSession,
|
| 25 |
+
expire_on_commit=False,
|
| 26 |
+
autoflush=False,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
| 31 |
+
async with AsyncSessionLocal() as session:
|
| 32 |
+
yield session
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
|
| 6 |
+
from app.api.router import api_router
|
| 7 |
+
from app.core.config import get_settings
|
| 8 |
+
from app.db.base import Base
|
| 9 |
+
from app.db.session import AsyncSessionLocal, engine
|
| 10 |
+
from app.models import orm # noqa: F401
|
| 11 |
+
from app.services.bootstrap import seed_demo_data
|
| 12 |
+
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(_: FastAPI):
|
| 18 |
+
if settings.auto_create_tables:
|
| 19 |
+
async with engine.begin() as conn:
|
| 20 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 21 |
+
async with AsyncSessionLocal() as session:
|
| 22 |
+
await seed_demo_data(session)
|
| 23 |
+
yield
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
app = FastAPI(
|
| 27 |
+
title=settings.app_name,
|
| 28 |
+
version=settings.app_version,
|
| 29 |
+
debug=settings.debug,
|
| 30 |
+
lifespan=lifespan,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
app.add_middleware(
|
| 34 |
+
CORSMiddleware,
|
| 35 |
+
allow_origins=settings.allowed_origins,
|
| 36 |
+
allow_credentials=True,
|
| 37 |
+
allow_methods=["*"],
|
| 38 |
+
allow_headers=["*"],
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.get("/health")
|
| 43 |
+
def healthcheck() -> dict[str, str]:
|
| 44 |
+
return {"status": "ok"}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
app.include_router(api_router, prefix=settings.api_prefix)
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models used across the backend."""
|
backend/app/models/orm.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, Uuid, func
|
| 7 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 8 |
+
|
| 9 |
+
from app.db.base import Base
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LearnerORM(Base):
|
| 13 |
+
__tablename__ = "learners"
|
| 14 |
+
|
| 15 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 16 |
+
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
| 17 |
+
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 18 |
+
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 19 |
+
locale: Mapped[str] = mapped_column(String(20), default="en-US")
|
| 20 |
+
timezone: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 21 |
+
educational_level: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 22 |
+
consent_personalization: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 23 |
+
consent_analytics: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 24 |
+
consent_version: Mapped[str | None] = mapped_column(String(50))
|
| 25 |
+
readiness_level: Mapped[str] = mapped_column(String(30), default="unknown")
|
| 26 |
+
readiness_score: Mapped[float] = mapped_column(Float, default=0.0)
|
| 27 |
+
mastery_map: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 28 |
+
risk_profile: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 29 |
+
profile_vector: Mapped[list] = mapped_column(JSON, default=list)
|
| 30 |
+
preferences: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 31 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 32 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
| 33 |
+
|
| 34 |
+
prior_learning_items: Mapped[list["LearnerPriorLearningORM"]] = relationship(
|
| 35 |
+
back_populates="learner", cascade="all, delete-orphan"
|
| 36 |
+
)
|
| 37 |
+
goals: Mapped[list["LearnerGoalORM"]] = relationship(back_populates="learner", cascade="all, delete-orphan")
|
| 38 |
+
enrollments: Mapped[list["EnrollmentORM"]] = relationship(back_populates="learner", cascade="all, delete-orphan")
|
| 39 |
+
interactions: Mapped[list["InteractionORM"]] = relationship(back_populates="learner", cascade="all, delete-orphan")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class LearnerPriorLearningORM(Base):
|
| 43 |
+
__tablename__ = "learner_prior_learning"
|
| 44 |
+
|
| 45 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 46 |
+
learner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("learners.id", ondelete="CASCADE"), nullable=False)
|
| 47 |
+
learning_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
| 48 |
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 49 |
+
provider: Mapped[str | None] = mapped_column(String(255))
|
| 50 |
+
description: Mapped[str | None] = mapped_column(Text)
|
| 51 |
+
evidence_url: Mapped[str | None] = mapped_column(Text)
|
| 52 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 53 |
+
|
| 54 |
+
learner: Mapped["LearnerORM"] = relationship(back_populates="prior_learning_items")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class LearnerGoalORM(Base):
|
| 58 |
+
__tablename__ = "learner_goals"
|
| 59 |
+
|
| 60 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 61 |
+
learner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("learners.id", ondelete="CASCADE"), nullable=False)
|
| 62 |
+
goal_horizon: Mapped[str] = mapped_column(String(20), nullable=False)
|
| 63 |
+
goal_text: Mapped[str] = mapped_column(Text, nullable=False)
|
| 64 |
+
target_date: Mapped[datetime | None] = mapped_column(Date)
|
| 65 |
+
priority: Mapped[int] = mapped_column(Integer, default=3)
|
| 66 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 67 |
+
|
| 68 |
+
learner: Mapped["LearnerORM"] = relationship(back_populates="goals")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class ModuleORM(Base):
|
| 72 |
+
__tablename__ = "modules"
|
| 73 |
+
|
| 74 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 75 |
+
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
| 76 |
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 77 |
+
description: Mapped[str] = mapped_column(Text, nullable=False)
|
| 78 |
+
ects_credits: Mapped[float] = mapped_column(Float, default=5.0)
|
| 79 |
+
difficulty_level: Mapped[str] = mapped_column(String(30), nullable=False)
|
| 80 |
+
estimated_total_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 81 |
+
self_paced: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 82 |
+
adaptive_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 83 |
+
topics: Mapped[list] = mapped_column(JSON, default=list)
|
| 84 |
+
completion_criteria: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 85 |
+
prerequisites: Mapped[list] = mapped_column(JSON, default=list)
|
| 86 |
+
module_vector: Mapped[list] = mapped_column(JSON, default=list)
|
| 87 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 88 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
| 89 |
+
|
| 90 |
+
objectives: Mapped[list["ModuleObjectiveORM"]] = relationship(
|
| 91 |
+
back_populates="module", cascade="all, delete-orphan", order_by="ModuleObjectiveORM.sort_order"
|
| 92 |
+
)
|
| 93 |
+
units: Mapped[list["ModuleUnitORM"]] = relationship(
|
| 94 |
+
back_populates="module", cascade="all, delete-orphan", order_by="ModuleUnitORM.sort_order"
|
| 95 |
+
)
|
| 96 |
+
activities: Mapped[list["ModuleActivityORM"]] = relationship(
|
| 97 |
+
back_populates="module", cascade="all, delete-orphan", order_by="ModuleActivityORM.sort_order"
|
| 98 |
+
)
|
| 99 |
+
assessments: Mapped[list["AssessmentDefinitionORM"]] = relationship(
|
| 100 |
+
back_populates="module", cascade="all, delete-orphan"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class ModuleObjectiveORM(Base):
|
| 105 |
+
__tablename__ = "module_objectives"
|
| 106 |
+
|
| 107 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 108 |
+
module_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
|
| 109 |
+
text: Mapped[str] = mapped_column(Text, nullable=False)
|
| 110 |
+
bloom_level: Mapped[str] = mapped_column(String(30), nullable=False)
|
| 111 |
+
competency_tag: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 112 |
+
measurable_outcome: Mapped[str] = mapped_column(Text, nullable=False)
|
| 113 |
+
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
| 114 |
+
|
| 115 |
+
module: Mapped["ModuleORM"] = relationship(back_populates="objectives")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class ModuleUnitORM(Base):
|
| 119 |
+
__tablename__ = "module_units"
|
| 120 |
+
|
| 121 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 122 |
+
module_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
|
| 123 |
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 124 |
+
unit_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
| 125 |
+
chunk_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 126 |
+
content_ref: Mapped[str] = mapped_column(Text, nullable=False)
|
| 127 |
+
adaptive_rules: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 128 |
+
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
| 129 |
+
|
| 130 |
+
module: Mapped["ModuleORM"] = relationship(back_populates="units")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class ModuleActivityORM(Base):
|
| 134 |
+
__tablename__ = "module_activities"
|
| 135 |
+
|
| 136 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 137 |
+
module_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
|
| 138 |
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 139 |
+
activity_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
| 140 |
+
instructions: Mapped[str] = mapped_column(Text, nullable=False)
|
| 141 |
+
max_score: Mapped[float | None] = mapped_column(Float)
|
| 142 |
+
required: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 143 |
+
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
| 144 |
+
|
| 145 |
+
module: Mapped["ModuleORM"] = relationship(back_populates="activities")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class AssessmentDefinitionORM(Base):
|
| 149 |
+
__tablename__ = "assessments"
|
| 150 |
+
|
| 151 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 152 |
+
module_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
|
| 153 |
+
assessment_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
| 154 |
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 155 |
+
blueprint: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 156 |
+
time_limit_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 157 |
+
|
| 158 |
+
module: Mapped["ModuleORM"] = relationship(back_populates="assessments")
|
| 159 |
+
attempts: Mapped[list["AssessmentAttemptORM"]] = relationship(back_populates="assessment")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class EnrollmentORM(Base):
|
| 163 |
+
__tablename__ = "enrollments"
|
| 164 |
+
__table_args__ = (UniqueConstraint("learner_id", "module_id", name="uq_enrollment_learner_module"),)
|
| 165 |
+
|
| 166 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 167 |
+
learner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("learners.id", ondelete="CASCADE"), nullable=False)
|
| 168 |
+
module_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
|
| 169 |
+
enrollment_status: Mapped[str] = mapped_column(String(30), default="active")
|
| 170 |
+
recommended_by: Mapped[str] = mapped_column(String(30), default="system")
|
| 171 |
+
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 172 |
+
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 173 |
+
mastery_score: Mapped[float] = mapped_column(Float, default=0.0)
|
| 174 |
+
|
| 175 |
+
learner: Mapped["LearnerORM"] = relationship(back_populates="enrollments")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class InteractionORM(Base):
|
| 179 |
+
__tablename__ = "interactions"
|
| 180 |
+
|
| 181 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 182 |
+
learner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("learners.id", ondelete="CASCADE"), nullable=False)
|
| 183 |
+
module_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("modules.id", ondelete="SET NULL"))
|
| 184 |
+
unit_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
| 185 |
+
activity_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
| 186 |
+
session_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
|
| 187 |
+
event_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 188 |
+
event_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 189 |
+
duration_ms: Mapped[int | None] = mapped_column(Integer)
|
| 190 |
+
payload: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 191 |
+
client_context: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 192 |
+
|
| 193 |
+
learner: Mapped["LearnerORM"] = relationship(back_populates="interactions")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class AssessmentAttemptORM(Base):
|
| 197 |
+
__tablename__ = "assessment_attempts"
|
| 198 |
+
|
| 199 |
+
id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 200 |
+
assessment_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("assessments.id", ondelete="CASCADE"), nullable=False)
|
| 201 |
+
learner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("learners.id", ondelete="CASCADE"), nullable=False)
|
| 202 |
+
time_limit_minutes: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 203 |
+
items: Mapped[list] = mapped_column(JSON, default=list)
|
| 204 |
+
status: Mapped[str] = mapped_column(String(30), default="in_progress")
|
| 205 |
+
integrity_status: Mapped[str] = mapped_column(String(20), default="pending")
|
| 206 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 207 |
+
score: Mapped[float | None] = mapped_column(Float)
|
| 208 |
+
pass_status: Mapped[str | None] = mapped_column(String(20))
|
| 209 |
+
feedback: Mapped[list] = mapped_column(JSON, default=list)
|
| 210 |
+
human_review_required: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 211 |
+
|
| 212 |
+
assessment: Mapped["AssessmentDefinitionORM"] = relationship(back_populates="attempts")
|
backend/app/models/schemas.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date, datetime, timezone
|
| 4 |
+
from typing import Any, Literal
|
| 5 |
+
from uuid import UUID, uuid4
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def utcnow() -> datetime:
|
| 11 |
+
return datetime.now(timezone.utc)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PriorLearningItem(BaseModel):
|
| 15 |
+
model_config = ConfigDict(from_attributes=True)
|
| 16 |
+
|
| 17 |
+
learning_type: Literal["formal", "non_formal", "informal"]
|
| 18 |
+
title: str
|
| 19 |
+
provider: str | None = None
|
| 20 |
+
description: str | None = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LearnerGoal(BaseModel):
|
| 24 |
+
model_config = ConfigDict(from_attributes=True)
|
| 25 |
+
|
| 26 |
+
goal_horizon: Literal["short_term", "long_term"]
|
| 27 |
+
goal_text: str
|
| 28 |
+
target_date: date | None = None
|
| 29 |
+
priority: int = 3
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LearnerPreferences(BaseModel):
|
| 33 |
+
model_config = ConfigDict(from_attributes=True)
|
| 34 |
+
|
| 35 |
+
preferred_modalities: list[str] = Field(default_factory=list)
|
| 36 |
+
preferred_session_length_minutes: int | None = None
|
| 37 |
+
language_preferences: list[str] = Field(default_factory=list)
|
| 38 |
+
accessibility_needs: dict[str, Any] = Field(default_factory=dict)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class RegisterLearnerRequest(BaseModel):
|
| 42 |
+
email: EmailStr
|
| 43 |
+
first_name: str
|
| 44 |
+
last_name: str
|
| 45 |
+
locale: str = "en-US"
|
| 46 |
+
timezone: str
|
| 47 |
+
educational_level: str
|
| 48 |
+
consent_personalization: bool = False
|
| 49 |
+
consent_analytics: bool = False
|
| 50 |
+
consent_version: str | None = None
|
| 51 |
+
prior_learning: list[PriorLearningItem]
|
| 52 |
+
goals: list[LearnerGoal]
|
| 53 |
+
preferences: LearnerPreferences | None = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class LearnerRegistrationResponse(BaseModel):
|
| 57 |
+
learner_id: UUID
|
| 58 |
+
next_step: str = "complete_diagnostic_assessment"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class DiagnosticSubmission(BaseModel):
|
| 62 |
+
responses: list[dict[str, Any]]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class UpdateLearnerProfileRequest(BaseModel):
|
| 66 |
+
goals: list[LearnerGoal] | None = None
|
| 67 |
+
preferences: LearnerPreferences | None = None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class LearnerProfile(BaseModel):
|
| 71 |
+
learner_id: UUID
|
| 72 |
+
readiness_level: str
|
| 73 |
+
readiness_score: float
|
| 74 |
+
goals: list[LearnerGoal]
|
| 75 |
+
preferences: LearnerPreferences
|
| 76 |
+
mastery_map: dict[str, float]
|
| 77 |
+
risk_profile: dict[str, Any]
|
| 78 |
+
profile_vector: list[float]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ModuleObjective(BaseModel):
|
| 82 |
+
model_config = ConfigDict(from_attributes=True)
|
| 83 |
+
|
| 84 |
+
id: UUID = Field(default_factory=uuid4)
|
| 85 |
+
text: str
|
| 86 |
+
bloom_level: str
|
| 87 |
+
competency_tag: str
|
| 88 |
+
measurable_outcome: str
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class ModuleUnit(BaseModel):
|
| 92 |
+
model_config = ConfigDict(from_attributes=True)
|
| 93 |
+
|
| 94 |
+
id: UUID = Field(default_factory=uuid4)
|
| 95 |
+
title: str
|
| 96 |
+
unit_type: str
|
| 97 |
+
chunk_minutes: int
|
| 98 |
+
content_ref: str
|
| 99 |
+
adaptive_rules: dict[str, Any] = Field(default_factory=dict)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class ModuleActivity(BaseModel):
|
| 103 |
+
model_config = ConfigDict(from_attributes=True)
|
| 104 |
+
|
| 105 |
+
id: UUID = Field(default_factory=uuid4)
|
| 106 |
+
title: str
|
| 107 |
+
activity_type: str
|
| 108 |
+
instructions: str
|
| 109 |
+
max_score: float | None = None
|
| 110 |
+
required: bool = True
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class ModuleCompletionCriteria(BaseModel):
|
| 114 |
+
model_config = ConfigDict(from_attributes=True)
|
| 115 |
+
|
| 116 |
+
min_unit_completion_ratio: float
|
| 117 |
+
min_formative_score: float
|
| 118 |
+
require_summative_pass: bool = True
|
| 119 |
+
min_time_on_task_minutes: int | None = None
|
| 120 |
+
integrity_clearance_required: bool = True
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ModuleSummary(BaseModel):
|
| 124 |
+
model_config = ConfigDict(from_attributes=True)
|
| 125 |
+
|
| 126 |
+
id: UUID
|
| 127 |
+
code: str
|
| 128 |
+
title: str
|
| 129 |
+
description: str
|
| 130 |
+
ects_credits: float = 5.0
|
| 131 |
+
difficulty_level: str
|
| 132 |
+
estimated_total_minutes: int
|
| 133 |
+
self_paced: bool = True
|
| 134 |
+
adaptive_enabled: bool = True
|
| 135 |
+
topics: list[str] = Field(default_factory=list)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class ModuleDetail(ModuleSummary):
|
| 139 |
+
objectives: list[ModuleObjective]
|
| 140 |
+
units: list[ModuleUnit]
|
| 141 |
+
activities: list[ModuleActivity]
|
| 142 |
+
completion_criteria: ModuleCompletionCriteria
|
| 143 |
+
prerequisites: list[UUID] = Field(default_factory=list)
|
| 144 |
+
module_vector: list[float] = Field(default_factory=list)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class RecommendationItem(BaseModel):
|
| 148 |
+
model_config = ConfigDict(from_attributes=True)
|
| 149 |
+
|
| 150 |
+
module_id: UUID
|
| 151 |
+
rank: int
|
| 152 |
+
score: float
|
| 153 |
+
reasons: list[str]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class RecommendationResponse(BaseModel):
|
| 157 |
+
learner_id: UUID
|
| 158 |
+
generated_at: datetime = Field(default_factory=utcnow)
|
| 159 |
+
items: list[RecommendationItem]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class EnrollmentCreate(BaseModel):
|
| 163 |
+
learner_id: UUID
|
| 164 |
+
module_id: UUID
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class Enrollment(BaseModel):
|
| 168 |
+
model_config = ConfigDict(from_attributes=True)
|
| 169 |
+
|
| 170 |
+
id: UUID = Field(default_factory=uuid4)
|
| 171 |
+
learner_id: UUID
|
| 172 |
+
module_id: UUID
|
| 173 |
+
enrollment_status: str = "active"
|
| 174 |
+
recommended_by: str = "system"
|
| 175 |
+
started_at: datetime | None = Field(default_factory=utcnow)
|
| 176 |
+
completed_at: datetime | None = None
|
| 177 |
+
mastery_score: float = 0.0
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class ProgressItem(BaseModel):
|
| 181 |
+
model_config = ConfigDict(from_attributes=True)
|
| 182 |
+
|
| 183 |
+
module_id: UUID
|
| 184 |
+
completion_percent: float
|
| 185 |
+
mastery_score: float
|
| 186 |
+
next_recommended_action: str
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class ProgressSummary(BaseModel):
|
| 190 |
+
learner_id: UUID
|
| 191 |
+
active_enrollments: list[ProgressItem]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class InteractionEvent(BaseModel):
|
| 195 |
+
model_config = ConfigDict(from_attributes=True)
|
| 196 |
+
|
| 197 |
+
learner_id: UUID
|
| 198 |
+
module_id: UUID | None = None
|
| 199 |
+
unit_id: UUID | None = None
|
| 200 |
+
activity_id: UUID | None = None
|
| 201 |
+
session_id: UUID | None = None
|
| 202 |
+
event_type: str
|
| 203 |
+
event_timestamp: datetime = Field(default_factory=utcnow)
|
| 204 |
+
duration_ms: int | None = None
|
| 205 |
+
payload: dict[str, Any] = Field(default_factory=dict)
|
| 206 |
+
client_context: dict[str, Any] = Field(default_factory=dict)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
class AssessmentAttemptCreate(BaseModel):
|
| 210 |
+
learner_id: UUID
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class AssessmentItem(BaseModel):
|
| 214 |
+
model_config = ConfigDict(from_attributes=True)
|
| 215 |
+
|
| 216 |
+
question_id: UUID = Field(default_factory=uuid4)
|
| 217 |
+
question_type: str
|
| 218 |
+
prompt: str
|
| 219 |
+
objective_tag: str
|
| 220 |
+
time_limit_seconds: int | None = None
|
| 221 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class AssessmentAttempt(BaseModel):
|
| 225 |
+
model_config = ConfigDict(from_attributes=True)
|
| 226 |
+
|
| 227 |
+
attempt_id: UUID = Field(default_factory=uuid4)
|
| 228 |
+
assessment_id: UUID
|
| 229 |
+
learner_id: UUID
|
| 230 |
+
time_limit_minutes: int
|
| 231 |
+
items: list[AssessmentItem]
|
| 232 |
+
status: str = "in_progress"
|
| 233 |
+
integrity_status: str = "pending"
|
| 234 |
+
created_at: datetime = Field(default_factory=utcnow)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
class AssessmentResponseItem(BaseModel):
|
| 238 |
+
question_id: UUID
|
| 239 |
+
answer: Any
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class AssessmentResponseSubmission(BaseModel):
|
| 243 |
+
responses: list[AssessmentResponseItem]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class AssessmentAttemptResult(BaseModel):
|
| 247 |
+
model_config = ConfigDict(from_attributes=True)
|
| 248 |
+
|
| 249 |
+
attempt_id: UUID
|
| 250 |
+
score: float
|
| 251 |
+
pass_status: str
|
| 252 |
+
integrity_status: str
|
| 253 |
+
feedback: list[str]
|
| 254 |
+
human_review_required: bool = False
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
class LearnerDashboard(BaseModel):
|
| 258 |
+
model_config = ConfigDict(from_attributes=True)
|
| 259 |
+
|
| 260 |
+
learner_id: UUID
|
| 261 |
+
mastery: dict[str, float]
|
| 262 |
+
time_on_task_minutes_7d: float
|
| 263 |
+
dropout_risk: float
|
| 264 |
+
success_probability: float
|
| 265 |
+
adaptive_feedback: list[str]
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class LearnerRecord(BaseModel):
|
| 269 |
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
| 270 |
+
|
| 271 |
+
id: UUID = Field(default_factory=uuid4)
|
| 272 |
+
email: EmailStr
|
| 273 |
+
first_name: str
|
| 274 |
+
last_name: str
|
| 275 |
+
locale: str
|
| 276 |
+
timezone: str
|
| 277 |
+
educational_level: str
|
| 278 |
+
consent_personalization: bool
|
| 279 |
+
consent_analytics: bool
|
| 280 |
+
consent_version: str | None = None
|
| 281 |
+
prior_learning: list[PriorLearningItem] = Field(default_factory=list)
|
| 282 |
+
goals: list[LearnerGoal] = Field(default_factory=list)
|
| 283 |
+
preferences: LearnerPreferences = Field(default_factory=LearnerPreferences)
|
| 284 |
+
readiness_level: str = "unknown"
|
| 285 |
+
readiness_score: float = 0.0
|
| 286 |
+
mastery_map: dict[str, float] = Field(default_factory=dict)
|
| 287 |
+
risk_profile: dict[str, Any] = Field(default_factory=dict)
|
| 288 |
+
profile_vector: list[float] = Field(default_factory=list)
|
| 289 |
+
created_at: datetime = Field(default_factory=utcnow)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class AssessmentDefinition(BaseModel):
|
| 293 |
+
model_config = ConfigDict(from_attributes=True)
|
| 294 |
+
|
| 295 |
+
id: UUID = Field(default_factory=uuid4)
|
| 296 |
+
module_id: UUID
|
| 297 |
+
assessment_type: str
|
| 298 |
+
title: str
|
| 299 |
+
blueprint: dict[str, Any]
|
| 300 |
+
time_limit_minutes: int
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class StoredInteraction(BaseModel):
|
| 304 |
+
model_config = ConfigDict(from_attributes=True)
|
| 305 |
+
|
| 306 |
+
id: UUID = Field(default_factory=uuid4)
|
| 307 |
+
event: InteractionEvent
|
backend/app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Domain services for recommendations, assessments, analytics, and storage."""
|
backend/app/services/bootstrap.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
|
| 5 |
+
from app.models.schemas import (
|
| 6 |
+
AssessmentDefinition,
|
| 7 |
+
ModuleActivity,
|
| 8 |
+
ModuleCompletionCriteria,
|
| 9 |
+
ModuleDetail,
|
| 10 |
+
ModuleObjective,
|
| 11 |
+
ModuleUnit,
|
| 12 |
+
)
|
| 13 |
+
from app.services import repository
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def seed_demo_data(session: AsyncSession) -> None:
|
| 17 |
+
if await repository.has_modules(session):
|
| 18 |
+
return
|
| 19 |
+
|
| 20 |
+
module_one = ModuleDetail(
|
| 21 |
+
id="11111111-1111-1111-1111-111111111111",
|
| 22 |
+
code="AID-101",
|
| 23 |
+
title="AI-Supported Self-Regulated Learning",
|
| 24 |
+
description="Introduces learners to using AI for planning, monitoring, and reflecting on self-directed study.",
|
| 25 |
+
difficulty_level="foundation",
|
| 26 |
+
estimated_total_minutes=750,
|
| 27 |
+
topics=["self-regulation", "ai literacy"],
|
| 28 |
+
objectives=[
|
| 29 |
+
ModuleObjective(
|
| 30 |
+
text="Explain how AI tools can support planning and self-monitoring.",
|
| 31 |
+
bloom_level="understand",
|
| 32 |
+
competency_tag="self_regulated_learning",
|
| 33 |
+
measurable_outcome="Learner explains planning and monitoring strategies.",
|
| 34 |
+
),
|
| 35 |
+
ModuleObjective(
|
| 36 |
+
text="Apply AI-supported reflection to improve study habits.",
|
| 37 |
+
bloom_level="apply",
|
| 38 |
+
competency_tag="reflective_practice",
|
| 39 |
+
measurable_outcome="Learner produces an evidence-based reflection.",
|
| 40 |
+
),
|
| 41 |
+
],
|
| 42 |
+
units=[
|
| 43 |
+
ModuleUnit(
|
| 44 |
+
title="Planning with AI",
|
| 45 |
+
unit_type="video",
|
| 46 |
+
chunk_minutes=12,
|
| 47 |
+
content_ref="/content/modules/aid-101/planning-with-ai",
|
| 48 |
+
),
|
| 49 |
+
ModuleUnit(
|
| 50 |
+
title="Monitoring Progress",
|
| 51 |
+
unit_type="reading",
|
| 52 |
+
chunk_minutes=10,
|
| 53 |
+
content_ref="/content/modules/aid-101/monitoring-progress",
|
| 54 |
+
),
|
| 55 |
+
],
|
| 56 |
+
activities=[
|
| 57 |
+
ModuleActivity(
|
| 58 |
+
title="Study Strategy Reflection",
|
| 59 |
+
activity_type="reflection",
|
| 60 |
+
instructions="Reflect on how AI could improve your weekly study routine.",
|
| 61 |
+
max_score=20,
|
| 62 |
+
)
|
| 63 |
+
],
|
| 64 |
+
completion_criteria=ModuleCompletionCriteria(
|
| 65 |
+
min_unit_completion_ratio=0.85,
|
| 66 |
+
min_formative_score=70,
|
| 67 |
+
min_time_on_task_minutes=300,
|
| 68 |
+
),
|
| 69 |
+
module_vector=[0.35, 0.7, 0.5],
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
module_two = ModuleDetail(
|
| 73 |
+
id="22222222-2222-2222-2222-222222222222",
|
| 74 |
+
code="AID-205",
|
| 75 |
+
title="Scenario-Based Problem Solving with AI",
|
| 76 |
+
description="Builds authentic problem-solving skills through case analysis and AI-supported reasoning.",
|
| 77 |
+
difficulty_level="intermediate",
|
| 78 |
+
estimated_total_minutes=780,
|
| 79 |
+
topics=["problem solving", "scenario analysis"],
|
| 80 |
+
objectives=[
|
| 81 |
+
ModuleObjective(
|
| 82 |
+
text="Analyze scenario constraints and choose suitable AI-supported strategies.",
|
| 83 |
+
bloom_level="analyze",
|
| 84 |
+
competency_tag="applied_ai_literacy",
|
| 85 |
+
measurable_outcome="Learner compares alternative strategies in a case.",
|
| 86 |
+
),
|
| 87 |
+
ModuleObjective(
|
| 88 |
+
text="Evaluate outputs for reliability, bias, and usefulness.",
|
| 89 |
+
bloom_level="evaluate",
|
| 90 |
+
competency_tag="critical_evaluation",
|
| 91 |
+
measurable_outcome="Learner critiques AI output quality.",
|
| 92 |
+
),
|
| 93 |
+
],
|
| 94 |
+
units=[
|
| 95 |
+
ModuleUnit(
|
| 96 |
+
title="Interpreting Realistic Cases",
|
| 97 |
+
unit_type="simulation",
|
| 98 |
+
chunk_minutes=15,
|
| 99 |
+
content_ref="/content/modules/aid-205/interpreting-cases",
|
| 100 |
+
),
|
| 101 |
+
ModuleUnit(
|
| 102 |
+
title="Evaluating AI Output",
|
| 103 |
+
unit_type="interactive",
|
| 104 |
+
chunk_minutes=14,
|
| 105 |
+
content_ref="/content/modules/aid-205/evaluating-output",
|
| 106 |
+
),
|
| 107 |
+
],
|
| 108 |
+
activities=[
|
| 109 |
+
ModuleActivity(
|
| 110 |
+
title="Case Analysis Quiz",
|
| 111 |
+
activity_type="quiz",
|
| 112 |
+
instructions="Select and justify the best response in each scenario.",
|
| 113 |
+
max_score=30,
|
| 114 |
+
)
|
| 115 |
+
],
|
| 116 |
+
completion_criteria=ModuleCompletionCriteria(
|
| 117 |
+
min_unit_completion_ratio=0.9,
|
| 118 |
+
min_formative_score=75,
|
| 119 |
+
min_time_on_task_minutes=320,
|
| 120 |
+
),
|
| 121 |
+
prerequisites=["11111111-1111-1111-1111-111111111111"],
|
| 122 |
+
module_vector=[0.65, 0.8, 0.6],
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
await repository.create_module(session, module_one)
|
| 126 |
+
await repository.create_module(session, module_two)
|
| 127 |
+
|
| 128 |
+
await repository.create_assessment(
|
| 129 |
+
session,
|
| 130 |
+
AssessmentDefinition(
|
| 131 |
+
id="33333333-3333-3333-3333-333333333333",
|
| 132 |
+
module_id=module_one.id,
|
| 133 |
+
assessment_type="summative",
|
| 134 |
+
title="AID-101 Summative Assessment",
|
| 135 |
+
blueprint={
|
| 136 |
+
"objectives": ["self_regulated_learning", "reflective_practice"],
|
| 137 |
+
"time_per_item_seconds": 420,
|
| 138 |
+
},
|
| 139 |
+
time_limit_minutes=45,
|
| 140 |
+
)
|
| 141 |
+
)
|
backend/app/services/engines.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from statistics import mean
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
|
| 8 |
+
from app.models.schemas import (
|
| 9 |
+
AssessmentAttempt,
|
| 10 |
+
AssessmentAttemptResult,
|
| 11 |
+
AssessmentDefinition,
|
| 12 |
+
AssessmentItem,
|
| 13 |
+
DiagnosticSubmission,
|
| 14 |
+
InteractionEvent,
|
| 15 |
+
LearnerDashboard,
|
| 16 |
+
LearnerProfile,
|
| 17 |
+
LearnerRecord,
|
| 18 |
+
ModuleDetail,
|
| 19 |
+
ProgressItem,
|
| 20 |
+
ProgressSummary,
|
| 21 |
+
RecommendationItem,
|
| 22 |
+
RecommendationResponse,
|
| 23 |
+
)
|
| 24 |
+
from app.services import repository
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_profile(learner: LearnerRecord, diagnostic: DiagnosticSubmission | None = None) -> LearnerRecord:
|
| 28 |
+
prior_score = min(len(learner.prior_learning) / 10, 1.0)
|
| 29 |
+
goal_score = min(len(learner.goals) / 5, 1.0)
|
| 30 |
+
pref_score = 0.2 if learner.preferences.preferred_modalities else 0.0
|
| 31 |
+
readiness = 0.5
|
| 32 |
+
|
| 33 |
+
if diagnostic and diagnostic.responses:
|
| 34 |
+
raw = mean(float(item.get("score", 0.5)) for item in diagnostic.responses)
|
| 35 |
+
readiness = max(0.0, min(raw, 1.0))
|
| 36 |
+
|
| 37 |
+
learner.readiness_score = round(readiness * 100, 2)
|
| 38 |
+
learner.readiness_level = (
|
| 39 |
+
"advanced" if readiness >= 0.75 else "intermediate" if readiness >= 0.45 else "foundation"
|
| 40 |
+
)
|
| 41 |
+
learner.profile_vector = [
|
| 42 |
+
round(prior_score, 4),
|
| 43 |
+
round(goal_score, 4),
|
| 44 |
+
round(pref_score, 4),
|
| 45 |
+
round(readiness, 4),
|
| 46 |
+
]
|
| 47 |
+
learner.mastery_map = {
|
| 48 |
+
"self_regulated_learning": round(readiness, 2),
|
| 49 |
+
"applied_ai_literacy": round((prior_score + readiness) / 2, 2),
|
| 50 |
+
"reflective_practice": round((goal_score + pref_score + readiness) / 2, 2),
|
| 51 |
+
}
|
| 52 |
+
learner.risk_profile = {
|
| 53 |
+
"dropout_risk": round(max(0.1, 1 - readiness), 2),
|
| 54 |
+
"recommended_support_level": "high" if readiness < 0.4 else "standard",
|
| 55 |
+
}
|
| 56 |
+
return learner
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def learner_to_profile(learner: LearnerRecord) -> LearnerProfile:
|
| 60 |
+
return LearnerProfile(
|
| 61 |
+
learner_id=learner.id,
|
| 62 |
+
readiness_level=learner.readiness_level,
|
| 63 |
+
readiness_score=learner.readiness_score,
|
| 64 |
+
goals=learner.goals,
|
| 65 |
+
preferences=learner.preferences,
|
| 66 |
+
mastery_map=learner.mastery_map,
|
| 67 |
+
risk_profile=learner.risk_profile,
|
| 68 |
+
profile_vector=learner.profile_vector,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def rank_modules(learner: LearnerRecord, modules: list[ModuleDetail]) -> RecommendationResponse:
|
| 73 |
+
scored: list[RecommendationItem] = []
|
| 74 |
+
learner_topics = {goal.goal_text.lower() for goal in learner.goals}
|
| 75 |
+
|
| 76 |
+
for module in modules:
|
| 77 |
+
topic_match = sum(1 for topic in module.topics if any(topic.lower() in g for g in learner_topics))
|
| 78 |
+
readiness_alignment = 1 - abs((learner.readiness_score / 100) - module.module_vector[0])
|
| 79 |
+
workload_score = 1 - min(module.estimated_total_minutes / 1500, 1.0)
|
| 80 |
+
score = round(0.45 * topic_match + 0.35 * readiness_alignment + 0.20 * workload_score, 4)
|
| 81 |
+
reasons = [
|
| 82 |
+
f"Aligned with {module.topics[0] if module.topics else 'your stated goals'}",
|
| 83 |
+
f"Suitable for {learner.readiness_level} readiness",
|
| 84 |
+
"Supports a self-paced 5 ECTS pathway",
|
| 85 |
+
]
|
| 86 |
+
scored.append(RecommendationItem(module_id=module.id, rank=0, score=score, reasons=reasons))
|
| 87 |
+
|
| 88 |
+
ranked = sorted(scored, key=lambda item: item.score, reverse=True)
|
| 89 |
+
for index, item in enumerate(ranked, start=1):
|
| 90 |
+
item.rank = index
|
| 91 |
+
|
| 92 |
+
return RecommendationResponse(learner_id=learner.id, items=ranked[:10])
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def generate_assessment_attempt(assessment: AssessmentDefinition, learner_id: UUID) -> AssessmentAttempt:
|
| 96 |
+
objectives = assessment.blueprint.get("objectives", [])
|
| 97 |
+
items = [
|
| 98 |
+
AssessmentItem(
|
| 99 |
+
question_type="scenario_open",
|
| 100 |
+
prompt=f"Scenario {index + 1}: Apply {objective} in a realistic learning context and justify your response.",
|
| 101 |
+
objective_tag=objective,
|
| 102 |
+
time_limit_seconds=assessment.blueprint.get("time_per_item_seconds", 300),
|
| 103 |
+
metadata={"unique_variant_seed": f"{learner_id}-{index}"},
|
| 104 |
+
)
|
| 105 |
+
for index, objective in enumerate(objectives)
|
| 106 |
+
]
|
| 107 |
+
return AssessmentAttempt(
|
| 108 |
+
assessment_id=assessment.id,
|
| 109 |
+
learner_id=learner_id,
|
| 110 |
+
time_limit_minutes=assessment.time_limit_minutes,
|
| 111 |
+
items=items,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def evaluate_attempt(attempt: AssessmentAttempt, responses_count: int) -> AssessmentAttemptResult:
|
| 116 |
+
completion_ratio = responses_count / max(len(attempt.items), 1)
|
| 117 |
+
score = round(min(100.0, 55 + completion_ratio * 45), 2)
|
| 118 |
+
integrity_status = "review" if responses_count < len(attempt.items) else "pass"
|
| 119 |
+
pass_status = "pass" if score >= 70 and integrity_status == "pass" else "review"
|
| 120 |
+
feedback = [
|
| 121 |
+
"Responses were evaluated against module objectives and participation thresholds.",
|
| 122 |
+
"Final certification should also check plagiarism and anomaly signals in production.",
|
| 123 |
+
]
|
| 124 |
+
return AssessmentAttemptResult(
|
| 125 |
+
attempt_id=attempt.attempt_id,
|
| 126 |
+
score=score,
|
| 127 |
+
pass_status=pass_status,
|
| 128 |
+
integrity_status=integrity_status,
|
| 129 |
+
feedback=feedback,
|
| 130 |
+
human_review_required=integrity_status != "pass",
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def build_progress_summary(session: AsyncSession, learner_id: UUID) -> ProgressSummary:
|
| 135 |
+
enrollments = await repository.list_enrollments_for_learner(session, learner_id)
|
| 136 |
+
interactions = await repository.list_interactions_for_learner(session, learner_id)
|
| 137 |
+
summary_items: list[ProgressItem] = []
|
| 138 |
+
|
| 139 |
+
for enrollment in enrollments:
|
| 140 |
+
module_events = [i for i in interactions if i.module_id == enrollment.module_id]
|
| 141 |
+
completion = min(len(module_events) * 10.0, 100.0)
|
| 142 |
+
mastery = round(min(100.0, 40 + len(module_events) * 5.5), 2)
|
| 143 |
+
summary_items.append(
|
| 144 |
+
ProgressItem(
|
| 145 |
+
module_id=enrollment.module_id,
|
| 146 |
+
completion_percent=completion,
|
| 147 |
+
mastery_score=mastery,
|
| 148 |
+
next_recommended_action="Complete the next content chunk and formative reflection.",
|
| 149 |
+
)
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return ProgressSummary(learner_id=learner_id, active_enrollments=summary_items)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
async def build_dashboard(session: AsyncSession, learner: LearnerRecord) -> LearnerDashboard:
|
| 156 |
+
interactions = await repository.list_interactions_for_learner(session, learner.id)
|
| 157 |
+
total_minutes = round(sum((item.duration_ms or 0) for item in interactions) / 60000, 2)
|
| 158 |
+
dropout_risk = learner.risk_profile.get("dropout_risk", 0.3)
|
| 159 |
+
success_probability = round(max(0.2, 1 - dropout_risk + 0.15), 2)
|
| 160 |
+
|
| 161 |
+
return LearnerDashboard(
|
| 162 |
+
learner_id=learner.id,
|
| 163 |
+
mastery=learner.mastery_map,
|
| 164 |
+
time_on_task_minutes_7d=total_minutes,
|
| 165 |
+
dropout_risk=dropout_risk,
|
| 166 |
+
success_probability=success_probability,
|
| 167 |
+
adaptive_feedback=[
|
| 168 |
+
"Continue with short focused sessions of 10 to 15 minutes.",
|
| 169 |
+
"Prioritize scenario-based practice to improve transfer and assessment performance.",
|
| 170 |
+
],
|
| 171 |
+
)
|
backend/app/services/repository.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from uuid import UUID
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException, status
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlalchemy.orm import selectinload
|
| 9 |
+
|
| 10 |
+
from app.models.orm import (
|
| 11 |
+
AssessmentAttemptORM,
|
| 12 |
+
AssessmentDefinitionORM,
|
| 13 |
+
EnrollmentORM,
|
| 14 |
+
InteractionORM,
|
| 15 |
+
LearnerGoalORM,
|
| 16 |
+
LearnerORM,
|
| 17 |
+
LearnerPriorLearningORM,
|
| 18 |
+
ModuleActivityORM,
|
| 19 |
+
ModuleORM,
|
| 20 |
+
ModuleObjectiveORM,
|
| 21 |
+
ModuleUnitORM,
|
| 22 |
+
)
|
| 23 |
+
from app.models.schemas import (
|
| 24 |
+
AssessmentAttempt,
|
| 25 |
+
AssessmentAttemptResult,
|
| 26 |
+
AssessmentDefinition,
|
| 27 |
+
Enrollment,
|
| 28 |
+
InteractionEvent,
|
| 29 |
+
LearnerGoal,
|
| 30 |
+
LearnerPreferences,
|
| 31 |
+
LearnerProfile,
|
| 32 |
+
LearnerRecord,
|
| 33 |
+
ModuleActivity,
|
| 34 |
+
ModuleCompletionCriteria,
|
| 35 |
+
ModuleDetail,
|
| 36 |
+
ModuleObjective,
|
| 37 |
+
ModuleUnit,
|
| 38 |
+
PriorLearningItem,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _learner_to_schema(model: LearnerORM) -> LearnerRecord:
|
| 43 |
+
return LearnerRecord(
|
| 44 |
+
id=model.id,
|
| 45 |
+
email=model.email,
|
| 46 |
+
first_name=model.first_name,
|
| 47 |
+
last_name=model.last_name,
|
| 48 |
+
locale=model.locale,
|
| 49 |
+
timezone=model.timezone,
|
| 50 |
+
educational_level=model.educational_level,
|
| 51 |
+
consent_personalization=model.consent_personalization,
|
| 52 |
+
consent_analytics=model.consent_analytics,
|
| 53 |
+
consent_version=model.consent_version,
|
| 54 |
+
prior_learning=[
|
| 55 |
+
PriorLearningItem(
|
| 56 |
+
learning_type=item.learning_type,
|
| 57 |
+
title=item.title,
|
| 58 |
+
provider=item.provider,
|
| 59 |
+
description=item.description,
|
| 60 |
+
)
|
| 61 |
+
for item in model.prior_learning_items
|
| 62 |
+
],
|
| 63 |
+
goals=[
|
| 64 |
+
LearnerGoal(
|
| 65 |
+
goal_horizon=item.goal_horizon,
|
| 66 |
+
goal_text=item.goal_text,
|
| 67 |
+
target_date=item.target_date,
|
| 68 |
+
priority=item.priority,
|
| 69 |
+
)
|
| 70 |
+
for item in model.goals
|
| 71 |
+
],
|
| 72 |
+
preferences=LearnerPreferences.model_validate(model.preferences or {}),
|
| 73 |
+
readiness_level=model.readiness_level,
|
| 74 |
+
readiness_score=model.readiness_score,
|
| 75 |
+
mastery_map=model.mastery_map or {},
|
| 76 |
+
risk_profile=model.risk_profile or {},
|
| 77 |
+
profile_vector=model.profile_vector or [],
|
| 78 |
+
created_at=model.created_at,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _module_to_schema(model: ModuleORM) -> ModuleDetail:
|
| 83 |
+
return ModuleDetail(
|
| 84 |
+
id=model.id,
|
| 85 |
+
code=model.code,
|
| 86 |
+
title=model.title,
|
| 87 |
+
description=model.description,
|
| 88 |
+
ects_credits=model.ects_credits,
|
| 89 |
+
difficulty_level=model.difficulty_level,
|
| 90 |
+
estimated_total_minutes=model.estimated_total_minutes,
|
| 91 |
+
self_paced=model.self_paced,
|
| 92 |
+
adaptive_enabled=model.adaptive_enabled,
|
| 93 |
+
topics=model.topics or [],
|
| 94 |
+
objectives=[
|
| 95 |
+
ModuleObjective(
|
| 96 |
+
id=item.id,
|
| 97 |
+
text=item.text,
|
| 98 |
+
bloom_level=item.bloom_level,
|
| 99 |
+
competency_tag=item.competency_tag,
|
| 100 |
+
measurable_outcome=item.measurable_outcome,
|
| 101 |
+
)
|
| 102 |
+
for item in model.objectives
|
| 103 |
+
],
|
| 104 |
+
units=[
|
| 105 |
+
ModuleUnit(
|
| 106 |
+
id=item.id,
|
| 107 |
+
title=item.title,
|
| 108 |
+
unit_type=item.unit_type,
|
| 109 |
+
chunk_minutes=item.chunk_minutes,
|
| 110 |
+
content_ref=item.content_ref,
|
| 111 |
+
adaptive_rules=item.adaptive_rules or {},
|
| 112 |
+
)
|
| 113 |
+
for item in model.units
|
| 114 |
+
],
|
| 115 |
+
activities=[
|
| 116 |
+
ModuleActivity(
|
| 117 |
+
id=item.id,
|
| 118 |
+
title=item.title,
|
| 119 |
+
activity_type=item.activity_type,
|
| 120 |
+
instructions=item.instructions,
|
| 121 |
+
max_score=item.max_score,
|
| 122 |
+
required=item.required,
|
| 123 |
+
)
|
| 124 |
+
for item in model.activities
|
| 125 |
+
],
|
| 126 |
+
completion_criteria=ModuleCompletionCriteria.model_validate(model.completion_criteria or {}),
|
| 127 |
+
prerequisites=model.prerequisites or [],
|
| 128 |
+
module_vector=model.module_vector or [],
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _assessment_to_schema(model: AssessmentDefinitionORM) -> AssessmentDefinition:
|
| 133 |
+
return AssessmentDefinition(
|
| 134 |
+
id=model.id,
|
| 135 |
+
module_id=model.module_id,
|
| 136 |
+
assessment_type=model.assessment_type,
|
| 137 |
+
title=model.title,
|
| 138 |
+
blueprint=model.blueprint or {},
|
| 139 |
+
time_limit_minutes=model.time_limit_minutes,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _attempt_to_schema(model: AssessmentAttemptORM) -> AssessmentAttempt:
|
| 144 |
+
return AssessmentAttempt(
|
| 145 |
+
attempt_id=model.id,
|
| 146 |
+
assessment_id=model.assessment_id,
|
| 147 |
+
learner_id=model.learner_id,
|
| 148 |
+
time_limit_minutes=model.time_limit_minutes,
|
| 149 |
+
items=model.items or [],
|
| 150 |
+
status=model.status,
|
| 151 |
+
integrity_status=model.integrity_status,
|
| 152 |
+
created_at=model.created_at,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def create_learner(session: AsyncSession, record: LearnerRecord) -> LearnerRecord:
|
| 157 |
+
learner = LearnerORM(
|
| 158 |
+
id=record.id,
|
| 159 |
+
email=str(record.email),
|
| 160 |
+
first_name=record.first_name,
|
| 161 |
+
last_name=record.last_name,
|
| 162 |
+
locale=record.locale,
|
| 163 |
+
timezone=record.timezone,
|
| 164 |
+
educational_level=record.educational_level,
|
| 165 |
+
consent_personalization=record.consent_personalization,
|
| 166 |
+
consent_analytics=record.consent_analytics,
|
| 167 |
+
consent_version=record.consent_version,
|
| 168 |
+
readiness_level=record.readiness_level,
|
| 169 |
+
readiness_score=record.readiness_score,
|
| 170 |
+
mastery_map=record.mastery_map,
|
| 171 |
+
risk_profile=record.risk_profile,
|
| 172 |
+
profile_vector=record.profile_vector,
|
| 173 |
+
preferences=record.preferences.model_dump(),
|
| 174 |
+
prior_learning_items=[
|
| 175 |
+
LearnerPriorLearningORM(
|
| 176 |
+
learning_type=item.learning_type,
|
| 177 |
+
title=item.title,
|
| 178 |
+
provider=item.provider,
|
| 179 |
+
description=item.description,
|
| 180 |
+
)
|
| 181 |
+
for item in record.prior_learning
|
| 182 |
+
],
|
| 183 |
+
goals=[
|
| 184 |
+
LearnerGoalORM(
|
| 185 |
+
goal_horizon=item.goal_horizon,
|
| 186 |
+
goal_text=item.goal_text,
|
| 187 |
+
target_date=item.target_date,
|
| 188 |
+
priority=item.priority,
|
| 189 |
+
)
|
| 190 |
+
for item in record.goals
|
| 191 |
+
],
|
| 192 |
+
)
|
| 193 |
+
session.add(learner)
|
| 194 |
+
await session.commit()
|
| 195 |
+
await session.refresh(learner)
|
| 196 |
+
return await get_learner(session, learner.id)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
async def update_learner(session: AsyncSession, record: LearnerRecord) -> LearnerRecord:
|
| 200 |
+
learner = await session.get(LearnerORM, record.id, options=(selectinload(LearnerORM.prior_learning_items), selectinload(LearnerORM.goals)))
|
| 201 |
+
if learner is None:
|
| 202 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
| 203 |
+
|
| 204 |
+
learner.first_name = record.first_name
|
| 205 |
+
learner.last_name = record.last_name
|
| 206 |
+
learner.locale = record.locale
|
| 207 |
+
learner.timezone = record.timezone
|
| 208 |
+
learner.educational_level = record.educational_level
|
| 209 |
+
learner.consent_personalization = record.consent_personalization
|
| 210 |
+
learner.consent_analytics = record.consent_analytics
|
| 211 |
+
learner.consent_version = record.consent_version
|
| 212 |
+
learner.readiness_level = record.readiness_level
|
| 213 |
+
learner.readiness_score = record.readiness_score
|
| 214 |
+
learner.mastery_map = record.mastery_map
|
| 215 |
+
learner.risk_profile = record.risk_profile
|
| 216 |
+
learner.profile_vector = record.profile_vector
|
| 217 |
+
learner.preferences = record.preferences.model_dump()
|
| 218 |
+
learner.prior_learning_items = [
|
| 219 |
+
LearnerPriorLearningORM(
|
| 220 |
+
learning_type=item.learning_type,
|
| 221 |
+
title=item.title,
|
| 222 |
+
provider=item.provider,
|
| 223 |
+
description=item.description,
|
| 224 |
+
)
|
| 225 |
+
for item in record.prior_learning
|
| 226 |
+
]
|
| 227 |
+
learner.goals = [
|
| 228 |
+
LearnerGoalORM(
|
| 229 |
+
goal_horizon=item.goal_horizon,
|
| 230 |
+
goal_text=item.goal_text,
|
| 231 |
+
target_date=item.target_date,
|
| 232 |
+
priority=item.priority,
|
| 233 |
+
)
|
| 234 |
+
for item in record.goals
|
| 235 |
+
]
|
| 236 |
+
await session.commit()
|
| 237 |
+
return await get_learner(session, learner.id)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
async def get_learner(session: AsyncSession, learner_id: UUID) -> LearnerRecord:
|
| 241 |
+
stmt = (
|
| 242 |
+
select(LearnerORM)
|
| 243 |
+
.where(LearnerORM.id == learner_id)
|
| 244 |
+
.options(selectinload(LearnerORM.prior_learning_items), selectinload(LearnerORM.goals))
|
| 245 |
+
)
|
| 246 |
+
learner = (await session.execute(stmt)).scalar_one_or_none()
|
| 247 |
+
if learner is None:
|
| 248 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Learner not found")
|
| 249 |
+
return _learner_to_schema(learner)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
async def list_modules(session: AsyncSession) -> list[ModuleDetail]:
|
| 253 |
+
stmt = select(ModuleORM).options(
|
| 254 |
+
selectinload(ModuleORM.objectives),
|
| 255 |
+
selectinload(ModuleORM.units),
|
| 256 |
+
selectinload(ModuleORM.activities),
|
| 257 |
+
)
|
| 258 |
+
modules = (await session.execute(stmt)).scalars().all()
|
| 259 |
+
return [_module_to_schema(item) for item in modules]
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
async def get_module(session: AsyncSession, module_id: UUID) -> ModuleDetail:
|
| 263 |
+
stmt = (
|
| 264 |
+
select(ModuleORM)
|
| 265 |
+
.where(ModuleORM.id == module_id)
|
| 266 |
+
.options(selectinload(ModuleORM.objectives), selectinload(ModuleORM.units), selectinload(ModuleORM.activities))
|
| 267 |
+
)
|
| 268 |
+
module = (await session.execute(stmt)).scalar_one_or_none()
|
| 269 |
+
if module is None:
|
| 270 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Module not found")
|
| 271 |
+
return _module_to_schema(module)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
async def create_module(session: AsyncSession, module: ModuleDetail) -> ModuleDetail:
|
| 275 |
+
module_orm = ModuleORM(
|
| 276 |
+
id=module.id,
|
| 277 |
+
code=module.code,
|
| 278 |
+
title=module.title,
|
| 279 |
+
description=module.description,
|
| 280 |
+
ects_credits=module.ects_credits,
|
| 281 |
+
difficulty_level=module.difficulty_level,
|
| 282 |
+
estimated_total_minutes=module.estimated_total_minutes,
|
| 283 |
+
self_paced=module.self_paced,
|
| 284 |
+
adaptive_enabled=module.adaptive_enabled,
|
| 285 |
+
topics=module.topics,
|
| 286 |
+
completion_criteria=module.completion_criteria.model_dump(),
|
| 287 |
+
prerequisites=[str(item) for item in module.prerequisites],
|
| 288 |
+
module_vector=module.module_vector,
|
| 289 |
+
objectives=[
|
| 290 |
+
ModuleObjectiveORM(
|
| 291 |
+
id=item.id,
|
| 292 |
+
text=item.text,
|
| 293 |
+
bloom_level=item.bloom_level,
|
| 294 |
+
competency_tag=item.competency_tag,
|
| 295 |
+
measurable_outcome=item.measurable_outcome,
|
| 296 |
+
sort_order=index,
|
| 297 |
+
)
|
| 298 |
+
for index, item in enumerate(module.objectives)
|
| 299 |
+
],
|
| 300 |
+
units=[
|
| 301 |
+
ModuleUnitORM(
|
| 302 |
+
id=item.id,
|
| 303 |
+
title=item.title,
|
| 304 |
+
unit_type=item.unit_type,
|
| 305 |
+
chunk_minutes=item.chunk_minutes,
|
| 306 |
+
content_ref=item.content_ref,
|
| 307 |
+
adaptive_rules=item.adaptive_rules,
|
| 308 |
+
sort_order=index,
|
| 309 |
+
)
|
| 310 |
+
for index, item in enumerate(module.units)
|
| 311 |
+
],
|
| 312 |
+
activities=[
|
| 313 |
+
ModuleActivityORM(
|
| 314 |
+
id=item.id,
|
| 315 |
+
title=item.title,
|
| 316 |
+
activity_type=item.activity_type,
|
| 317 |
+
instructions=item.instructions,
|
| 318 |
+
max_score=item.max_score,
|
| 319 |
+
required=item.required,
|
| 320 |
+
sort_order=index,
|
| 321 |
+
)
|
| 322 |
+
for index, item in enumerate(module.activities)
|
| 323 |
+
],
|
| 324 |
+
)
|
| 325 |
+
session.add(module_orm)
|
| 326 |
+
await session.commit()
|
| 327 |
+
return await get_module(session, module.id)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
async def create_assessment(session: AsyncSession, definition: AssessmentDefinition) -> AssessmentDefinition:
|
| 331 |
+
assessment = AssessmentDefinitionORM(
|
| 332 |
+
id=definition.id,
|
| 333 |
+
module_id=definition.module_id,
|
| 334 |
+
assessment_type=definition.assessment_type,
|
| 335 |
+
title=definition.title,
|
| 336 |
+
blueprint=definition.blueprint,
|
| 337 |
+
time_limit_minutes=definition.time_limit_minutes,
|
| 338 |
+
)
|
| 339 |
+
session.add(assessment)
|
| 340 |
+
await session.commit()
|
| 341 |
+
return await get_assessment(session, definition.id)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
async def get_assessment(session: AsyncSession, assessment_id: UUID) -> AssessmentDefinition:
|
| 345 |
+
assessment = await session.get(AssessmentDefinitionORM, assessment_id)
|
| 346 |
+
if assessment is None:
|
| 347 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assessment not found")
|
| 348 |
+
return _assessment_to_schema(assessment)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
async def create_enrollment(session: AsyncSession, enrollment: Enrollment) -> Enrollment:
|
| 352 |
+
model = EnrollmentORM(
|
| 353 |
+
id=enrollment.id,
|
| 354 |
+
learner_id=enrollment.learner_id,
|
| 355 |
+
module_id=enrollment.module_id,
|
| 356 |
+
enrollment_status=enrollment.enrollment_status,
|
| 357 |
+
recommended_by=enrollment.recommended_by,
|
| 358 |
+
mastery_score=enrollment.mastery_score,
|
| 359 |
+
completed_at=enrollment.completed_at,
|
| 360 |
+
)
|
| 361 |
+
session.add(model)
|
| 362 |
+
await session.commit()
|
| 363 |
+
await session.refresh(model)
|
| 364 |
+
return Enrollment.model_validate(model)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
async def list_enrollments_for_learner(session: AsyncSession, learner_id: UUID) -> list[Enrollment]:
|
| 368 |
+
stmt = select(EnrollmentORM).where(EnrollmentORM.learner_id == learner_id)
|
| 369 |
+
enrollments = (await session.execute(stmt)).scalars().all()
|
| 370 |
+
return [Enrollment.model_validate(item) for item in enrollments]
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
async def create_interaction(session: AsyncSession, event: InteractionEvent) -> InteractionEvent:
|
| 374 |
+
model = InteractionORM(
|
| 375 |
+
learner_id=event.learner_id,
|
| 376 |
+
module_id=event.module_id,
|
| 377 |
+
unit_id=event.unit_id,
|
| 378 |
+
activity_id=event.activity_id,
|
| 379 |
+
session_id=event.session_id,
|
| 380 |
+
event_type=event.event_type,
|
| 381 |
+
event_timestamp=event.event_timestamp,
|
| 382 |
+
duration_ms=event.duration_ms,
|
| 383 |
+
payload=event.payload,
|
| 384 |
+
client_context=event.client_context,
|
| 385 |
+
)
|
| 386 |
+
session.add(model)
|
| 387 |
+
await session.commit()
|
| 388 |
+
return event
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
async def list_interactions_for_learner(session: AsyncSession, learner_id: UUID) -> list[InteractionEvent]:
|
| 392 |
+
stmt = select(InteractionORM).where(InteractionORM.learner_id == learner_id)
|
| 393 |
+
interactions = (await session.execute(stmt)).scalars().all()
|
| 394 |
+
return [
|
| 395 |
+
InteractionEvent(
|
| 396 |
+
learner_id=item.learner_id,
|
| 397 |
+
module_id=item.module_id,
|
| 398 |
+
unit_id=item.unit_id,
|
| 399 |
+
activity_id=item.activity_id,
|
| 400 |
+
session_id=item.session_id,
|
| 401 |
+
event_type=item.event_type,
|
| 402 |
+
event_timestamp=item.event_timestamp,
|
| 403 |
+
duration_ms=item.duration_ms,
|
| 404 |
+
payload=item.payload or {},
|
| 405 |
+
client_context=item.client_context or {},
|
| 406 |
+
)
|
| 407 |
+
for item in interactions
|
| 408 |
+
]
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
async def create_attempt(session: AsyncSession, attempt: AssessmentAttempt) -> AssessmentAttempt:
|
| 412 |
+
model = AssessmentAttemptORM(
|
| 413 |
+
id=attempt.attempt_id,
|
| 414 |
+
assessment_id=attempt.assessment_id,
|
| 415 |
+
learner_id=attempt.learner_id,
|
| 416 |
+
time_limit_minutes=attempt.time_limit_minutes,
|
| 417 |
+
items=[item.model_dump(mode="json") for item in attempt.items],
|
| 418 |
+
status=attempt.status,
|
| 419 |
+
integrity_status=attempt.integrity_status,
|
| 420 |
+
)
|
| 421 |
+
session.add(model)
|
| 422 |
+
await session.commit()
|
| 423 |
+
return await get_attempt(session, attempt.attempt_id)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
async def get_attempt(session: AsyncSession, attempt_id: UUID) -> AssessmentAttempt:
|
| 427 |
+
attempt = await session.get(AssessmentAttemptORM, attempt_id)
|
| 428 |
+
if attempt is None:
|
| 429 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assessment attempt not found")
|
| 430 |
+
return _attempt_to_schema(attempt)
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
async def save_result(session: AsyncSession, result: AssessmentAttemptResult) -> AssessmentAttemptResult:
|
| 434 |
+
attempt = await session.get(AssessmentAttemptORM, result.attempt_id)
|
| 435 |
+
if attempt is None:
|
| 436 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assessment attempt not found")
|
| 437 |
+
attempt.score = result.score
|
| 438 |
+
attempt.pass_status = result.pass_status
|
| 439 |
+
attempt.integrity_status = result.integrity_status
|
| 440 |
+
attempt.feedback = result.feedback
|
| 441 |
+
attempt.human_review_required = result.human_review_required
|
| 442 |
+
attempt.status = "completed"
|
| 443 |
+
await session.commit()
|
| 444 |
+
return result
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
async def get_result(session: AsyncSession, attempt_id: UUID) -> AssessmentAttemptResult:
|
| 448 |
+
attempt = await session.get(AssessmentAttemptORM, attempt_id)
|
| 449 |
+
if attempt is None or attempt.score is None:
|
| 450 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assessment result not ready")
|
| 451 |
+
return AssessmentAttemptResult(
|
| 452 |
+
attempt_id=attempt.id,
|
| 453 |
+
score=attempt.score,
|
| 454 |
+
pass_status=attempt.pass_status or "review",
|
| 455 |
+
integrity_status=attempt.integrity_status,
|
| 456 |
+
feedback=attempt.feedback or [],
|
| 457 |
+
human_review_required=attempt.human_review_required,
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
async def module_exists(session: AsyncSession, module_id: UUID) -> bool:
|
| 462 |
+
return await session.get(ModuleORM, module_id) is not None
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
async def learner_exists(session: AsyncSession, learner_id: UUID) -> bool:
|
| 466 |
+
return await session.get(LearnerORM, learner_id) is not None
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
async def has_modules(session: AsyncSession) -> bool:
|
| 470 |
+
stmt = select(ModuleORM.id).limit(1)
|
| 471 |
+
return (await session.execute(stmt)).first() is not None
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "aididact-backend"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "FastAPI scaffold for the AIDidact AI-powered microlearning ecosystem."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"fastapi==0.116.1",
|
| 9 |
+
"uvicorn[standard]==0.35.0",
|
| 10 |
+
"pydantic==2.11.7",
|
| 11 |
+
"pydantic-settings==2.10.1",
|
| 12 |
+
"email-validator==2.2.0",
|
| 13 |
+
"sqlalchemy==2.0.43",
|
| 14 |
+
"asyncpg==0.30.0",
|
| 15 |
+
"alembic==1.16.4",
|
| 16 |
+
"greenlet==3.2.4",
|
| 17 |
+
"aiosqlite==0.21.0",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[build-system]
|
| 21 |
+
requires = ["setuptools>=68"]
|
| 22 |
+
build-backend = "setuptools.build_meta"
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.116.1
|
| 2 |
+
uvicorn[standard]==0.35.0
|
| 3 |
+
pydantic==2.11.7
|
| 4 |
+
pydantic-settings==2.10.1
|
| 5 |
+
email-validator==2.2.0
|
| 6 |
+
sqlalchemy==2.0.43
|
| 7 |
+
asyncpg==0.30.0
|
| 8 |
+
alembic==1.16.4
|
| 9 |
+
greenlet==3.2.4
|
| 10 |
+
aiosqlite==0.21.0
|
docs/algorithms.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core Algorithms
|
| 2 |
+
|
| 3 |
+
## 1. Learner Profile Vector
|
| 4 |
+
|
| 5 |
+
```text
|
| 6 |
+
FUNCTION build_learner_profile_vector(registration, diagnostic, history):
|
| 7 |
+
prior_learning_embedding = embed(registration.prior_learning)
|
| 8 |
+
goal_embedding = embed(registration.goals)
|
| 9 |
+
education_features = encode_categorical(registration.educational_level)
|
| 10 |
+
readiness_features = normalize(diagnostic.scores)
|
| 11 |
+
preference_features = encode_optional(registration.preferences)
|
| 12 |
+
behavior_features = aggregate(history)
|
| 13 |
+
|
| 14 |
+
profile_vector = concatenate(
|
| 15 |
+
prior_learning_embedding,
|
| 16 |
+
goal_embedding,
|
| 17 |
+
education_features,
|
| 18 |
+
readiness_features,
|
| 19 |
+
preference_features,
|
| 20 |
+
behavior_features
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
mastery_map = initialize_mastery(diagnostic)
|
| 24 |
+
RETURN { profile_vector, mastery_map }
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## 2. Module Vectorization
|
| 28 |
+
|
| 29 |
+
```text
|
| 30 |
+
FUNCTION build_module_vector(module):
|
| 31 |
+
objective_embedding = embed(module.objectives)
|
| 32 |
+
topic_embedding = embed(module.topics)
|
| 33 |
+
bloom_features = encode_bloom_levels(module.objectives)
|
| 34 |
+
workload_features = normalize(module.estimated_minutes, module.ects)
|
| 35 |
+
activity_features = encode_multilabel(module.activity_types)
|
| 36 |
+
prerequisite_features = encode_graph_position(module.prerequisites)
|
| 37 |
+
|
| 38 |
+
RETURN concatenate(
|
| 39 |
+
objective_embedding,
|
| 40 |
+
topic_embedding,
|
| 41 |
+
bloom_features,
|
| 42 |
+
workload_features,
|
| 43 |
+
activity_features,
|
| 44 |
+
prerequisite_features
|
| 45 |
+
)
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## 3. Hybrid Recommendation
|
| 49 |
+
|
| 50 |
+
```text
|
| 51 |
+
FUNCTION recommend_modules(learner, candidates):
|
| 52 |
+
FOR module IN candidates:
|
| 53 |
+
content_score = cosine_similarity(learner.profile_vector, module.vector)
|
| 54 |
+
collaborative_score = collaborative_model.predict(learner.id, module.id)
|
| 55 |
+
readiness_score = readiness_alignment(learner.mastery_map, module.required_competencies)
|
| 56 |
+
pacing_score = pace_alignment(learner, module)
|
| 57 |
+
diversity_penalty = novelty_penalty(learner.recent_topics, module.topic_cluster)
|
| 58 |
+
|
| 59 |
+
final_score =
|
| 60 |
+
0.35 * content_score +
|
| 61 |
+
0.30 * collaborative_score +
|
| 62 |
+
0.20 * readiness_score +
|
| 63 |
+
0.15 * pacing_score -
|
| 64 |
+
diversity_penalty
|
| 65 |
+
|
| 66 |
+
IF not prerequisites_satisfied(learner, module):
|
| 67 |
+
final_score = -INF
|
| 68 |
+
|
| 69 |
+
RETURN top_k_by_score(candidates)
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
## 4. Dynamic Assessment Generation
|
| 73 |
+
|
| 74 |
+
```text
|
| 75 |
+
FUNCTION generate_unique_assessment(blueprint, learner):
|
| 76 |
+
selected_items = []
|
| 77 |
+
|
| 78 |
+
FOR objective IN blueprint.objectives:
|
| 79 |
+
pool = filter_question_pool(
|
| 80 |
+
objective=objective,
|
| 81 |
+
difficulty=target_difficulty(learner, objective)
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
item = choose_unique_variant(pool, learner.previous_items)
|
| 85 |
+
|
| 86 |
+
IF item.parameterized:
|
| 87 |
+
item = inject_unique_parameters(item, learner.id, now())
|
| 88 |
+
|
| 89 |
+
IF item.ai_generatable:
|
| 90 |
+
item = ai_generate_variant(item.template, blueprint.rubric)
|
| 91 |
+
|
| 92 |
+
selected_items.append(item)
|
| 93 |
+
|
| 94 |
+
RETURN shuffle(selected_items)
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## 5. Open-Ended AI Evaluation
|
| 98 |
+
|
| 99 |
+
```text
|
| 100 |
+
FUNCTION evaluate_open_response(response, rubric):
|
| 101 |
+
deterministic_checks = run_basic_checks(response, rubric)
|
| 102 |
+
ai_result = ai_score_with_rubric(response.text, rubric)
|
| 103 |
+
final_score = weighted_merge(deterministic_checks, ai_result.score)
|
| 104 |
+
|
| 105 |
+
save_audit_trace(response.id, rubric, ai_result.rationale, ai_result.confidence)
|
| 106 |
+
|
| 107 |
+
IF ai_result.confidence < 0.60:
|
| 108 |
+
queue_human_review(response.id)
|
| 109 |
+
|
| 110 |
+
RETURN final_score
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
## 6. Integrity Detection
|
| 114 |
+
|
| 115 |
+
```text
|
| 116 |
+
FUNCTION integrity_check(attempt):
|
| 117 |
+
plagiarism = compare_to_corpus(attempt.responses)
|
| 118 |
+
peer_similarity = compare_to_peer_attempts(attempt)
|
| 119 |
+
timing_anomaly = detect_impossible_timing(attempt.event_stream)
|
| 120 |
+
session_anomaly = detect_session_irregularity(attempt.session_metadata)
|
| 121 |
+
|
| 122 |
+
risk = weighted_sum(plagiarism, peer_similarity, timing_anomaly, session_anomaly)
|
| 123 |
+
|
| 124 |
+
IF risk >= 0.85:
|
| 125 |
+
RETURN "block"
|
| 126 |
+
IF risk >= 0.60:
|
| 127 |
+
RETURN "review"
|
| 128 |
+
RETURN "pass"
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
## 7. Dropout Risk Prediction
|
| 132 |
+
|
| 133 |
+
```text
|
| 134 |
+
FUNCTION predict_dropout_risk(learner_features):
|
| 135 |
+
risk_probability = dropout_model.predict([
|
| 136 |
+
learner_features.time_on_task_7d,
|
| 137 |
+
learner_features.inactivity_days,
|
| 138 |
+
learner_features.quiz_failure_rate,
|
| 139 |
+
learner_features.pace_slippage,
|
| 140 |
+
learner_features.unfinished_activity_ratio
|
| 141 |
+
])
|
| 142 |
+
|
| 143 |
+
IF risk_probability > 0.75:
|
| 144 |
+
trigger_high_priority_intervention()
|
| 145 |
+
ELSE IF risk_probability > 0.45:
|
| 146 |
+
trigger_nudge()
|
| 147 |
+
|
| 148 |
+
RETURN risk_probability
|
| 149 |
+
```
|
docs/architecture.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AIDidact Architecture Blueprint
|
| 2 |
+
|
| 3 |
+
## 1. Core Components
|
| 4 |
+
|
| 5 |
+
### Learner profiling system
|
| 6 |
+
|
| 7 |
+
- Collects formal, non-formal, and informal prior learning.
|
| 8 |
+
- Captures short-term and long-term learning goals.
|
| 9 |
+
- Stores educational level and diagnostic readiness.
|
| 10 |
+
- Accepts optional learning preferences such as modality and pacing.
|
| 11 |
+
- Produces a learner profile vector and baseline mastery map.
|
| 12 |
+
|
| 13 |
+
### Microlearning module system
|
| 14 |
+
|
| 15 |
+
Each 5 ECTS module contains:
|
| 16 |
+
|
| 17 |
+
- Bloom-aligned learning objectives
|
| 18 |
+
- content chunks capped at 10 to 15 minutes
|
| 19 |
+
- interactive activities such as quizzes, reflections, and simulations
|
| 20 |
+
- formative assessments
|
| 21 |
+
- explicit completion criteria
|
| 22 |
+
|
| 23 |
+
### Recommendation engine
|
| 24 |
+
|
| 25 |
+
- Matches learner profile vectors against module metadata vectors.
|
| 26 |
+
- Combines content similarity with collaborative filtering signals.
|
| 27 |
+
- Re-ranks recommendations based on progress, mastery, pacing, and risk.
|
| 28 |
+
|
| 29 |
+
### Assessment and integrity
|
| 30 |
+
|
| 31 |
+
- Builds unique question sets per learner from pools and AI-generated variants.
|
| 32 |
+
- Supports scenario-based questions and open-ended tasks.
|
| 33 |
+
- Uses time limits, randomization, plagiarism checks, and anomaly detection.
|
| 34 |
+
|
| 35 |
+
### Learning analytics
|
| 36 |
+
|
| 37 |
+
- Tracks time-on-task, interaction patterns, assessment performance, and progression paths.
|
| 38 |
+
- Produces learner dashboards, dropout risk, success probability, and adaptive feedback.
|
| 39 |
+
|
| 40 |
+
## 2. Scalable Architecture
|
| 41 |
+
|
| 42 |
+
### Frontend
|
| 43 |
+
|
| 44 |
+
- Web SPA for learners and admins.
|
| 45 |
+
- Mobile app with sync support and notifications.
|
| 46 |
+
|
| 47 |
+
### Backend services
|
| 48 |
+
|
| 49 |
+
- `auth-service`
|
| 50 |
+
- `profile-service`
|
| 51 |
+
- `module-service`
|
| 52 |
+
- `progress-service`
|
| 53 |
+
- `assessment-service`
|
| 54 |
+
- `recommendation-service`
|
| 55 |
+
- `dashboard-service`
|
| 56 |
+
- `notification-service`
|
| 57 |
+
|
| 58 |
+
### AI and ML services
|
| 59 |
+
|
| 60 |
+
- embedding generation service
|
| 61 |
+
- recommendation ranker
|
| 62 |
+
- AI question generation service
|
| 63 |
+
- AI rubric evaluation service
|
| 64 |
+
- plagiarism and anomaly detection engine
|
| 65 |
+
- predictive analytics models
|
| 66 |
+
|
| 67 |
+
### Data storage
|
| 68 |
+
|
| 69 |
+
- PostgreSQL for learners, modules, assessments, and progress
|
| 70 |
+
- NoSQL store for high-volume event logs
|
| 71 |
+
- analytics warehouse for dashboards and modeling
|
| 72 |
+
- vector index for learner/module semantic matching
|
| 73 |
+
- object storage for assets, submissions, and artifacts
|
| 74 |
+
|
| 75 |
+
## 3. Runtime Sequence
|
| 76 |
+
|
| 77 |
+
### Registration to first recommendation
|
| 78 |
+
|
| 79 |
+
1. Register learner and capture consent.
|
| 80 |
+
2. Store learner intake data.
|
| 81 |
+
3. Run diagnostic assessment.
|
| 82 |
+
4. Build profile vector and readiness features.
|
| 83 |
+
5. Rank starter modules.
|
| 84 |
+
|
| 85 |
+
### Learning progression
|
| 86 |
+
|
| 87 |
+
1. Learner consumes a content chunk.
|
| 88 |
+
2. Progress and interaction events are captured.
|
| 89 |
+
3. Analytics processor updates features.
|
| 90 |
+
4. Recommendation engine refreshes next-best content or modules.
|
| 91 |
+
|
| 92 |
+
### Assessment completion
|
| 93 |
+
|
| 94 |
+
1. Assessment blueprint defines objectives and difficulty targets.
|
| 95 |
+
2. Service assembles unique items per learner.
|
| 96 |
+
3. Responses are graded using deterministic logic and AI rubric scoring.
|
| 97 |
+
4. Integrity engine evaluates plagiarism and anomalies.
|
| 98 |
+
5. Pass decision requires sufficient score, engagement, and integrity clearance.
|
| 99 |
+
|
| 100 |
+
## 4. Scalability Notes
|
| 101 |
+
|
| 102 |
+
- Stateless services behind autoscaling groups.
|
| 103 |
+
- Event streaming for telemetry and downstream analytics.
|
| 104 |
+
- Read/write separation to keep analytics off the transactional path.
|
| 105 |
+
- Recommendation candidate generation can be precomputed; final ranking can run on demand.
|
| 106 |
+
- AI-heavy tasks should be asynchronous where possible and fully audited.
|
| 107 |
+
|
| 108 |
+
## 5. GDPR and Trust
|
| 109 |
+
|
| 110 |
+
- Consent versioning and purpose-bound processing.
|
| 111 |
+
- Pseudonymous analytics identifiers.
|
| 112 |
+
- Right-to-access, deletion, correction, and portability support.
|
| 113 |
+
- Human review for contested grading or high-stakes automated outcomes.
|
| 114 |
+
- Clear model audit logs for recommendations and AI evaluation.
|
schemas/schema.sql
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
| 2 |
+
|
| 3 |
+
CREATE TABLE learners (
|
| 4 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 5 |
+
email VARCHAR(255) UNIQUE NOT NULL,
|
| 6 |
+
first_name VARCHAR(100) NOT NULL,
|
| 7 |
+
last_name VARCHAR(100) NOT NULL,
|
| 8 |
+
locale VARCHAR(20) NOT NULL DEFAULT 'en-US',
|
| 9 |
+
timezone VARCHAR(64) NOT NULL,
|
| 10 |
+
educational_level VARCHAR(50) NOT NULL,
|
| 11 |
+
registration_status VARCHAR(30) NOT NULL DEFAULT 'active',
|
| 12 |
+
consent_personalization BOOLEAN NOT NULL DEFAULT FALSE,
|
| 13 |
+
consent_analytics BOOLEAN NOT NULL DEFAULT FALSE,
|
| 14 |
+
consent_version VARCHAR(50),
|
| 15 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 16 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 17 |
+
deleted_at TIMESTAMPTZ
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
CREATE TABLE learner_prior_learning (
|
| 21 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 22 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 23 |
+
learning_type VARCHAR(20) NOT NULL CHECK (learning_type IN ('formal', 'non_formal', 'informal')),
|
| 24 |
+
title VARCHAR(255) NOT NULL,
|
| 25 |
+
provider VARCHAR(255),
|
| 26 |
+
description TEXT,
|
| 27 |
+
evidence_url TEXT,
|
| 28 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
CREATE TABLE learner_goals (
|
| 32 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 33 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 34 |
+
goal_horizon VARCHAR(20) NOT NULL CHECK (goal_horizon IN ('short_term', 'long_term')),
|
| 35 |
+
goal_text TEXT NOT NULL,
|
| 36 |
+
target_date DATE,
|
| 37 |
+
priority SMALLINT NOT NULL DEFAULT 3,
|
| 38 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
CREATE TABLE learner_preferences (
|
| 42 |
+
learner_id UUID PRIMARY KEY REFERENCES learners(id),
|
| 43 |
+
preferred_modalities JSONB NOT NULL DEFAULT '[]'::jsonb,
|
| 44 |
+
preferred_session_length_minutes SMALLINT,
|
| 45 |
+
accessibility_needs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 46 |
+
language_preferences JSONB NOT NULL DEFAULT '[]'::jsonb,
|
| 47 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 48 |
+
);
|
| 49 |
+
|
| 50 |
+
CREATE TABLE learner_profiles (
|
| 51 |
+
learner_id UUID PRIMARY KEY REFERENCES learners(id),
|
| 52 |
+
readiness_level VARCHAR(30) NOT NULL,
|
| 53 |
+
readiness_score NUMERIC(5,2) NOT NULL,
|
| 54 |
+
competency_vector JSONB NOT NULL,
|
| 55 |
+
goal_vector JSONB NOT NULL,
|
| 56 |
+
profile_vector JSONB NOT NULL,
|
| 57 |
+
mastery_map JSONB NOT NULL,
|
| 58 |
+
risk_profile JSONB NOT NULL,
|
| 59 |
+
profile_version INTEGER NOT NULL DEFAULT 1,
|
| 60 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
CREATE TABLE modules (
|
| 64 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 65 |
+
code VARCHAR(50) UNIQUE NOT NULL,
|
| 66 |
+
title VARCHAR(255) NOT NULL,
|
| 67 |
+
description TEXT NOT NULL,
|
| 68 |
+
ects_credits NUMERIC(4,1) NOT NULL DEFAULT 5.0,
|
| 69 |
+
difficulty_level VARCHAR(30) NOT NULL,
|
| 70 |
+
estimated_total_minutes INTEGER NOT NULL,
|
| 71 |
+
self_paced BOOLEAN NOT NULL DEFAULT TRUE,
|
| 72 |
+
adaptive_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
| 73 |
+
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
| 74 |
+
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 75 |
+
module_vector JSONB,
|
| 76 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 77 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
CREATE TABLE module_objectives (
|
| 81 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 82 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 83 |
+
objective_text TEXT NOT NULL,
|
| 84 |
+
bloom_level VARCHAR(30) NOT NULL,
|
| 85 |
+
competency_tag VARCHAR(100) NOT NULL,
|
| 86 |
+
measurable_outcome TEXT NOT NULL,
|
| 87 |
+
sort_order SMALLINT NOT NULL
|
| 88 |
+
);
|
| 89 |
+
|
| 90 |
+
CREATE TABLE module_units (
|
| 91 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 92 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 93 |
+
title VARCHAR(255) NOT NULL,
|
| 94 |
+
unit_type VARCHAR(30) NOT NULL,
|
| 95 |
+
chunk_minutes SMALLINT NOT NULL CHECK (chunk_minutes BETWEEN 1 AND 15),
|
| 96 |
+
content_ref TEXT NOT NULL,
|
| 97 |
+
adaptive_rules JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 98 |
+
sort_order SMALLINT NOT NULL
|
| 99 |
+
);
|
| 100 |
+
|
| 101 |
+
CREATE TABLE module_activities (
|
| 102 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 103 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 104 |
+
unit_id UUID REFERENCES module_units(id),
|
| 105 |
+
activity_type VARCHAR(30) NOT NULL,
|
| 106 |
+
title VARCHAR(255) NOT NULL,
|
| 107 |
+
instructions TEXT NOT NULL,
|
| 108 |
+
scoring_rubric JSONB,
|
| 109 |
+
max_score NUMERIC(6,2),
|
| 110 |
+
required BOOLEAN NOT NULL DEFAULT TRUE,
|
| 111 |
+
sort_order SMALLINT NOT NULL
|
| 112 |
+
);
|
| 113 |
+
|
| 114 |
+
CREATE TABLE module_completion_rules (
|
| 115 |
+
module_id UUID PRIMARY KEY REFERENCES modules(id),
|
| 116 |
+
min_unit_completion_ratio NUMERIC(5,2) NOT NULL,
|
| 117 |
+
min_formative_score NUMERIC(5,2) NOT NULL,
|
| 118 |
+
require_summative_pass BOOLEAN NOT NULL DEFAULT TRUE,
|
| 119 |
+
min_time_on_task_minutes INTEGER,
|
| 120 |
+
integrity_clearance_required BOOLEAN NOT NULL DEFAULT TRUE,
|
| 121 |
+
rules_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
CREATE TABLE module_prerequisites (
|
| 125 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 126 |
+
prerequisite_module_id UUID NOT NULL REFERENCES modules(id),
|
| 127 |
+
PRIMARY KEY (module_id, prerequisite_module_id)
|
| 128 |
+
);
|
| 129 |
+
|
| 130 |
+
CREATE TABLE assessments (
|
| 131 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 132 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 133 |
+
assessment_type VARCHAR(30) NOT NULL CHECK (assessment_type IN ('diagnostic', 'formative', 'summative')),
|
| 134 |
+
title VARCHAR(255) NOT NULL,
|
| 135 |
+
blueprint JSONB NOT NULL,
|
| 136 |
+
time_limit_minutes INTEGER,
|
| 137 |
+
randomization_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 138 |
+
integrity_policy JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 139 |
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 140 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
CREATE TABLE assessment_question_pool (
|
| 144 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 145 |
+
assessment_id UUID NOT NULL REFERENCES assessments(id),
|
| 146 |
+
objective_id UUID REFERENCES module_objectives(id),
|
| 147 |
+
question_type VARCHAR(30) NOT NULL,
|
| 148 |
+
difficulty_band VARCHAR(20) NOT NULL,
|
| 149 |
+
prompt_template TEXT NOT NULL,
|
| 150 |
+
answer_key JSONB,
|
| 151 |
+
rubric JSONB,
|
| 152 |
+
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
| 153 |
+
ai_generatable BOOLEAN NOT NULL DEFAULT FALSE,
|
| 154 |
+
active BOOLEAN NOT NULL DEFAULT TRUE,
|
| 155 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 156 |
+
);
|
| 157 |
+
|
| 158 |
+
CREATE TABLE assessment_attempts (
|
| 159 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 160 |
+
assessment_id UUID NOT NULL REFERENCES assessments(id),
|
| 161 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 162 |
+
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 163 |
+
submitted_at TIMESTAMPTZ,
|
| 164 |
+
duration_seconds INTEGER,
|
| 165 |
+
status VARCHAR(30) NOT NULL DEFAULT 'in_progress',
|
| 166 |
+
unique_question_set JSONB NOT NULL,
|
| 167 |
+
score NUMERIC(6,2),
|
| 168 |
+
pass_status VARCHAR(20),
|
| 169 |
+
integrity_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
| 170 |
+
ai_evaluation_trace JSONB,
|
| 171 |
+
anomaly_flags JSONB NOT NULL DEFAULT '[]'::jsonb
|
| 172 |
+
);
|
| 173 |
+
|
| 174 |
+
CREATE TABLE assessment_responses (
|
| 175 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 176 |
+
attempt_id UUID NOT NULL REFERENCES assessment_attempts(id),
|
| 177 |
+
question_pool_id UUID REFERENCES assessment_question_pool(id),
|
| 178 |
+
prompt_snapshot TEXT NOT NULL,
|
| 179 |
+
response_payload JSONB NOT NULL,
|
| 180 |
+
auto_score NUMERIC(6,2),
|
| 181 |
+
ai_score NUMERIC(6,2),
|
| 182 |
+
final_score NUMERIC(6,2),
|
| 183 |
+
plagiarism_score NUMERIC(6,4),
|
| 184 |
+
feedback TEXT,
|
| 185 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 186 |
+
);
|
| 187 |
+
|
| 188 |
+
CREATE TABLE enrollments (
|
| 189 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 190 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 191 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 192 |
+
enrollment_status VARCHAR(30) NOT NULL DEFAULT 'active',
|
| 193 |
+
recommended_by VARCHAR(30) NOT NULL DEFAULT 'system',
|
| 194 |
+
started_at TIMESTAMPTZ,
|
| 195 |
+
completed_at TIMESTAMPTZ,
|
| 196 |
+
mastery_score NUMERIC(6,2),
|
| 197 |
+
UNIQUE (learner_id, module_id)
|
| 198 |
+
);
|
| 199 |
+
|
| 200 |
+
CREATE TABLE learner_progress (
|
| 201 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 202 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 203 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 204 |
+
unit_id UUID REFERENCES module_units(id),
|
| 205 |
+
activity_id UUID REFERENCES module_activities(id),
|
| 206 |
+
progress_state VARCHAR(30) NOT NULL,
|
| 207 |
+
attempts_count INTEGER NOT NULL DEFAULT 0,
|
| 208 |
+
best_score NUMERIC(6,2),
|
| 209 |
+
time_on_task_seconds INTEGER NOT NULL DEFAULT 0,
|
| 210 |
+
last_interaction_at TIMESTAMPTZ,
|
| 211 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 212 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 213 |
+
);
|
| 214 |
+
|
| 215 |
+
CREATE TABLE interactions (
|
| 216 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 217 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 218 |
+
module_id UUID REFERENCES modules(id),
|
| 219 |
+
unit_id UUID REFERENCES module_units(id),
|
| 220 |
+
activity_id UUID REFERENCES module_activities(id),
|
| 221 |
+
session_id UUID,
|
| 222 |
+
event_type VARCHAR(50) NOT NULL,
|
| 223 |
+
event_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 224 |
+
duration_ms INTEGER,
|
| 225 |
+
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 226 |
+
client_context JSONB NOT NULL DEFAULT '{}'::jsonb
|
| 227 |
+
);
|
| 228 |
+
|
| 229 |
+
CREATE TABLE recommendation_impressions (
|
| 230 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 231 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 232 |
+
module_id UUID NOT NULL REFERENCES modules(id),
|
| 233 |
+
rank_position INTEGER NOT NULL,
|
| 234 |
+
recommendation_reason JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 235 |
+
model_version VARCHAR(50) NOT NULL,
|
| 236 |
+
accepted BOOLEAN,
|
| 237 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 238 |
+
);
|
| 239 |
+
|
| 240 |
+
CREATE TABLE analytics_daily_learner_metrics (
|
| 241 |
+
learner_id UUID NOT NULL REFERENCES learners(id),
|
| 242 |
+
metric_date DATE NOT NULL,
|
| 243 |
+
time_on_task_minutes NUMERIC(8,2) NOT NULL DEFAULT 0,
|
| 244 |
+
completed_units INTEGER NOT NULL DEFAULT 0,
|
| 245 |
+
formative_avg_score NUMERIC(6,2),
|
| 246 |
+
summative_pass_count INTEGER NOT NULL DEFAULT 0,
|
| 247 |
+
inactivity_days INTEGER NOT NULL DEFAULT 0,
|
| 248 |
+
dropout_risk NUMERIC(6,4),
|
| 249 |
+
success_probability NUMERIC(6,4),
|
| 250 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 251 |
+
PRIMARY KEY (learner_id, metric_date)
|
| 252 |
+
);
|
| 253 |
+
|
| 254 |
+
CREATE TABLE analytics_model_audit_logs (
|
| 255 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 256 |
+
learner_id UUID REFERENCES learners(id),
|
| 257 |
+
related_entity_type VARCHAR(50) NOT NULL,
|
| 258 |
+
related_entity_id UUID,
|
| 259 |
+
model_name VARCHAR(100) NOT NULL,
|
| 260 |
+
model_version VARCHAR(50) NOT NULL,
|
| 261 |
+
input_summary JSONB NOT NULL,
|
| 262 |
+
output_summary JSONB NOT NULL,
|
| 263 |
+
explanation TEXT,
|
| 264 |
+
human_review_required BOOLEAN NOT NULL DEFAULT FALSE,
|
| 265 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 266 |
+
);
|
| 267 |
+
|
| 268 |
+
CREATE INDEX idx_interactions_learner_time ON interactions (learner_id, event_timestamp DESC);
|
| 269 |
+
CREATE INDEX idx_progress_learner_module ON learner_progress (learner_id, module_id);
|
| 270 |
+
CREATE INDEX idx_attempts_learner_assessment ON assessment_attempts (learner_id, assessment_id);
|
| 271 |
+
CREATE INDEX idx_recommendation_impressions_learner ON recommendation_impressions (learner_id, created_at DESC);
|