From 14d2b13afabcbcda26b20a2324f0eb049c9061a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:49:18 +0000 Subject: [PATCH 1/3] Initial plan From 0ce1627747c7d5adbffc8e5529f5a71feb42a092 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:06:31 +0000 Subject: [PATCH 2/3] Implement AI-powered personalized learning lab with Cloudflare Workers AI Co-authored-by: A1L13N <193832434+A1L13N@users.noreply.github.com> --- .env.example | 11 + .gitignore | 29 + README.md | 106 +++- learning/__init__.py | 0 learning/admin.py | 69 +++ learning/ai/__init__.py | 0 learning/ai/adaptive.py | 225 ++++++++ learning/ai/cloudflare_ai.py | 151 ++++++ learning/ai/tutor.py | 232 ++++++++ learning/apps.py | 9 + learning/auth_urls.py | 9 + learning/auth_views.py | 23 + learning/management/__init__.py | 0 learning/management/commands/__init__.py | 0 learning/management/commands/seed_data.py | 311 +++++++++++ learning/migrations/0001_initial.py | 185 +++++++ .../0002_alter_learnerprofile_last_active.py | 19 + learning/migrations/__init__.py | 0 learning/models.py | 235 ++++++++ learning/urls.py | 28 + learning/views.py | 507 ++++++++++++++++++ learnpilot/__init__.py | 0 learnpilot/asgi.py | 9 + learnpilot/settings.py | 117 ++++ learnpilot/urls.py | 13 + learnpilot/wsgi.py | 9 + manage.py | 22 + requirements.txt | 6 + static/css/main.css | 23 + static/js/tutor.js | 234 ++++++++ templates/base.html | 70 +++ templates/learning/adaptive_path.html | 45 ++ templates/learning/course_detail.html | 67 +++ templates/learning/course_list.html | 56 ++ templates/learning/dashboard.html | 125 +++++ templates/learning/generate_path.html | 41 ++ templates/learning/home.html | 46 ++ templates/learning/progress.html | 80 +++ templates/learning/session.html | 127 +++++ templates/learning/session_end.html | 52 ++ templates/registration/login.html | 45 ++ templates/registration/register.html | 46 ++ tests/__init__.py | 0 tests/test_adaptive.py | 84 +++ tests/test_cloudflare_ai.py | 106 ++++ tests/test_models.py | 157 ++++++ tests/test_tutor.py | 92 ++++ tests/test_views.py | 220 ++++++++ workers/README.md | 64 +++ workers/src/worker.py | 366 +++++++++++++ workers/wrangler.toml | 15 + 51 files changed, 4485 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 learning/__init__.py create mode 100644 learning/admin.py create mode 100644 learning/ai/__init__.py create mode 100644 learning/ai/adaptive.py create mode 100644 learning/ai/cloudflare_ai.py create mode 100644 learning/ai/tutor.py create mode 100644 learning/apps.py create mode 100644 learning/auth_urls.py create mode 100644 learning/auth_views.py create mode 100644 learning/management/__init__.py create mode 100644 learning/management/commands/__init__.py create mode 100644 learning/management/commands/seed_data.py create mode 100644 learning/migrations/0001_initial.py create mode 100644 learning/migrations/0002_alter_learnerprofile_last_active.py create mode 100644 learning/migrations/__init__.py create mode 100644 learning/models.py create mode 100644 learning/urls.py create mode 100644 learning/views.py create mode 100644 learnpilot/__init__.py create mode 100644 learnpilot/asgi.py create mode 100644 learnpilot/settings.py create mode 100644 learnpilot/urls.py create mode 100644 learnpilot/wsgi.py create mode 100644 manage.py create mode 100644 requirements.txt create mode 100644 static/css/main.css create mode 100644 static/js/tutor.js create mode 100644 templates/base.html create mode 100644 templates/learning/adaptive_path.html create mode 100644 templates/learning/course_detail.html create mode 100644 templates/learning/course_list.html create mode 100644 templates/learning/dashboard.html create mode 100644 templates/learning/generate_path.html create mode 100644 templates/learning/home.html create mode 100644 templates/learning/progress.html create mode 100644 templates/learning/session.html create mode 100644 templates/learning/session_end.html create mode 100644 templates/registration/login.html create mode 100644 templates/registration/register.html create mode 100644 tests/__init__.py create mode 100644 tests/test_adaptive.py create mode 100644 tests/test_cloudflare_ai.py create mode 100644 tests/test_models.py create mode 100644 tests/test_tutor.py create mode 100644 tests/test_views.py create mode 100644 workers/README.md create mode 100644 workers/src/worker.py create mode 100644 workers/wrangler.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..00815bd --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Django settings +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Cloudflare AI credentials +CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token + +# Optional: Cloudflare Worker URL (if deployed) +CLOUDFLARE_WORKER_URL=https://learnpilot-ai.your-subdomain.workers.dev diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2426314 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ +.env +.venv/ +venv/ +env/ +ENV/ +db.sqlite3 +*.sqlite3 +*.log +.DS_Store +staticfiles/ +media/ +.idea/ +.vscode/ +*.swp +*.swo +node_modules/ +.node_modules/ +workers/.wrangler/ +workers/node_modules/ diff --git a/README.md b/README.md index 0d1aecb..4fa40e5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,106 @@ # learnpilot -AI-powered personalized learning lab that adapts to each learner in real time. Combines natural language processing, adaptive curricula, intelligent tutoring, and progress tracking to create a dynamic educational experience with interactive explanations, guided practice, and continuous feedback. +AI-powered personalized learning lab that adapts to each learner in real time. Combines natural language processing, adaptive curricula, intelligent tutoring, and progress tracking to create a dynamic educational experience with interactive explanations, guided practice, and continuous feedback. uses Cloudflare AI python workers + +## Features + +- 🧠 **Adaptive Curricula** – Cloudflare Workers AI generates a personalised learning path based on each learner's skill level, learning style, and goals. +- πŸ’¬ **Intelligent Tutoring** – Real-time AI tutor chat powered by `@cf/meta/llama-3.1-8b-instruct` explains concepts, answers questions, and adapts to the learner. +- πŸ“ˆ **Progress Tracking** – XP system, day streaks, per-lesson scores, and AI-generated progress insights. +- ✏️ **Guided Practice** – AI-generated practice questions with instant evaluation and constructive feedback. +- πŸ—ΊοΈ **Personalised Paths** – Each learner gets a unique ordered learning path tailored to their knowledge gaps and goals. + +## Architecture + +``` +LearnPilot Django app (web UI + REST API) + β”‚ + β”‚ HTTP (when CLOUDFLARE_WORKER_URL is configured) + β–Ό +Cloudflare Python Worker (workers/src/worker.py) + β”‚ + β”‚ Workers AI binding (env.AI) + β–Ό +Cloudflare Workers AI (@cf/meta/llama-3.1-8b-instruct) +``` + +When `CLOUDFLARE_WORKER_URL` is **not** set, the Django app calls the +[Cloudflare Workers AI REST API](https://developers.cloudflare.com/workers-ai/get-started/rest-api/) +directly using your `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`. + +## Quick Start + +### 1. Clone & install dependencies + +```bash +git clone https://github.com/alphaonelabs/learnpilot.git +cd learnpilot +pip install -r requirements.txt +``` + +### 2. Configure environment + +```bash +cp .env.example .env +# Edit .env and set: +# SECRET_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN +``` + +### 3. Initialise the database + +```bash +python manage.py migrate +python manage.py seed_data # Loads sample topics, courses, and lessons +python manage.py createsuperuser +``` + +### 4. Run the development server + +```bash +python manage.py runserver +``` + +Open [http://127.0.0.1:8000/](http://127.0.0.1:8000/) in your browser. + +## Deploy Cloudflare Python Worker (optional) + +See [`workers/README.md`](workers/README.md) for step-by-step deployment instructions. + +Once deployed, set `CLOUDFLARE_WORKER_URL` in your `.env` to route AI +requests through the edge worker for lower latency. + +## Running Tests + +```bash +python manage.py test tests +``` + +## Project Structure + +``` +learnpilot/ +β”œβ”€β”€ manage.py +β”œβ”€β”€ requirements.txt +β”œβ”€β”€ .env.example +β”œβ”€β”€ learnpilot/ # Django project settings & root URLs +β”œβ”€β”€ learning/ # Main Django app +β”‚ β”œβ”€β”€ models.py # Topic, Course, Lesson, LearnerProfile, Progress, … +β”‚ β”œβ”€β”€ views.py # Dashboard, course list, tutoring session, progress +β”‚ β”œβ”€β”€ urls.py +β”‚ β”œβ”€β”€ admin.py +β”‚ β”œβ”€β”€ ai/ +β”‚ β”‚ β”œβ”€β”€ cloudflare_ai.py # Cloudflare Workers AI HTTP client +β”‚ β”‚ β”œβ”€β”€ tutor.py # IntelligentTutor – explain, practice, evaluate +β”‚ β”‚ └── adaptive.py # AdaptiveCurriculum – path generation, difficulty +β”‚ └── management/commands/ +β”‚ └── seed_data.py # Sample topics, courses, and lessons +β”œβ”€β”€ templates/ # Django HTML templates (Tailwind CSS via CDN) +β”œβ”€β”€ static/ +β”‚ β”œβ”€β”€ css/main.css +β”‚ └── js/tutor.js # Real-time tutor chat UI +β”œβ”€β”€ tests/ # Unit & integration tests +└── workers/ # Cloudflare Python Worker + β”œβ”€β”€ wrangler.toml + β”œβ”€β”€ src/worker.py # Edge worker with Cloudflare AI bindings + └── README.md +``` + diff --git a/learning/__init__.py b/learning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning/admin.py b/learning/admin.py new file mode 100644 index 0000000..0c1a0c4 --- /dev/null +++ b/learning/admin.py @@ -0,0 +1,69 @@ +"""Admin registration for learning models.""" + +from django.contrib import admin + +from .models import ( + AdaptivePath, + Course, + Lesson, + LearnerProfile, + LearningSession, + Message, + PathLesson, + Progress, + Topic, +) + + +@admin.register(Topic) +class TopicAdmin(admin.ModelAdmin): + list_display = ("name", "difficulty", "icon") + search_fields = ("name",) + + +@admin.register(Course) +class CourseAdmin(admin.ModelAdmin): + list_display = ("title", "topic", "difficulty", "estimated_hours", "is_published") + list_filter = ("topic", "difficulty", "is_published") + search_fields = ("title",) + + +@admin.register(Lesson) +class LessonAdmin(admin.ModelAdmin): + list_display = ("title", "course", "lesson_type", "order", "xp_reward") + list_filter = ("course__topic", "lesson_type") + search_fields = ("title",) + ordering = ("course", "order") + + +@admin.register(LearnerProfile) +class LearnerProfileAdmin(admin.ModelAdmin): + list_display = ("user", "skill_level", "learning_style", "total_xp", "streak_days") + search_fields = ("user__username",) + + +@admin.register(Progress) +class ProgressAdmin(admin.ModelAdmin): + list_display = ("learner", "lesson", "score", "completed", "attempts") + list_filter = ("completed",) + + +@admin.register(AdaptivePath) +class AdaptivePathAdmin(admin.ModelAdmin): + list_display = ("learner", "topic", "is_active", "created_at") + list_filter = ("topic", "is_active") + + +admin.register(PathLesson)(admin.ModelAdmin) + + +@admin.register(LearningSession) +class LearningSessionAdmin(admin.ModelAdmin): + list_display = ("learner", "lesson", "started_at", "is_active") + list_filter = ("is_active",) + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ("session", "role", "created_at") + list_filter = ("role",) diff --git a/learning/ai/__init__.py b/learning/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning/ai/adaptive.py b/learning/ai/adaptive.py new file mode 100644 index 0000000..7e0b832 --- /dev/null +++ b/learning/ai/adaptive.py @@ -0,0 +1,225 @@ +""" +Adaptive Curriculum module. + +Uses Cloudflare Workers AI to personalise learning paths, adjust +difficulty in real time, and recommend the next best lesson based on +each learner's history and performance. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +from .cloudflare_ai import CloudflareAIClient, get_ai_client + +if TYPE_CHECKING: + from learning.models import AdaptivePath, LearnerProfile, Lesson, Topic + +logger = logging.getLogger(__name__) + +_CURRICULUM_SYSTEM_PROMPT = """You are an expert curriculum designer and learning \ +scientist. You create highly personalised, adaptive learning paths that maximise \ +learner engagement and knowledge retention. You base your recommendations on \ +evidence-based learning principles such as spaced repetition, scaffolded \ +instruction, and Bloom's taxonomy.""" + + +class AdaptiveCurriculum: + """ + AI-powered adaptive curriculum engine backed by Cloudflare Workers AI. + + Generates personalised learning paths, adjusts difficulty, and + recommends the next lesson based on learner performance. + """ + + def __init__(self, ai_client: CloudflareAIClient | None = None): + self.ai = ai_client or get_ai_client() + + # ------------------------------------------------------------------ + # Path generation + # ------------------------------------------------------------------ + + def generate_learning_path( + self, + topic_name: str, + skill_level: str, + learning_style: str, + available_lessons: list[dict], + goals: str = "", + ) -> dict: + """ + Generate a personalised ordered learning path. + + :param topic_name: The subject area (e.g., "Python Programming"). + :param skill_level: The learner's current level. + :param learning_style: The learner's preferred modality. + :param available_lessons: List of ``{id, title, type, difficulty}`` dicts. + :param goals: Optional free-text learning goals. + :returns: ``{ordered_lesson_ids: list[int], rationale: str}`` + """ + lesson_list = json.dumps(available_lessons, indent=2) + goals_section = f"\nLearner goals: {goals}" if goals else "" + + prompt = f"""Create a personalised learning path for: +- Topic: {topic_name} +- Skill level: {skill_level} +- Learning style: {learning_style}{goals_section} + +Available lessons (JSON): +{lesson_list} + +Return a JSON object with exactly two keys: +{{ + "ordered_lesson_ids": [], + "rationale": "<2-3 sentence explanation of the path design>" +}} + +Only include lessons that are appropriate for this learner. Order them from \ +foundational to advanced.""" + + raw = self.ai.chat( + messages=[ + {"role": "system", "content": _CURRICULUM_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + return _parse_json_response(raw, default={"ordered_lesson_ids": [], "rationale": raw}) + + def recommend_next_lesson( + self, + topic_name: str, + completed_lessons: list[dict], + available_lessons: list[dict], + recent_scores: list[float], + ) -> dict: + """ + Recommend the single best next lesson given the learner's history. + + :returns: ``{lesson_id: int | None, reason: str}`` + """ + completed_titles = [l["title"] for l in completed_lessons] + avg_score = sum(recent_scores) / len(recent_scores) if recent_scores else 0.5 + + prompt = f"""A learner is studying "{topic_name}". + +Completed lessons: {json.dumps(completed_titles)} +Average recent score: {avg_score:.0%} +Available next lessons: {json.dumps(available_lessons, indent=2)} + +Which single lesson should the learner tackle next? Return JSON: +{{ + "lesson_id": , + "reason": "" +}}""" + + raw = self.ai.chat( + messages=[ + {"role": "system", "content": _CURRICULUM_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + return _parse_json_response(raw, default={"lesson_id": None, "reason": raw}) + + # ------------------------------------------------------------------ + # Difficulty adaptation + # ------------------------------------------------------------------ + + def adapt_difficulty( + self, + topic_name: str, + current_difficulty: str, + recent_scores: list[float], + struggles: list[str] | None = None, + ) -> dict: + """ + Recommend a difficulty adjustment based on recent performance. + + :returns: ``{new_difficulty: str, reasoning: str, action: str}`` + """ + avg = sum(recent_scores) / len(recent_scores) if recent_scores else 0.5 + struggle_text = "" + if struggles: + struggle_text = f"\nTopics the learner struggled with: {', '.join(struggles)}" + + prompt = f"""A learner studying "{topic_name}" at {current_difficulty} difficulty \ +has achieved an average score of {avg:.0%} over their last {len(recent_scores)} attempt(s).{struggle_text} + +Should the difficulty change? Respond with JSON: +{{ + "new_difficulty": "", + "action": "", + "reasoning": "" +}}""" + + raw = self.ai.chat( + messages=[ + {"role": "system", "content": _CURRICULUM_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + return _parse_json_response( + raw, + default={"new_difficulty": current_difficulty, "action": "maintain", "reasoning": raw}, + ) + + # ------------------------------------------------------------------ + # Feedback & insights + # ------------------------------------------------------------------ + + def generate_progress_insights( + self, + learner_name: str, + topic_name: str, + progress_data: list[dict], + ) -> str: + """ + Produce a human-readable progress report with actionable insights. + + :param progress_data: List of ``{lesson, score, completed, attempts}`` dicts. + """ + prompt = f"""Analyse {learner_name}'s learning progress in "{topic_name}": + +{json.dumps(progress_data, indent=2)} + +Write a concise progress report (4–6 sentences) that: +1. Summarises overall performance. +2. Identifies strengths. +3. Pinpoints areas needing improvement. +4. Recommends a concrete next action.""" + + return self.ai.chat( + messages=[ + {"role": "system", "content": _CURRICULUM_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _parse_json_response(raw: str, default: dict) -> dict: + """ + Extract the first JSON object from *raw* text. + + Falls back to *default* if parsing fails. + """ + # Find first '{' and last '}' + start = raw.find("{") + end = raw.rfind("}") + if start == -1 or end == -1 or end <= start: + logger.warning("Could not find JSON in AI response: %s", raw[:200]) + return default + try: + return json.loads(raw[start : end + 1]) + except json.JSONDecodeError as exc: + logger.warning("Failed to parse JSON from AI response (%s): %s", exc, raw[:200]) + return default + + +def get_adaptive_curriculum(ai_client: CloudflareAIClient | None = None) -> AdaptiveCurriculum: + """Return a configured :class:`AdaptiveCurriculum` instance.""" + return AdaptiveCurriculum(ai_client=ai_client) diff --git a/learning/ai/cloudflare_ai.py b/learning/ai/cloudflare_ai.py new file mode 100644 index 0000000..b7e5856 --- /dev/null +++ b/learning/ai/cloudflare_ai.py @@ -0,0 +1,151 @@ +""" +Cloudflare Workers AI client. + +Communicates with the Cloudflare AI REST API to run inference on +edge-deployed models, or (when CLOUDFLARE_WORKER_URL is set) routes +requests through a deployed Cloudflare Python Worker for lower latency. + +Cloudflare AI API reference: + https://developers.cloudflare.com/workers-ai/get-started/rest-api/ +""" + +import logging +from typing import Any + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + +# Default text-generation model +DEFAULT_MODEL = "@cf/meta/llama-3.1-8b-instruct" + +# Cloudflare AI REST endpoint +_CF_BASE = "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/" + + +class CloudflareAIError(Exception): + """Raised when the Cloudflare AI API returns an error.""" + + +class CloudflareAIClient: + """ + Thin wrapper around the Cloudflare Workers AI REST API. + + Usage:: + + client = CloudflareAIClient() + response = client.chat( + messages=[{"role": "user", "content": "Explain recursion"}] + ) + print(response) # "Recursion is when a function calls itself …" + """ + + def __init__( + self, + account_id: str | None = None, + api_token: str | None = None, + worker_url: str | None = None, + model: str | None = None, + timeout: int = 30, + ): + self.account_id = account_id or settings.CLOUDFLARE_ACCOUNT_ID + self.api_token = api_token or settings.CLOUDFLARE_API_TOKEN + self.worker_url = worker_url or getattr(settings, "CLOUDFLARE_WORKER_URL", "") + self.model = model or getattr(settings, "CLOUDFLARE_AI_MODEL", DEFAULT_MODEL) + self.timeout = timeout + + self._session = requests.Session() + if self.api_token: + self._session.headers.update( + { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + } + ) + + # ------------------------------------------------------------------ + # Low-level helpers + # ------------------------------------------------------------------ + + def _direct_api_url(self, model: str) -> str: + return _CF_BASE.format(account_id=self.account_id) + model + + def _run_via_direct_api(self, model: str, payload: dict[str, Any]) -> dict[str, Any]: + """Call the Cloudflare Workers AI REST API directly.""" + if not self.account_id or not self.api_token: + raise CloudflareAIError( + "CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN must be configured." + ) + url = self._direct_api_url(model) + try: + resp = self._session.post(url, json=payload, timeout=self.timeout) + resp.raise_for_status() + except requests.Timeout as exc: + raise CloudflareAIError("Cloudflare AI API request timed out.") from exc + except requests.RequestException as exc: + raise CloudflareAIError(f"Cloudflare AI API request failed: {exc}") from exc + data = resp.json() + if not data.get("success"): + errors = data.get("errors", []) + raise CloudflareAIError(f"Cloudflare AI API errors: {errors}") + return data.get("result", {}) + + def _run_via_worker(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + """Route the request through the deployed Cloudflare Python Worker.""" + url = self.worker_url.rstrip("/") + path + try: + resp = self._session.post(url, json=payload, timeout=self.timeout) + resp.raise_for_status() + except requests.Timeout as exc: + raise CloudflareAIError("Cloudflare Worker request timed out.") from exc + except requests.RequestException as exc: + raise CloudflareAIError(f"Cloudflare Worker request failed: {exc}") from exc + return resp.json() + + def run_model(self, model: str, payload: dict[str, Any]) -> dict[str, Any]: + """ + Run an AI model inference. + + If CLOUDFLARE_WORKER_URL is configured the request is forwarded to + the deployed Python Worker; otherwise the REST API is called directly. + """ + if self.worker_url: + logger.debug("Running model %s via worker at %s", model, self.worker_url) + return self._run_via_worker("/ai/run", {"model": model, **payload}) + logger.debug("Running model %s via direct Cloudflare API", model) + return self._run_via_direct_api(model, payload) + + # ------------------------------------------------------------------ + # High-level helpers + # ------------------------------------------------------------------ + + def chat( + self, + messages: list[dict[str, str]], + max_tokens: int = 1024, + model: str | None = None, + ) -> str: + """ + Send a chat messages list to the text-generation model. + + Returns the assistant's text response. + """ + result = self.run_model( + model or self.model, + {"messages": messages, "max_tokens": max_tokens}, + ) + return result.get("response", "") + + def complete(self, prompt: str, max_tokens: int = 1024, model: str | None = None) -> str: + """Single-turn text completion (wraps the prompt as a user message).""" + return self.chat( + messages=[{"role": "user", "content": prompt}], + max_tokens=max_tokens, + model=model, + ) + + +def get_ai_client() -> CloudflareAIClient: + """Return a configured :class:`CloudflareAIClient` instance.""" + return CloudflareAIClient() diff --git a/learning/ai/tutor.py b/learning/ai/tutor.py new file mode 100644 index 0000000..76d58c4 --- /dev/null +++ b/learning/ai/tutor.py @@ -0,0 +1,232 @@ +""" +Intelligent Tutor module. + +Uses Cloudflare Workers AI to provide adaptive explanations, generate +practice questions, evaluate learner answers, and produce personalised +feedback – all in real time. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from .cloudflare_ai import CloudflareAIClient, get_ai_client + +if TYPE_CHECKING: + from learning.models import Lesson, LearnerProfile + +logger = logging.getLogger(__name__) + +# System prompt that frames the AI as an educational tutor +_TUTOR_SYSTEM_PROMPT = """You are LearnPilot, an expert AI tutor specialising in \ +personalised education. You adapt your explanations to the learner's skill level \ +and preferred learning style. You are patient, encouraging, and precise. + +Guidelines: +- Keep explanations clear, structured, and appropriately concise. +- Use analogies and real-world examples to illuminate abstract concepts. +- When a learner struggles, break concepts into smaller steps. +- Acknowledge correct answers warmly; redirect incorrect ones gently. +- Ask clarifying questions when the learner's intent is ambiguous. +- Always end tutoring responses with an invitation to ask follow-up questions.""" + + +class IntelligentTutor: + """ + AI-powered tutoring engine backed by Cloudflare Workers AI. + + Each public method builds a targeted prompt and calls the AI model, + returning the generated text directly. + """ + + def __init__(self, ai_client: CloudflareAIClient | None = None): + self.ai = ai_client or get_ai_client() + + # ------------------------------------------------------------------ + # Core tutoring operations + # ------------------------------------------------------------------ + + def explain_concept( + self, + concept: str, + skill_level: str = "beginner", + learning_style: str = "visual", + context: str = "", + ) -> str: + """ + Generate a personalised explanation of *concept*. + + :param concept: The topic or term to explain. + :param skill_level: One of ``beginner``, ``intermediate``, ``advanced``. + :param learning_style: Learner's preferred style (visual, reading, kinesthetic …). + :param context: Optional extra context (e.g., surrounding lesson material). + """ + style_hints = { + "visual": "Use diagrams described in text, flowcharts, and visual metaphors.", + "auditory": "Explain as if speaking aloud; use rhythm and narrative flow.", + "reading": "Provide structured text with numbered lists and definitions.", + "kinesthetic": "Emphasise hands-on examples, exercises, and step-by-step tasks.", + } + style_hint = style_hints.get(learning_style, "") + context_section = f"\n\nLesson context:\n{context}" if context else "" + + prompt = f"""Explain the following concept to a {skill_level}-level learner. +Learning style: {learning_style}. {style_hint} + +Concept: {concept}{context_section} + +Structure your response as: +1. **Core Explanation** – what it is and why it matters (2–4 sentences). +2. **Analogy** – a memorable real-world comparison. +3. **Key Points** – 3–5 bullet points summarising what to remember. +4. **Quick Example** – a short, concrete illustration.""" + + return self.ai.chat( + messages=[ + {"role": "system", "content": _TUTOR_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + + def generate_practice_question( + self, + topic: str, + difficulty: str = "beginner", + question_type: str = "open-ended", + ) -> str: + """ + Generate a practice question to reinforce learning. + + :param topic: The topic to test. + :param difficulty: ``beginner``, ``intermediate``, or ``advanced``. + :param question_type: ``open-ended``, ``multiple-choice``, or ``true-false``. + """ + prompt = f"""Generate a {difficulty}-level {question_type} practice question about: +"{topic}" + +Format: +- **Question:** +- **Hint:** +- **Expected Answer:** """ + + return self.ai.chat( + messages=[ + {"role": "system", "content": _TUTOR_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + + def evaluate_answer( + self, + question: str, + learner_answer: str, + expected_answer: str = "", + topic: str = "", + ) -> dict[str, str | float]: + """ + Evaluate a learner's answer and return a score plus feedback. + + Returns a dict with keys ``score`` (0.0–1.0), ``feedback``, and + ``correct_answer``. + """ + context = f"Topic: {topic}\n" if topic else "" + expected = f"Expected answer context: {expected_answer}\n" if expected_answer else "" + prompt = f"""{context}Question: {question} +{expected} +Learner's answer: {learner_answer} + +Evaluate this answer and respond in exactly this format: +SCORE: +FEEDBACK: <2-3 sentences of constructive feedback> +CORRECT_ANSWER: """ + + raw = self.ai.chat( + messages=[ + {"role": "system", "content": _TUTOR_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + return _parse_evaluation(raw) + + def continue_conversation( + self, + history: list[dict[str, str]], + user_message: str, + lesson_context: str = "", + ) -> str: + """ + Continue an ongoing tutoring conversation. + + :param history: Prior ``[{role, content}, …]`` messages. + :param user_message: The learner's latest message. + :param lesson_context: The current lesson's content for grounding. + """ + system = _TUTOR_SYSTEM_PROMPT + if lesson_context: + system += f"\n\nCurrent lesson material:\n{lesson_context}" + + messages = [{"role": "system", "content": system}] + messages.extend(history[-10:]) # keep last 10 turns for context window + messages.append({"role": "user", "content": user_message}) + + return self.ai.chat(messages=messages) + + def generate_session_summary( + self, + conversation: list[dict[str, str]], + lesson_title: str, + ) -> str: + """ + Summarise a completed tutoring session for the learner. + + Returns a brief summary with key takeaways and recommended next steps. + """ + dialogue = "\n".join( + f"{m['role'].upper()}: {m['content']}" for m in conversation if m["role"] != "system" + ) + prompt = f"""A tutoring session on "{lesson_title}" just ended. +Conversation: +{dialogue} + +Write a concise session summary (3–5 sentences) that: +1. Highlights the key concepts covered. +2. Notes any misconceptions that were corrected. +3. Suggests 1–2 concrete next steps for the learner.""" + + return self.ai.chat( + messages=[ + {"role": "system", "content": _TUTOR_SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ] + ) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _parse_evaluation(raw: str) -> dict[str, str | float]: + """Parse the structured evaluation response from the AI.""" + result: dict[str, str | float] = { + "score": 0.5, + "feedback": raw, + "correct_answer": "", + } + for line in raw.splitlines(): + line = line.strip() + if line.startswith("SCORE:"): + try: + result["score"] = float(line.split(":", 1)[1].strip()) + except ValueError: + pass + elif line.startswith("FEEDBACK:"): + result["feedback"] = line.split(":", 1)[1].strip() + elif line.startswith("CORRECT_ANSWER:"): + result["correct_answer"] = line.split(":", 1)[1].strip() + return result + + +def get_tutor(ai_client: CloudflareAIClient | None = None) -> IntelligentTutor: + """Return a configured :class:`IntelligentTutor` instance.""" + return IntelligentTutor(ai_client=ai_client) diff --git a/learning/apps.py b/learning/apps.py new file mode 100644 index 0000000..0116a79 --- /dev/null +++ b/learning/apps.py @@ -0,0 +1,9 @@ +"""Learning app configuration.""" + +from django.apps import AppConfig + + +class LearningConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "learning" + verbose_name = "Learning" diff --git a/learning/auth_urls.py b/learning/auth_urls.py new file mode 100644 index 0000000..21c01aa --- /dev/null +++ b/learning/auth_urls.py @@ -0,0 +1,9 @@ +"""Registration URL for the learning app.""" + +from django.urls import path + +from .auth_views import RegisterView + +urlpatterns = [ + path("", RegisterView.as_view(), name="register"), +] diff --git a/learning/auth_views.py b/learning/auth_views.py new file mode 100644 index 0000000..55d4d9b --- /dev/null +++ b/learning/auth_views.py @@ -0,0 +1,23 @@ +"""Authentication views for the learning app.""" + +from django.contrib.auth import login +from django.contrib.auth.forms import UserCreationForm +from django.shortcuts import redirect, render +from django.views import View + + +class RegisterView(View): + template_name = "registration/register.html" + + def get(self, request): + if request.user.is_authenticated: + return redirect("dashboard") + return render(request, self.template_name, {"form": UserCreationForm()}) + + def post(self, request): + form = UserCreationForm(request.POST) + if form.is_valid(): + user = form.save() + login(request, user) + return redirect("dashboard") + return render(request, self.template_name, {"form": form}) diff --git a/learning/management/__init__.py b/learning/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning/management/commands/__init__.py b/learning/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning/management/commands/seed_data.py b/learning/management/commands/seed_data.py new file mode 100644 index 0000000..7e0281d --- /dev/null +++ b/learning/management/commands/seed_data.py @@ -0,0 +1,311 @@ +"""Management command to seed the database with sample topics, courses, and lessons.""" + +from django.core.management.base import BaseCommand + +from learning.models import Course, Lesson, Topic + +SEED_DATA = [ + { + "topic": { + "name": "Python Programming", + "description": "Learn Python from the ground up – variables, functions, OOP, and more.", + "difficulty": "beginner", + "icon": "🐍", + }, + "courses": [ + { + "title": "Python Fundamentals", + "description": "Core Python syntax and concepts for absolute beginners.", + "difficulty": "beginner", + "estimated_hours": 4.0, + "lessons": [ + { + "title": "Variables and Data Types", + "content": ( + "Understand Python variables, integers, floats, strings, " + "booleans, and the `type()` function." + ), + "lesson_type": "theory", + "order": 1, + "xp_reward": 10, + }, + { + "title": "Control Flow: if / elif / else", + "content": ( + "Learn how to make decisions in Python using conditional " + "statements and comparison operators." + ), + "lesson_type": "theory", + "order": 2, + "xp_reward": 10, + }, + { + "title": "Loops: for and while", + "content": ( + "Iterate over sequences with `for` loops and repeat " + "actions with `while` loops." + ), + "lesson_type": "practice", + "order": 3, + "xp_reward": 15, + }, + { + "title": "Functions and Scope", + "content": ( + "Define reusable functions with `def`, understand " + "parameters, return values, and variable scope." + ), + "lesson_type": "theory", + "order": 4, + "xp_reward": 15, + }, + { + "title": "Lists and Dictionaries", + "content": ( + "Work with Python's most common data structures: " + "lists (ordered, mutable) and dicts (key-value pairs)." + ), + "lesson_type": "practice", + "order": 5, + "xp_reward": 20, + }, + ], + }, + { + "title": "Object-Oriented Python", + "description": "Classes, inheritance, and design patterns in Python.", + "difficulty": "intermediate", + "estimated_hours": 6.0, + "lessons": [ + { + "title": "Classes and Objects", + "content": "Define classes, create objects, and use `__init__` constructors.", + "lesson_type": "theory", + "order": 1, + "xp_reward": 15, + }, + { + "title": "Inheritance and Polymorphism", + "content": "Extend classes with inheritance and override methods for polymorphic behaviour.", + "lesson_type": "theory", + "order": 2, + "xp_reward": 20, + }, + { + "title": "Special Methods (Dunder Methods)", + "content": "Implement `__str__`, `__repr__`, `__len__`, `__eq__` and other magic methods.", + "lesson_type": "practice", + "order": 3, + "xp_reward": 20, + }, + ], + }, + ], + }, + { + "topic": { + "name": "Web Development", + "description": "Build dynamic web applications with HTML, CSS, JavaScript, and Django.", + "difficulty": "intermediate", + "icon": "🌐", + }, + "courses": [ + { + "title": "HTML & CSS Basics", + "description": "Structure and style web pages from scratch.", + "difficulty": "beginner", + "estimated_hours": 3.0, + "lessons": [ + { + "title": "HTML Document Structure", + "content": "DOCTYPE, html, head, body, semantic tags (header, main, footer).", + "lesson_type": "theory", + "order": 1, + "xp_reward": 10, + }, + { + "title": "CSS Selectors and the Box Model", + "content": "Selectors, specificity, margin, padding, border, and the CSS box model.", + "lesson_type": "theory", + "order": 2, + "xp_reward": 10, + }, + { + "title": "Flexbox Layout", + "content": "Build responsive one-dimensional layouts using CSS Flexbox.", + "lesson_type": "practice", + "order": 3, + "xp_reward": 20, + }, + ], + }, + { + "title": "Django Web Framework", + "description": "Build full-stack web apps with Python's batteries-included framework.", + "difficulty": "intermediate", + "estimated_hours": 8.0, + "lessons": [ + { + "title": "Django MTV Architecture", + "content": "Understand Models, Templates, and Views and how requests flow through Django.", + "lesson_type": "theory", + "order": 1, + "xp_reward": 15, + }, + { + "title": "Models and the ORM", + "content": "Define database models and query the database using Django's ORM.", + "lesson_type": "theory", + "order": 2, + "xp_reward": 20, + }, + { + "title": "Class-Based Views", + "content": "Use ListView, DetailView, CreateView and other generic CBVs.", + "lesson_type": "practice", + "order": 3, + "xp_reward": 20, + }, + ], + }, + ], + }, + { + "topic": { + "name": "Data Science", + "description": "Analyse data, build models, and extract insights with Python.", + "difficulty": "intermediate", + "icon": "πŸ“Š", + }, + "courses": [ + { + "title": "Introduction to Data Analysis", + "description": "Explore and visualise data using pandas and matplotlib.", + "difficulty": "beginner", + "estimated_hours": 5.0, + "lessons": [ + { + "title": "NumPy Arrays", + "content": "Create and manipulate N-dimensional arrays with NumPy.", + "lesson_type": "theory", + "order": 1, + "xp_reward": 15, + }, + { + "title": "Pandas DataFrames", + "content": "Load, clean, filter, and aggregate tabular data with pandas.", + "lesson_type": "practice", + "order": 2, + "xp_reward": 20, + }, + { + "title": "Data Visualisation with Matplotlib", + "content": "Create line plots, bar charts, histograms, and scatter plots.", + "lesson_type": "practice", + "order": 3, + "xp_reward": 20, + }, + ], + }, + ], + }, + { + "topic": { + "name": "Machine Learning", + "description": "Build predictive models and understand core ML algorithms.", + "difficulty": "advanced", + "icon": "πŸ€–", + }, + "courses": [ + { + "title": "Supervised Learning Fundamentals", + "description": "Linear regression, logistic regression, decision trees, and SVMs.", + "difficulty": "intermediate", + "estimated_hours": 10.0, + "lessons": [ + { + "title": "Linear Regression", + "content": "Fit a line to data by minimising mean squared error; understand bias-variance tradeoff.", + "lesson_type": "theory", + "order": 1, + "xp_reward": 20, + }, + { + "title": "Logistic Regression & Classification", + "content": "Predict discrete class labels using the sigmoid function and cross-entropy loss.", + "lesson_type": "theory", + "order": 2, + "xp_reward": 20, + }, + { + "title": "Decision Trees and Random Forests", + "content": "Build tree-based models and ensemble them into Random Forests.", + "lesson_type": "practice", + "order": 3, + "xp_reward": 25, + }, + { + "title": "Model Evaluation Metrics", + "content": "Accuracy, precision, recall, F1-score, ROC-AUC, and cross-validation.", + "lesson_type": "quiz", + "order": 4, + "xp_reward": 15, + }, + ], + }, + ], + }, +] + + +class Command(BaseCommand): + help = "Seed the database with sample topics, courses, and lessons." + + def handle(self, *args, **options): + created_topics = 0 + created_courses = 0 + created_lessons = 0 + + for entry in SEED_DATA: + topic_data = entry["topic"] + topic, t_created = Topic.objects.get_or_create( + name=topic_data["name"], + defaults={ + "description": topic_data["description"], + "difficulty": topic_data["difficulty"], + "icon": topic_data["icon"], + }, + ) + if t_created: + created_topics += 1 + + for course_data in entry["courses"]: + lessons_data = course_data.pop("lessons") + course, c_created = Course.objects.get_or_create( + title=course_data["title"], + topic=topic, + defaults={**course_data}, + ) + if c_created: + created_courses += 1 + + for lesson_data in lessons_data: + _, l_created = Lesson.objects.get_or_create( + title=lesson_data["title"], + course=course, + defaults={ + "content": lesson_data["content"], + "lesson_type": lesson_data["lesson_type"], + "order": lesson_data["order"], + "xp_reward": lesson_data["xp_reward"], + }, + ) + if l_created: + created_lessons += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Seed complete: {created_topics} topics, " + f"{created_courses} courses, {created_lessons} lessons created." + ) + ) diff --git a/learning/migrations/0001_initial.py b/learning/migrations/0001_initial.py new file mode 100644 index 0000000..4ff7628 --- /dev/null +++ b/learning/migrations/0001_initial.py @@ -0,0 +1,185 @@ +# Generated by Django 4.2.20 on 2026-03-08 23:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AdaptivePath', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rationale', models.TextField(blank=True, help_text='AI-generated explanation of path choices')), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Adaptive Path', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField()), + ('difficulty', models.CharField(choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], default='beginner', max_length=20)), + ('estimated_hours', models.FloatField(default=1.0)), + ('is_published', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['topic', 'difficulty', 'title'], + }, + ), + migrations.CreateModel( + name='LearnerProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('learning_style', models.CharField(choices=[('visual', 'Visual'), ('auditory', 'Auditory'), ('reading', 'Reading/Writing'), ('kinesthetic', 'Hands-on/Kinesthetic')], default='visual', max_length=20)), + ('skill_level', models.CharField(choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], default='beginner', max_length=20)), + ('total_xp', models.PositiveIntegerField(default=0)), + ('streak_days', models.PositiveIntegerField(default=0)), + ('last_active', models.DateTimeField(default=django.utils.timezone.now)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Learner Profile', + }, + ), + migrations.CreateModel( + name='LearningSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('ended_at', models.DateTimeField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('learner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='learning.learnerprofile')), + ], + options={ + 'ordering': ['-started_at'], + }, + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('content', models.TextField(help_text='Core lesson content / learning objectives')), + ('lesson_type', models.CharField(choices=[('theory', 'Theory'), ('practice', 'Practice'), ('quiz', 'Quiz'), ('project', 'Project')], default='theory', max_length=20)), + ('order', models.PositiveIntegerField(default=0)), + ('xp_reward', models.PositiveIntegerField(default=10, help_text='Experience points awarded on completion')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='learning.course')), + ], + options={ + 'ordering': ['course', 'order'], + }, + ), + migrations.CreateModel( + name='Topic', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('description', models.TextField()), + ('difficulty', models.CharField(choices=[('beginner', 'Beginner'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], default='beginner', max_length=20)), + ('icon', models.CharField(default='πŸ“š', help_text='Emoji icon for the topic', max_length=50)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='PathLesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField(default=0)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning.lesson')), + ('path', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning.adaptivepath')), + ], + options={ + 'ordering': ['order'], + 'unique_together': {('path', 'lesson')}, + }, + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('user', 'Learner'), ('assistant', 'AI Tutor'), ('system', 'System')], max_length=20)), + ('content', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='learning.learningsession')), + ], + options={ + 'ordering': ['created_at'], + }, + ), + migrations.AddField( + model_name='learningsession', + name='lesson', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='learning.lesson'), + ), + migrations.AddField( + model_name='learnerprofile', + name='preferred_topics', + field=models.ManyToManyField(blank=True, related_name='interested_learners', to='learning.topic'), + ), + migrations.AddField( + model_name='learnerprofile', + name='user', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='learner_profile', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='course', + name='topic', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='learning.topic'), + ), + migrations.AddField( + model_name='adaptivepath', + name='learner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='adaptive_paths', to='learning.learnerprofile'), + ), + migrations.AddField( + model_name='adaptivepath', + name='lessons', + field=models.ManyToManyField(related_name='adaptive_paths', through='learning.PathLesson', to='learning.lesson'), + ), + migrations.AddField( + model_name='adaptivepath', + name='topic', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='learning.topic'), + ), + migrations.CreateModel( + name='Progress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.FloatField(default=0.0, help_text='Normalised score 0.0–1.0')), + ('completed', models.BooleanField(default=False)), + ('attempts', models.PositiveIntegerField(default=0)), + ('time_spent_seconds', models.PositiveIntegerField(default=0)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('learner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_records', to='learning.learnerprofile')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_records', to='learning.lesson')), + ], + options={ + 'verbose_name_plural': 'Progress records', + 'unique_together': {('learner', 'lesson')}, + }, + ), + ] diff --git a/learning/migrations/0002_alter_learnerprofile_last_active.py b/learning/migrations/0002_alter_learnerprofile_last_active.py new file mode 100644 index 0000000..a39fb5d --- /dev/null +++ b/learning/migrations/0002_alter_learnerprofile_last_active.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.20 on 2026-03-08 23:02 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='learnerprofile', + name='last_active', + field=models.DateTimeField(blank=True, default=django.utils.timezone.now, null=True), + ), + ] diff --git a/learning/migrations/__init__.py b/learning/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learning/models.py b/learning/models.py new file mode 100644 index 0000000..2920343 --- /dev/null +++ b/learning/models.py @@ -0,0 +1,235 @@ +"""Models for the learning app.""" + +from django.contrib.auth.models import User +from django.db import models +from django.utils import timezone + + +class Topic(models.Model): + """A subject area for learning (e.g., Python, Mathematics).""" + + DIFFICULTY_CHOICES = [ + ("beginner", "Beginner"), + ("intermediate", "Intermediate"), + ("advanced", "Advanced"), + ] + + name = models.CharField(max_length=200) + description = models.TextField() + difficulty = models.CharField(max_length=20, choices=DIFFICULTY_CHOICES, default="beginner") + icon = models.CharField(max_length=50, default="πŸ“š", help_text="Emoji icon for the topic") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + +class Course(models.Model): + """A structured course within a topic.""" + + DIFFICULTY_CHOICES = [ + ("beginner", "Beginner"), + ("intermediate", "Intermediate"), + ("advanced", "Advanced"), + ] + + title = models.CharField(max_length=200) + description = models.TextField() + topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name="courses") + difficulty = models.CharField(max_length=20, choices=DIFFICULTY_CHOICES, default="beginner") + estimated_hours = models.FloatField(default=1.0) + is_published = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["topic", "difficulty", "title"] + + def __str__(self): + return f"{self.title} ({self.topic})" + + def total_lessons(self): + return self.lessons.count() + + +class Lesson(models.Model): + """An individual lesson within a course.""" + + LESSON_TYPES = [ + ("theory", "Theory"), + ("practice", "Practice"), + ("quiz", "Quiz"), + ("project", "Project"), + ] + + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="lessons") + title = models.CharField(max_length=200) + content = models.TextField(help_text="Core lesson content / learning objectives") + lesson_type = models.CharField(max_length=20, choices=LESSON_TYPES, default="theory") + order = models.PositiveIntegerField(default=0) + xp_reward = models.PositiveIntegerField(default=10, help_text="Experience points awarded on completion") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["course", "order"] + + def __str__(self): + return f"{self.course.title} – {self.title}" + + +class LearnerProfile(models.Model): + """Extended profile for a learner with adaptive learning data.""" + + LEARNING_STYLES = [ + ("visual", "Visual"), + ("auditory", "Auditory"), + ("reading", "Reading/Writing"), + ("kinesthetic", "Hands-on/Kinesthetic"), + ] + + SKILL_LEVELS = [ + ("beginner", "Beginner"), + ("intermediate", "Intermediate"), + ("advanced", "Advanced"), + ] + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="learner_profile") + learning_style = models.CharField(max_length=20, choices=LEARNING_STYLES, default="visual") + skill_level = models.CharField(max_length=20, choices=SKILL_LEVELS, default="beginner") + preferred_topics = models.ManyToManyField(Topic, blank=True, related_name="interested_learners") + total_xp = models.PositiveIntegerField(default=0) + streak_days = models.PositiveIntegerField(default=0) + last_active = models.DateTimeField(null=True, blank=True, default=timezone.now) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Learner Profile" + + def __str__(self): + return f"{self.user.username}'s profile" + + def update_activity(self): + """Update streak and last-active timestamp.""" + now = timezone.now() + if self.last_active: + delta = now.date() - self.last_active.date() + if delta.days == 1: + self.streak_days += 1 + elif delta.days > 1: + self.streak_days = 1 + else: + self.streak_days = 1 + self.last_active = now + self.save(update_fields=["streak_days", "last_active"]) + + +class Progress(models.Model): + """Tracks a learner's progress through a specific lesson.""" + + learner = models.ForeignKey(LearnerProfile, on_delete=models.CASCADE, related_name="progress_records") + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name="progress_records") + score = models.FloatField(default=0.0, help_text="Normalised score 0.0–1.0") + completed = models.BooleanField(default=False) + attempts = models.PositiveIntegerField(default=0) + time_spent_seconds = models.PositiveIntegerField(default=0) + completed_at = models.DateTimeField(null=True, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("learner", "lesson") + verbose_name_plural = "Progress records" + + def __str__(self): + status = "βœ“" if self.completed else "…" + return f"{status} {self.learner.user.username} – {self.lesson.title}" + + def mark_complete(self, score: float): + self.score = max(0.0, min(1.0, score)) + self.completed = True + self.completed_at = timezone.now() + self.attempts += 1 + self.save() + # Award XP + self.learner.total_xp += self.lesson.xp_reward + self.learner.save(update_fields=["total_xp"]) + + +class AdaptivePath(models.Model): + """A personalised learning path generated by the AI for a learner.""" + + learner = models.ForeignKey(LearnerProfile, on_delete=models.CASCADE, related_name="adaptive_paths") + topic = models.ForeignKey(Topic, on_delete=models.CASCADE) + lessons = models.ManyToManyField(Lesson, through="PathLesson", related_name="adaptive_paths") + rationale = models.TextField(blank=True, help_text="AI-generated explanation of path choices") + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Adaptive Path" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.learner.user.username} – {self.topic.name} path" + + +class PathLesson(models.Model): + """Ordered membership of a lesson in an adaptive path.""" + + path = models.ForeignKey(AdaptivePath, on_delete=models.CASCADE) + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["order"] + unique_together = ("path", "lesson") + + +class LearningSession(models.Model): + """An active or completed tutoring session for a learner on a lesson.""" + + learner = models.ForeignKey(LearnerProfile, on_delete=models.CASCADE, related_name="sessions") + lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name="sessions") + started_at = models.DateTimeField(auto_now_add=True) + ended_at = models.DateTimeField(null=True, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["-started_at"] + + def __str__(self): + return f"Session: {self.learner.user.username} on '{self.lesson.title}'" + + def end_session(self): + self.ended_at = timezone.now() + self.is_active = False + self.save(update_fields=["ended_at", "is_active"]) + + def duration_seconds(self): + if self.ended_at: + return int((self.ended_at - self.started_at).total_seconds()) + return int((timezone.now() - self.started_at).total_seconds()) + + +class Message(models.Model): + """A chat message within a learning session.""" + + ROLES = [ + ("user", "Learner"), + ("assistant", "AI Tutor"), + ("system", "System"), + ] + + session = models.ForeignKey(LearningSession, on_delete=models.CASCADE, related_name="messages") + role = models.CharField(max_length=20, choices=ROLES) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] + + def __str__(self): + return f"[{self.role}] {self.content[:60]}…" diff --git a/learning/urls.py b/learning/urls.py new file mode 100644 index 0000000..67c3a8f --- /dev/null +++ b/learning/urls.py @@ -0,0 +1,28 @@ +"""URL configuration for the learning app.""" + +from django.urls import path + +from . import views + +urlpatterns = [ + # Home + path("", views.HomeView.as_view(), name="home"), + # Dashboard + path("dashboard/", views.DashboardView.as_view(), name="dashboard"), + # Courses + path("courses/", views.CourseListView.as_view(), name="course_list"), + path("courses//", views.CourseDetailView.as_view(), name="course_detail"), + # Adaptive path + path("topics//generate-path/", views.generate_path_view, name="generate_path"), + path("paths//", views.AdaptivePathDetailView.as_view(), name="adaptive_path_detail"), + # Tutoring sessions + path("lessons//start/", views.start_session_view, name="start_session"), + path("sessions//", views.TutorSessionView.as_view(), name="tutor_session"), + path("sessions//chat/", views.tutor_chat_api, name="tutor_chat_api"), + path("sessions//end/", views.end_session_view, name="end_session"), + # Practice & feedback + path("lessons//practice/", views.practice_question_api, name="practice_question_api"), + path("evaluate/", views.evaluate_answer_api, name="evaluate_answer_api"), + # Progress + path("progress/", views.ProgressView.as_view(), name="progress"), +] diff --git a/learning/views.py b/learning/views.py new file mode 100644 index 0000000..8a69cec --- /dev/null +++ b/learning/views.py @@ -0,0 +1,507 @@ +"""Views for the learning app.""" + +from __future__ import annotations + +import json +import logging + +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views import View +from django.views.generic import DetailView, ListView, TemplateView + +from .ai.adaptive import get_adaptive_curriculum +from .ai.cloudflare_ai import CloudflareAIError +from .ai.tutor import get_tutor +from .models import ( + AdaptivePath, + Course, + Lesson, + LearnerProfile, + LearningSession, + Message, + PathLesson, + Progress, + Topic, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_or_create_profile(user) -> LearnerProfile: + profile, _ = LearnerProfile.objects.get_or_create(user=user) + return profile + + +def _progress_map(profile: LearnerProfile) -> dict[int, Progress]: + """Return a mapping of lesson_id β†’ Progress for the given profile.""" + return {p.lesson_id: p for p in profile.progress_records.all()} + + +# --------------------------------------------------------------------------- +# Home / landing page +# --------------------------------------------------------------------------- + +class HomeView(TemplateView): + template_name = "learning/home.html" + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated: + return redirect("dashboard") + return super().get(request, *args, **kwargs) + + +# --------------------------------------------------------------------------- +# Dashboard +# --------------------------------------------------------------------------- + +class DashboardView(LoginRequiredMixin, TemplateView): + template_name = "learning/dashboard.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = _get_or_create_profile(self.request.user) + profile.update_activity() + + progress_qs = profile.progress_records.select_related("lesson__course__topic").filter(completed=True) + completed_lessons = progress_qs.count() + recent_progress = progress_qs.order_by("-completed_at")[:5] + + active_sessions = profile.sessions.filter(is_active=True).select_related("lesson__course") + paths = profile.adaptive_paths.filter(is_active=True).select_related("topic")[:3] + + ctx.update( + { + "profile": profile, + "completed_lessons": completed_lessons, + "recent_progress": recent_progress, + "active_sessions": active_sessions, + "adaptive_paths": paths, + "topics": Topic.objects.all()[:6], + } + ) + return ctx + + +# --------------------------------------------------------------------------- +# Courses +# --------------------------------------------------------------------------- + +class CourseListView(LoginRequiredMixin, ListView): + model = Course + template_name = "learning/course_list.html" + context_object_name = "courses" + + def get_queryset(self): + qs = Course.objects.filter(is_published=True).select_related("topic") + topic_id = self.request.GET.get("topic") + difficulty = self.request.GET.get("difficulty") + if topic_id: + qs = qs.filter(topic_id=topic_id) + if difficulty: + qs = qs.filter(difficulty=difficulty) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["topics"] = Topic.objects.all() + ctx["selected_topic"] = self.request.GET.get("topic") + ctx["selected_difficulty"] = self.request.GET.get("difficulty") + return ctx + + +class CourseDetailView(LoginRequiredMixin, DetailView): + model = Course + template_name = "learning/course_detail.html" + context_object_name = "course" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = _get_or_create_profile(self.request.user) + prog_map = _progress_map(profile) + lessons = self.object.lessons.all() + lesson_data = [ + { + "lesson": l, + "progress": prog_map.get(l.pk), + } + for l in lessons + ] + ctx["lesson_data"] = lesson_data + ctx["profile"] = profile + return ctx + + +# --------------------------------------------------------------------------- +# Adaptive path generation +# --------------------------------------------------------------------------- + +@login_required +def generate_path_view(request, topic_id: int): + topic = get_object_or_404(Topic, pk=topic_id) + profile = _get_or_create_profile(request.user) + + if request.method == "POST": + try: + available = list( + topic.courses.filter(is_published=True) + .values_list("lessons__id", "lessons__title", "lessons__lesson_type", "lessons__course__difficulty") + .order_by("lessons__course__difficulty", "lessons__order") + ) + lesson_dicts = [ + {"id": row[0], "title": row[1], "type": row[2], "difficulty": row[3]} + for row in available + if row[0] is not None + ] + + curriculum = get_adaptive_curriculum() + result = curriculum.generate_learning_path( + topic_name=topic.name, + skill_level=profile.skill_level, + learning_style=profile.learning_style, + available_lessons=lesson_dicts, + goals=request.POST.get("goals", ""), + ) + + # Deactivate old paths for this topic + profile.adaptive_paths.filter(topic=topic, is_active=True).update(is_active=False) + + path = AdaptivePath.objects.create( + learner=profile, + topic=topic, + rationale=result.get("rationale", ""), + ) + ordered_ids = result.get("ordered_lesson_ids", []) + for order, lesson_id in enumerate(ordered_ids): + try: + lesson = Lesson.objects.get(pk=lesson_id) + PathLesson.objects.create(path=path, lesson=lesson, order=order) + except Lesson.DoesNotExist: + pass + + messages.success(request, f'Your personalised path for "{topic.name}" is ready!') + return redirect("adaptive_path_detail", pk=path.pk) + + except CloudflareAIError as exc: + logger.error("CloudflareAIError generating path: %s", exc) + messages.error(request, "Could not reach AI service. Please try again later.") + + return render(request, "learning/generate_path.html", {"topic": topic, "profile": profile}) + + +class AdaptivePathDetailView(LoginRequiredMixin, DetailView): + model = AdaptivePath + template_name = "learning/adaptive_path.html" + context_object_name = "path" + + def get_queryset(self): + profile = _get_or_create_profile(self.request.user) + return AdaptivePath.objects.filter(learner=profile) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = _get_or_create_profile(self.request.user) + prog_map = _progress_map(profile) + path_lessons = ( + self.object.pathlesson_set.select_related("lesson__course").order_by("order") + ) + ctx["path_lessons"] = [ + {"pl": pl, "progress": prog_map.get(pl.lesson_id)} + for pl in path_lessons + ] + return ctx + + +# --------------------------------------------------------------------------- +# Tutoring session +# --------------------------------------------------------------------------- + +@login_required +def start_session_view(request, lesson_id: int): + lesson = get_object_or_404(Lesson, pk=lesson_id) + profile = _get_or_create_profile(request.user) + + # Reuse an existing active session or create a new one + session = profile.sessions.filter(lesson=lesson, is_active=True).first() + if not session: + session = LearningSession.objects.create(learner=profile, lesson=lesson) + # Seed with an opening message from the tutor + try: + tutor = get_tutor() + greeting = tutor.explain_concept( + concept=lesson.title, + skill_level=profile.skill_level, + learning_style=profile.learning_style, + context=lesson.content[:500], + ) + except CloudflareAIError: + greeting = ( + f'Welcome to "{lesson.title}"! I\'m your AI tutor. ' + "Ask me anything about this lesson." + ) + Message.objects.create(session=session, role="assistant", content=greeting) + + return redirect("tutor_session", session_id=session.pk) + + +class TutorSessionView(LoginRequiredMixin, View): + template_name = "learning/session.html" + + def get(self, request, session_id: int): + profile = _get_or_create_profile(request.user) + session = get_object_or_404(LearningSession, pk=session_id, learner=profile) + chat_history = session.messages.order_by("created_at") + prog, _ = Progress.objects.get_or_create(learner=profile, lesson=session.lesson) + + return render( + request, + self.template_name, + { + "session": session, + "chat_history": chat_history, + "progress": prog, + "profile": profile, + }, + ) + + +@login_required +def tutor_chat_api(request, session_id: int): + """AJAX endpoint: receive a learner message, return AI tutor response.""" + if request.method != "POST": + return JsonResponse({"error": "POST required"}, status=405) + + profile = _get_or_create_profile(request.user) + session = get_object_or_404(LearningSession, pk=session_id, learner=profile) + + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + user_message = body.get("message", "").strip() + if not user_message: + return JsonResponse({"error": "Empty message"}, status=400) + + # Persist learner message + Message.objects.create(session=session, role="user", content=user_message) + + # Build conversation history (last 20 messages, excluding the one just added) + all_messages = list( + session.messages.exclude(role="system").order_by("created_at").values("role", "content") + ) + # Exclude the last message (the user message just persisted) so it's passed separately + history = all_messages[:-1][-20:] + + try: + tutor = get_tutor() + ai_response = tutor.continue_conversation( + history=history, + user_message=user_message, + lesson_context=session.lesson.content[:800], + ) + except CloudflareAIError as exc: + logger.error("CloudflareAIError in chat: %s", exc) + ai_response = "I'm having trouble connecting to my AI backend right now. Please try again in a moment." + + # Persist tutor message + msg = Message.objects.create(session=session, role="assistant", content=ai_response) + return JsonResponse( + { + "response": ai_response, + "message_id": msg.pk, + "timestamp": msg.created_at.isoformat(), + } + ) + + +@login_required +def end_session_view(request, session_id: int): + """End a tutoring session, generate a summary, and update progress.""" + profile = _get_or_create_profile(request.user) + session = get_object_or_404(LearningSession, pk=session_id, learner=profile) + + if session.is_active: + session.end_session() + + # Generate summary + conversation = [ + {"role": m.role, "content": m.content} + for m in session.messages.order_by("created_at") + ] + try: + tutor = get_tutor() + summary = tutor.generate_session_summary( + conversation=conversation, + lesson_title=session.lesson.title, + ) + except CloudflareAIError: + summary = "Session completed." + + # Update progress + prog, _ = Progress.objects.get_or_create(learner=profile, lesson=session.lesson) + prog.attempts += 1 + prog.time_spent_seconds += session.duration_seconds() + if not prog.completed: + # Mark as completed with a default score of 0.7 for finishing the session + prog.mark_complete(score=0.7) + else: + prog.save() + + return render( + request, + "learning/session_end.html", + {"session": session, "summary": summary, "progress": prog}, + ) + + +# --------------------------------------------------------------------------- +# Practice & feedback +# --------------------------------------------------------------------------- + +@login_required +def practice_question_api(request, lesson_id: int): + """Return an AI-generated practice question for the lesson.""" + if request.method != "GET": + return JsonResponse({"error": "GET required"}, status=405) + + lesson = get_object_or_404(Lesson, pk=lesson_id) + profile = _get_or_create_profile(request.user) + + try: + tutor = get_tutor() + question = tutor.generate_practice_question( + topic=lesson.title, + difficulty=lesson.course.difficulty, + question_type="open-ended", + ) + except CloudflareAIError as exc: + logger.error("CloudflareAIError generating question: %s", exc) + return JsonResponse({"error": "AI service unavailable"}, status=503) + + return JsonResponse({"question": question, "lesson_id": lesson_id}) + + +@login_required +def evaluate_answer_api(request): + """Evaluate a learner's answer and return score + feedback.""" + if request.method != "POST": + return JsonResponse({"error": "POST required"}, status=405) + + try: + body = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + question = body.get("question", "").strip() + answer = body.get("answer", "").strip() + lesson_id = body.get("lesson_id") + + if not question or not answer: + return JsonResponse({"error": "question and answer required"}, status=400) + + topic = "" + if lesson_id: + lesson = Lesson.objects.filter(pk=lesson_id).first() + if lesson: + topic = lesson.title + + try: + tutor = get_tutor() + result = tutor.evaluate_answer(question=question, learner_answer=answer, topic=topic) + except CloudflareAIError as exc: + logger.error("CloudflareAIError evaluating answer: %s", exc) + return JsonResponse({"error": "AI service unavailable"}, status=503) + + # Optionally update progress score + if lesson_id: + profile = _get_or_create_profile(request.user) + prog, _ = Progress.objects.get_or_create(learner=profile, lesson_id=lesson_id) + score = float(result.get("score", 0.5)) + if score > prog.score: + prog.score = score + prog.attempts += 1 + if score >= 0.8 and not prog.completed: + prog.mark_complete(score=score) + else: + prog.save() + + return JsonResponse(result) + + +# --------------------------------------------------------------------------- +# Progress +# --------------------------------------------------------------------------- + +class ProgressView(LoginRequiredMixin, TemplateView): + template_name = "learning/progress.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + profile = _get_or_create_profile(self.request.user) + progress_qs = ( + profile.progress_records + .select_related("lesson__course__topic") + .order_by("lesson__course__topic__name", "lesson__order") + ) + + # Group by topic + by_topic: dict[str, list] = {} + for p in progress_qs: + topic_name = p.lesson.course.topic.name + by_topic.setdefault(topic_name, []).append(p) + + # Adaptive recommendations + recommendations = [] + recent_scores = [p.score for p in progress_qs if p.completed][-5:] + for topic in Topic.objects.all(): + incomplete = ( + Lesson.objects.filter(course__topic=topic, course__is_published=True) + .exclude(progress_records__learner=profile, progress_records__completed=True) + .select_related("course")[:3] + ) + if incomplete.exists(): + recommendations.append({"topic": topic, "lessons": incomplete}) + if len(recommendations) >= 3: + break + + try: + if by_topic and recent_scores: + curriculum = get_adaptive_curriculum() + topic_name = next(iter(by_topic)) + insights = curriculum.generate_progress_insights( + learner_name=self.request.user.username, + topic_name=topic_name, + progress_data=[ + { + "lesson": p.lesson.title, + "score": p.score, + "completed": p.completed, + "attempts": p.attempts, + } + for p in progress_qs[:10] + ], + ) + else: + insights = "" + except CloudflareAIError: + insights = "" + + ctx.update( + { + "profile": profile, + "progress_by_topic": by_topic, + "recommendations": recommendations, + "insights": insights, + } + ) + return ctx diff --git a/learnpilot/__init__.py b/learnpilot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/learnpilot/asgi.py b/learnpilot/asgi.py new file mode 100644 index 0000000..2001b86 --- /dev/null +++ b/learnpilot/asgi.py @@ -0,0 +1,9 @@ +"""ASGI config for learnpilot project.""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "learnpilot.settings") + +application = get_asgi_application() diff --git a/learnpilot/settings.py b/learnpilot/settings.py new file mode 100644 index 0000000..f75e7f9 --- /dev/null +++ b/learnpilot/settings.py @@ -0,0 +1,117 @@ +""" +Django settings for learnpilot project. +""" + +import os +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get( + "SECRET_KEY", + "django-insecure-dev-key-change-in-production-xk2#p$8!w@n0&m7v", +) + +DEBUG = os.environ.get("DEBUG", "True") == "True" + +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "learning", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "learnpilot.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "learnpilot.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATICFILES_DIRS = [BASE_DIR / "static"] +STATIC_ROOT = BASE_DIR / "staticfiles" +# Use manifest storage in production; simple storage in DEBUG/test mode +STATICFILES_STORAGE = ( + "django.contrib.staticfiles.storage.StaticFilesStorage" + if DEBUG + else "whitenoise.storage.CompressedManifestStaticFilesStorage" +) + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGIN_URL = "/accounts/login/" +LOGIN_REDIRECT_URL = "/dashboard/" +LOGOUT_REDIRECT_URL = "/" + +# Cloudflare AI configuration +CLOUDFLARE_ACCOUNT_ID = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "") +CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "") +CLOUDFLARE_WORKER_URL = os.environ.get("CLOUDFLARE_WORKER_URL", "") + +# Default AI model for tutoring +CLOUDFLARE_AI_MODEL = os.environ.get( + "CLOUDFLARE_AI_MODEL", "@cf/meta/llama-3.1-8b-instruct" +) + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} diff --git a/learnpilot/urls.py b/learnpilot/urls.py new file mode 100644 index 0000000..43cf00a --- /dev/null +++ b/learnpilot/urls.py @@ -0,0 +1,13 @@ +"""URL configuration for learnpilot project.""" + +from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("accounts/login/", auth_views.LoginView.as_view(template_name="registration/login.html"), name="login"), + path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), + path("accounts/register/", include("learning.auth_urls")), + path("", include("learning.urls")), +] diff --git a/learnpilot/wsgi.py b/learnpilot/wsgi.py new file mode 100644 index 0000000..77e1373 --- /dev/null +++ b/learnpilot/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI config for learnpilot project.""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "learnpilot.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..9b51450 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "learnpilot.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..01f2e4d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Django==4.2.20 +djangorestframework==3.15.2 +python-dotenv==1.0.1 +requests==2.32.3 +Pillow==10.4.0 +whitenoise==6.8.2 diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..52c764a --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,23 @@ +/* LearnPilot – Main stylesheet */ + +/* Typing animation for AI tutor indicator */ +.typing-dots span { + animation: blink 1.4s infinite; + animation-fill-mode: both; +} +.typing-dots span:nth-child(2) { animation-delay: 0.2s; } +.typing-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes blink { + 0% { opacity: 0.2; } + 20% { opacity: 1; } + 100% { opacity: 0.2; } +} + +/* Prose-like line-clamp support (Tailwind v3 doesn't include it by default) */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/static/js/tutor.js b/static/js/tutor.js new file mode 100644 index 0000000..696935d --- /dev/null +++ b/static/js/tutor.js @@ -0,0 +1,234 @@ +/** + * LearnPilot – Tutor session JavaScript + * + * Handles: + * - Real-time AI tutoring chat + * - AI practice question generation + * - Answer evaluation with feedback + */ + +(function () { + "use strict"; + + /* ------------------------------------------------------------------ */ + /* Utilities */ + /* ------------------------------------------------------------------ */ + + function scrollToBottom() { + const el = document.getElementById("chat-messages"); + if (el) el.scrollTop = el.scrollHeight; + } + + function setTyping(visible) { + const indicator = document.getElementById("typing-indicator"); + if (indicator) { + indicator.classList.toggle("hidden", !visible); + scrollToBottom(); + } + } + + function appendMessage(role, content) { + const container = document.getElementById("chat-messages"); + if (!container) return; + + const wrapper = document.createElement("div"); + wrapper.className = `flex ${role === "user" ? "justify-end" : "justify-start"}`; + + const bubble = document.createElement("div"); + bubble.className = [ + "max-w-[80%] px-4 py-3 rounded-2xl text-sm leading-relaxed", + role === "user" + ? "bg-indigo-600 text-white rounded-br-sm" + : "bg-gray-100 text-gray-800 rounded-bl-sm", + ].join(" "); + + if (role === "assistant") { + const label = document.createElement("div"); + label.className = "text-xs font-semibold text-indigo-500 mb-1"; + label.textContent = "πŸ€– AI Tutor"; + bubble.appendChild(label); + } + + // Render newlines as
+ const text = document.createElement("span"); + text.innerHTML = content.replace(/\n/g, "
"); + bubble.appendChild(text); + wrapper.appendChild(bubble); + + // Insert before typing indicator + const typingIndicator = document.getElementById("typing-indicator"); + container.insertBefore(wrapper, typingIndicator); + scrollToBottom(); + } + + /* ------------------------------------------------------------------ */ + /* Chat */ + /* ------------------------------------------------------------------ */ + + async function sendChatMessage(message) { + const input = document.getElementById("chat-input"); + const form = document.getElementById("chat-form"); + if (!input || !form) return; + + // Disable input while waiting + input.disabled = true; + form.querySelector("button[type=submit]").disabled = true; + + appendMessage("user", message); + setTyping(true); + + try { + const response = await fetch(CHAT_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": CSRF_TOKEN, + }, + body: JSON.stringify({ message }), + }); + + const data = await response.json(); + + if (!response.ok) { + appendMessage("assistant", `⚠️ Error: ${data.error || "Something went wrong."}`); + } else { + appendMessage("assistant", data.response); + } + } catch (err) { + appendMessage("assistant", "⚠️ Network error. Please check your connection and try again."); + } finally { + setTyping(false); + input.disabled = false; + form.querySelector("button[type=submit]").disabled = false; + input.focus(); + } + } + + function initChat() { + const form = document.getElementById("chat-form"); + const input = document.getElementById("chat-input"); + if (!form || !input) return; + + scrollToBottom(); + + form.addEventListener("submit", (e) => { + e.preventDefault(); + const message = input.value.trim(); + if (!message) return; + input.value = ""; + sendChatMessage(message); + }); + } + + /* ------------------------------------------------------------------ */ + /* Practice questions */ + /* ------------------------------------------------------------------ */ + + async function loadPracticeQuestion() { + const btn = document.getElementById("get-question-btn"); + const area = document.getElementById("practice-area"); + const questionText = document.getElementById("question-text"); + const feedbackArea = document.getElementById("feedback-area"); + const answerInput = document.getElementById("answer-input"); + + if (!btn || !area || !questionText) return; + + btn.disabled = true; + btn.textContent = "Generating…"; + + try { + const response = await fetch(PRACTICE_API_URL); + const data = await response.json(); + + if (response.ok && data.question) { + questionText.textContent = data.question; + answerInput.value = ""; + feedbackArea.classList.add("hidden"); + feedbackArea.textContent = ""; + area.classList.remove("hidden"); + btn.textContent = "New Question"; + } else { + btn.textContent = "Try Again"; + } + } catch { + btn.textContent = "Try Again"; + } finally { + btn.disabled = false; + } + } + + async function evaluateAnswer() { + const submitBtn = document.getElementById("submit-answer-btn"); + const answerInput = document.getElementById("answer-input"); + const questionText = document.getElementById("question-text"); + const feedbackArea = document.getElementById("feedback-area"); + + if (!submitBtn || !answerInput || !questionText || !feedbackArea) return; + + const answer = answerInput.value.trim(); + if (!answer) { + answerInput.focus(); + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Evaluating…"; + + try { + const response = await fetch(EVALUATE_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": CSRF_TOKEN, + }, + body: JSON.stringify({ + question: questionText.textContent, + answer, + lesson_id: LESSON_ID, + }), + }); + const data = await response.json(); + + feedbackArea.classList.remove("hidden", "bg-green-50", "bg-red-50", "border-green-200", "border-red-200"); + + const score = parseFloat(data.score) || 0; + if (score >= 0.7) { + feedbackArea.className = + "mt-3 p-4 rounded-xl text-sm bg-green-50 border border-green-200 text-green-800"; + } else { + feedbackArea.className = + "mt-3 p-4 rounded-xl text-sm bg-red-50 border border-red-200 text-red-800"; + } + + let html = `Score: ${Math.round(score * 100)}%
`; + html += `${data.feedback || ""}`; + if (data.correct_answer) { + html += `
Reference: ${data.correct_answer}`; + } + feedbackArea.innerHTML = html; + } catch { + feedbackArea.className = "mt-3 p-4 rounded-xl text-sm bg-gray-50 text-gray-600"; + feedbackArea.textContent = "Could not evaluate answer. Please try again."; + feedbackArea.classList.remove("hidden"); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = "Submit Answer"; + } + } + + function initPractice() { + const btn = document.getElementById("get-question-btn"); + const submitBtn = document.getElementById("submit-answer-btn"); + if (btn) btn.addEventListener("click", loadPracticeQuestion); + if (submitBtn) submitBtn.addEventListener("click", evaluateAnswer); + } + + /* ------------------------------------------------------------------ */ + /* Bootstrap */ + /* ------------------------------------------------------------------ */ + + document.addEventListener("DOMContentLoaded", () => { + initChat(); + initPractice(); + }); +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..41be822 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,70 @@ + + + + + + {% block title %}LearnPilot{% endblock %} – AI-Powered Learning Lab + + + {% load static %} + + + + + +
+ + +{% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+{% endif %} + + +
+ {% block content %}{% endblock %} +
+ + + + +{% block extra_js %}{% endblock %} + + diff --git a/templates/learning/adaptive_path.html b/templates/learning/adaptive_path.html new file mode 100644 index 0000000..a2c10e3 --- /dev/null +++ b/templates/learning/adaptive_path.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}AI Learning Path – {{ path.topic.name }}{% endblock %} + +{% block content %} + + +
+
+

πŸ—ΊοΈ {{ path.topic.name }} – Your Personalised Path

+

Generated {{ path.created_at|date:"M j, Y" }} by Cloudflare Workers AI

+
+ {% if path.rationale %} +
+

{{ path.rationale }}

+
+ {% endif %} +
+ +
+ {% for item in path_lessons %} + {% with pl=item.pl prog=item.progress %} +
+
+
+ {% if prog and prog.completed %}βœ“{% else %}{{ pl.order|add:1 }}{% endif %} +
+
+
{{ pl.lesson.title }}
+
{{ pl.lesson.course.title }}
+
+
+ + {% if prog and prog.completed %}Review{% else %}Start{% endif %} + +
+ {% endwith %} + {% empty %} +

No lessons in this path yet.

+ {% endfor %} +
+{% endblock %} diff --git a/templates/learning/course_detail.html b/templates/learning/course_detail.html new file mode 100644 index 0000000..6ba25d3 --- /dev/null +++ b/templates/learning/course_detail.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}{{ course.title }}{% endblock %} + +{% block content %} + + +
+
+ {{ course.topic.icon }} +
+

{{ course.title }}

+

{{ course.topic.name }} • {{ course.difficulty|capfirst }} • ⏱ {{ course.estimated_hours }}h

+
+
+ +
+ + +

Lessons ({{ course.total_lessons }})

+
+ {% for item in lesson_data %} + {% with lesson=item.lesson prog=item.progress %} +
+
+
+ {% if prog and prog.completed %}βœ“{% else %}{{ lesson.order }}{% endif %} +
+
+
{{ lesson.title }}
+
+ {{ lesson.lesson_type }} + ⭐ {{ lesson.xp_reward }} XP + {% if prog %} + {{ prog.attempts }} attempt{{ prog.attempts|pluralize }} + {% endif %} +
+
+
+
+ {% if prog and prog.completed %} + + {{ prog.score|floatformat:0 }}% + + {% endif %} + + {% if prog and prog.completed %}Review{% else %}Start{% endif %} + +
+
+ {% endwith %} + {% empty %} +

No lessons in this course yet.

+ {% endfor %} +
+{% endblock %} diff --git a/templates/learning/course_list.html b/templates/learning/course_list.html new file mode 100644 index 0000000..9b61403 --- /dev/null +++ b/templates/learning/course_list.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Courses{% endblock %} + +{% block content %} +
+

Courses

+
+ + +
+ + +
+ + +
+ {% for course in courses %} + +
+ {{ course.topic.icon }} +
+
{{ course.title }}
+
{{ course.topic.name }}
+
+
+
+

{{ course.description }}

+
+ {{ course.difficulty }} + ⏱ {{ course.estimated_hours }}h • {{ course.total_lessons }} lessons +
+
+
+ {% empty %} +
+
πŸ“­
+

No courses found. Try different filters or run python manage.py seed_data.

+
+ {% endfor %} +
+{% endblock %} diff --git a/templates/learning/dashboard.html b/templates/learning/dashboard.html new file mode 100644 index 0000000..1ba05b4 --- /dev/null +++ b/templates/learning/dashboard.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} + +{% block content %} +
+
+

Welcome back, {{ request.user.username }}! πŸ‘‹

+

+ Level: {{ profile.skill_level|capfirst }} • + πŸ”₯ {{ profile.streak_days }}-day streak • + ⭐ {{ profile.total_xp }} XP +

+
+ + Browse Courses + +
+ +
+ + +
+
+

Your Stats

+
+
+
{{ completed_lessons }}
+
Lessons Completed
+
+
+
{{ profile.total_xp }}
+
Total XP
+
+
+
{{ profile.streak_days }}
+
Day Streak πŸ”₯
+
+
+
{{ active_sessions.count }}
+
Active Sessions
+
+
+
+ + + {% if active_sessions %} +
+

Resume Learning

+ {% for session in active_sessions %} + + ▢️ +
+
{{ session.lesson.title }}
+
{{ session.lesson.course.title }}
+
+
+ {% endfor %} +
+ {% endif %} +
+ + +
+ + + {% if recent_progress %} +
+

Recent Progress

+
    + {% for p in recent_progress %} +
  • +
    +
    {{ p.lesson.title }}
    +
    {{ p.lesson.course.topic.name }} • {{ p.lesson.course.title }}
    +
    +
    +
    +
    +
    + {{ p.score|floatformat:0 }}% +
    +
  • + {% endfor %} +
+ View full progress β†’ +
+ {% endif %} + + +
+

Explore Topics

+
+ {% for topic in topics %} + + {{ topic.icon }} + {{ topic.name }} + {{ topic.difficulty }} + + {% empty %} +

No topics yet. Run python manage.py seed_data.

+ {% endfor %} +
+
+ + + {% if adaptive_paths %} + + {% endif %} + +
+
+{% endblock %} diff --git a/templates/learning/generate_path.html b/templates/learning/generate_path.html new file mode 100644 index 0000000..1f24ea0 --- /dev/null +++ b/templates/learning/generate_path.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Generate AI Learning Path{% endblock %} + +{% block content %} + + +
+
+
+ {{ topic.icon }} +

Generate AI Learning Path

+

+ Topic: {{ topic.name }} • + Your level: {{ profile.skill_level|capfirst }} +

+
+ +
+ {% csrf_token %} +
+ + +
+ +
+ +

+ Powered by Cloudflare Workers AI – path generation may take a few seconds. +

+
+
+{% endblock %} diff --git a/templates/learning/home.html b/templates/learning/home.html new file mode 100644 index 0000000..c755c9e --- /dev/null +++ b/templates/learning/home.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Welcome{% endblock %} + +{% block content %} + +
+ πŸš€ +

LearnPilot

+

+ An AI-powered personalised learning lab that adapts to you in real time. + Powered by Cloudflare Workers AI. +

+ +
+ + +
+
+
🧠
+

Adaptive Curricula

+

AI generates a personalised learning path based on your skill level and goals.

+
+
+
πŸ’¬
+

Intelligent Tutoring

+

Chat with your AI tutor 24/7 for instant explanations, hints, and feedback.

+
+
+
πŸ“ˆ
+

Progress Tracking

+

Monitor your XP, streaks, and completion rates across every topic.

+
+
+
✏️
+

Guided Practice

+

AI-generated practice questions with instant evaluation and personalised feedback.

+
+
+{% endblock %} diff --git a/templates/learning/progress.html b/templates/learning/progress.html new file mode 100644 index 0000000..78f0a3e --- /dev/null +++ b/templates/learning/progress.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}My Progress{% endblock %} + +{% block content %} +
+
+

My Progress

+

⭐ {{ profile.total_xp }} XP • πŸ”₯ {{ profile.streak_days }}-day streak

+
+
+ + +{% if insights %} +
+

πŸ€– AI Progress Insights

+

{{ insights }}

+
+{% endif %} + + +{% if progress_by_topic %} +{% for topic_name, records in progress_by_topic.items %} +
+
+

{{ topic_name }}

+
+
    + {% for p in records %} +
  • +
    +
    {{ p.lesson.title }}
    +
    {{ p.lesson.course.title }} • {{ p.attempts }} attempt{{ p.attempts|pluralize }}
    +
    +
    +
    + {% if p.completed %}βœ“ Completed{% else %}In progress{% endif %} +
    +
    +
    +
    + {{ p.score|floatformat:0 }}% + + Review + +
    +
  • + {% endfor %} +
+
+{% endfor %} +{% else %} +
+
πŸ“Š
+

No progress yet. Start a course to track your learning.

+
+{% endif %} + + +{% if recommendations %} +
+

Recommended Next Lessons

+
+ {% for rec in recommendations %} +
+
+ {{ rec.topic.icon }} {{ rec.topic.name }} +
+ {% for lesson in rec.lessons %} + + β†’ {{ lesson.title }} + + {% endfor %} +
+ {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/templates/learning/session.html b/templates/learning/session.html new file mode 100644 index 0000000..04fd210 --- /dev/null +++ b/templates/learning/session.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} +{% block title %}{{ session.lesson.title }} – Tutor Session{% endblock %} + +{% block content %} +
+
+ ← {{ session.lesson.course.title }} +

{{ session.lesson.title }}

+

{{ session.lesson.course.topic.name }} • {{ session.lesson.lesson_type|capfirst }}

+
+ + End Session + +
+ +
+ + +
+
+ +
+ {% for msg in chat_history %} +
+
+ {% if msg.role == 'assistant' %} +
πŸ€– AI Tutor
+ {% endif %} + {{ msg.content|linebreaksbr }} +
+
+ {% endfor %} + + +
+ + +
+
+ {% csrf_token %} + + +
+

Powered by Cloudflare Workers AI

+
+
+ + +
+
+

Practice Question

+ +
+ +
+
+ + +
+ +
+

πŸ“– Lesson Overview

+

{{ session.lesson.content }}

+
+ + +
+

πŸ“Š Your Progress

+
+
+ Score + {{ progress.score|floatformat:0 }}% +
+
+
+
+
+ Attempts + {{ progress.attempts }} +
+ {% if progress.completed %} +
βœ“ Completed!
+ {% endif %} +
+
+
+
+{% endblock %} + +{% block extra_js %} +{% load static %} + + +{% endblock %} diff --git a/templates/learning/session_end.html b/templates/learning/session_end.html new file mode 100644 index 0000000..773d1b2 --- /dev/null +++ b/templates/learning/session_end.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Session Complete{% endblock %} + +{% block content %} +
+
+
+
πŸŽ‰
+

Session Complete!

+

{{ session.lesson.title }}

+
+ +
+ +
+
+
{{ progress.score|floatformat:0 }}%
+
Score
+
+
+
+{{ session.lesson.xp_reward }}
+
XP Earned
+
+
+
{{ progress.attempts }}
+
Attempts
+
+
+ + + {% if summary %} +
+

πŸ€– AI Session Summary

+

{{ summary }}

+
+ {% endif %} + + + +
+
+
+{% endblock %} diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..dc4d86f --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,45 @@ +{% load static %} + + + + + Sign In – LearnPilot + + + +
+
+ πŸš€ LearnPilot +

Sign in to continue learning

+
+ + {% if form.errors %} +
+ Invalid username or password. +
+ {% endif %} + +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+ +

+ No account? Register here +

+
+ + diff --git a/templates/registration/register.html b/templates/registration/register.html new file mode 100644 index 0000000..03d2b4c --- /dev/null +++ b/templates/registration/register.html @@ -0,0 +1,46 @@ +{% load static %} + + + + + Register – LearnPilot + + + +
+
+ πŸš€ LearnPilot +

Create your free account

+
+ +
+ {% csrf_token %} + {% for field in form %} +
+ + + {% if field.errors %} +

{{ field.errors|join:", " }}

+ {% endif %} + {% if field.help_text %} +

{{ field.help_text }}

+ {% endif %} +
+ {% endfor %} + + +
+ +

+ Already have an account? Sign in +

+
+ + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_adaptive.py b/tests/test_adaptive.py new file mode 100644 index 0000000..0894a43 --- /dev/null +++ b/tests/test_adaptive.py @@ -0,0 +1,84 @@ +""" +Tests for learning.ai.adaptive.AdaptiveCurriculum +""" + +from unittest.mock import MagicMock + +from django.test import TestCase + +from learning.ai.adaptive import AdaptiveCurriculum, _parse_json_response + + +class ParseJsonResponseTest(TestCase): + def test_extracts_json_from_prose(self): + raw = 'Here is the result: {"key": "value", "num": 42} – end.' + result = _parse_json_response(raw, default={}) + self.assertEqual(result, {"key": "value", "num": 42}) + + def test_returns_default_on_missing_json(self): + result = _parse_json_response("No JSON here", default={"fallback": True}) + self.assertEqual(result, {"fallback": True}) + + def test_returns_default_on_malformed_json(self): + result = _parse_json_response("{bad json}", default={"fallback": True}) + self.assertEqual(result, {"fallback": True}) + + +class AdaptiveCurriculumTest(TestCase): + def _make_curriculum(self, ai_json_response: str): + mock_client = MagicMock() + mock_client.chat.return_value = ai_json_response + return AdaptiveCurriculum(ai_client=mock_client) + + def test_generate_learning_path_returns_dict(self): + ai_resp = '{"ordered_lesson_ids": [1, 2, 3], "rationale": "Start with basics."}' + curriculum = self._make_curriculum(ai_resp) + result = curriculum.generate_learning_path( + topic_name="Python", + skill_level="beginner", + learning_style="visual", + available_lessons=[ + {"id": 1, "title": "Variables", "type": "theory", "difficulty": "beginner"}, + {"id": 2, "title": "Loops", "type": "practice", "difficulty": "beginner"}, + {"id": 3, "title": "Functions", "type": "theory", "difficulty": "beginner"}, + ], + ) + self.assertEqual(result["ordered_lesson_ids"], [1, 2, 3]) + self.assertEqual(result["rationale"], "Start with basics.") + + def test_generate_learning_path_falls_back_on_bad_ai_response(self): + curriculum = self._make_curriculum("Sorry, I cannot generate a path.") + result = curriculum.generate_learning_path("Python", "beginner", "visual", []) + self.assertIn("ordered_lesson_ids", result) + self.assertEqual(result["ordered_lesson_ids"], []) + + def test_recommend_next_lesson(self): + ai_resp = '{"lesson_id": 5, "reason": "Next logical step."}' + curriculum = self._make_curriculum(ai_resp) + result = curriculum.recommend_next_lesson( + topic_name="Python", + completed_lessons=[{"title": "Variables"}], + available_lessons=[{"id": 5, "title": "Loops", "type": "practice", "difficulty": "beginner"}], + recent_scores=[0.8, 0.9], + ) + self.assertEqual(result["lesson_id"], 5) + + def test_adapt_difficulty_returns_action(self): + ai_resp = '{"new_difficulty": "intermediate", "action": "increase", "reasoning": "Scores are high."}' + curriculum = self._make_curriculum(ai_resp) + result = curriculum.adapt_difficulty( + topic_name="Python", + current_difficulty="beginner", + recent_scores=[0.9, 0.95, 0.88], + ) + self.assertEqual(result["action"], "increase") + self.assertEqual(result["new_difficulty"], "intermediate") + + def test_generate_progress_insights_returns_string(self): + curriculum = self._make_curriculum("You're doing great! Keep practising loops.") + result = curriculum.generate_progress_insights( + learner_name="Alice", + topic_name="Python", + progress_data=[{"lesson": "Variables", "score": 0.9, "completed": True, "attempts": 1}], + ) + self.assertEqual(result, "You're doing great! Keep practising loops.") diff --git a/tests/test_cloudflare_ai.py b/tests/test_cloudflare_ai.py new file mode 100644 index 0000000..dc8ac0d --- /dev/null +++ b/tests/test_cloudflare_ai.py @@ -0,0 +1,106 @@ +""" +Tests for learning.ai.cloudflare_ai.CloudflareAIClient +""" + +import json +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings + +from learning.ai.cloudflare_ai import CloudflareAIClient, CloudflareAIError + + +@override_settings( + CLOUDFLARE_ACCOUNT_ID="test-account-id", + CLOUDFLARE_API_TOKEN="test-api-token", + CLOUDFLARE_WORKER_URL="", + CLOUDFLARE_AI_MODEL="@cf/meta/llama-3.1-8b-instruct", +) +class CloudflareAIClientDirectAPITest(TestCase): + """Tests for direct Cloudflare REST API calls.""" + + def _make_client(self): + return CloudflareAIClient( + account_id="test-account-id", + api_token="test-api-token", + worker_url="", + ) + + @patch("learning.ai.cloudflare_ai.requests.Session.post") + def test_chat_returns_response_text(self, mock_post): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "success": True, + "result": {"response": "Recursion is when a function calls itself."}, + } + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + client = self._make_client() + result = client.chat(messages=[{"role": "user", "content": "Explain recursion"}]) + + self.assertEqual(result, "Recursion is when a function calls itself.") + mock_post.assert_called_once() + + @patch("learning.ai.cloudflare_ai.requests.Session.post") + def test_chat_raises_on_api_error(self, mock_post): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "success": False, + "errors": [{"message": "Unauthorized"}], + } + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + client = self._make_client() + with self.assertRaises(CloudflareAIError): + client.chat(messages=[{"role": "user", "content": "Hello"}]) + + @patch("learning.ai.cloudflare_ai.requests.Session.post") + def test_complete_wraps_prompt_as_user_message(self, mock_post): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"success": True, "result": {"response": "42"}} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + client = self._make_client() + result = client.complete("What is 6Γ—7?") + + self.assertEqual(result, "42") + # Verify the payload was sent with messages + sent_json = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json", {}) + self.assertIn("messages", sent_json) + self.assertEqual(sent_json["messages"][0]["role"], "user") + + def test_raises_without_credentials(self): + client = CloudflareAIClient(account_id="", api_token="", worker_url="") + with self.assertRaises(CloudflareAIError): + client.run_model("@cf/meta/llama-3.1-8b-instruct", {"messages": []}) + + +@override_settings( + CLOUDFLARE_ACCOUNT_ID="test-account-id", + CLOUDFLARE_API_TOKEN="test-api-token", + CLOUDFLARE_WORKER_URL="https://test-worker.example.com", + CLOUDFLARE_AI_MODEL="@cf/meta/llama-3.1-8b-instruct", +) +class CloudflareAIClientWorkerTest(TestCase): + """Tests for routing through Cloudflare Python Worker.""" + + @patch("learning.ai.cloudflare_ai.requests.Session.post") + def test_routes_via_worker_url(self, mock_post): + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"response": "Hello from worker"} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + client = CloudflareAIClient(worker_url="https://test-worker.example.com") + result = client.chat(messages=[{"role": "user", "content": "Hi"}]) + + self.assertEqual(result, "Hello from worker") + called_url = mock_post.call_args[0][0] + self.assertIn("test-worker.example.com", called_url) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..199b0df --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,157 @@ +""" +Tests for learning models +""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone + +from learning.models import ( + Course, + Lesson, + LearnerProfile, + LearningSession, + Message, + Progress, + Topic, +) + + +class TopicModelTest(TestCase): + def test_str(self): + topic = Topic(name="Python", description="", difficulty="beginner", icon="🐍") + self.assertEqual(str(topic), "Python") + + +class CourseModelTest(TestCase): + def setUp(self): + self.topic = Topic.objects.create( + name="Python", description="Learn Python", difficulty="beginner", icon="🐍" + ) + self.course = Course.objects.create( + title="Python Fundamentals", + description="Core Python", + topic=self.topic, + difficulty="beginner", + estimated_hours=4.0, + ) + + def test_str(self): + self.assertIn("Python Fundamentals", str(self.course)) + + def test_total_lessons(self): + self.assertEqual(self.course.total_lessons(), 0) + Lesson.objects.create( + course=self.course, title="Variables", content="Variables", lesson_type="theory", order=1 + ) + self.assertEqual(self.course.total_lessons(), 1) + + +class LearnerProfileTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="testuser", password="pass") + self.profile = LearnerProfile.objects.create(user=self.user) + + def test_str(self): + self.assertIn("testuser", str(self.profile)) + + def test_update_activity_initialises_streak(self): + self.profile.streak_days = 0 + self.profile.last_active = None + self.profile.save() + self.profile.update_activity() + self.assertEqual(self.profile.streak_days, 1) + + def test_update_activity_increments_streak(self): + yesterday = timezone.now() - timezone.timedelta(days=1) + self.profile.last_active = yesterday + self.profile.streak_days = 3 + self.profile.save() + self.profile.update_activity() + self.assertEqual(self.profile.streak_days, 4) + + def test_update_activity_resets_streak_on_gap(self): + old_date = timezone.now() - timezone.timedelta(days=3) + self.profile.last_active = old_date + self.profile.streak_days = 10 + self.profile.save() + self.profile.update_activity() + self.assertEqual(self.profile.streak_days, 1) + + +class ProgressModelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="learner", password="pass") + self.profile = LearnerProfile.objects.create(user=self.user) + self.topic = Topic.objects.create( + name="Python", description="", difficulty="beginner", icon="🐍" + ) + self.course = Course.objects.create( + title="Basics", description="", topic=self.topic, difficulty="beginner" + ) + self.lesson = Lesson.objects.create( + course=self.course, + title="Variables", + content="Vars", + lesson_type="theory", + order=1, + xp_reward=20, + ) + + def test_mark_complete_awards_xp(self): + prog = Progress.objects.create(learner=self.profile, lesson=self.lesson) + self.profile.total_xp = 0 + self.profile.save() + + prog.mark_complete(score=0.9) + + prog.refresh_from_db() + self.assertTrue(prog.completed) + self.assertAlmostEqual(prog.score, 0.9) + self.assertIsNotNone(prog.completed_at) + + self.profile.refresh_from_db() + self.assertEqual(self.profile.total_xp, 20) + + def test_mark_complete_clamps_score(self): + prog = Progress.objects.create(learner=self.profile, lesson=self.lesson) + prog.mark_complete(score=1.5) + prog.refresh_from_db() + self.assertEqual(prog.score, 1.0) + + +class LearningSessionTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="sess_user", password="pass") + self.profile = LearnerProfile.objects.create(user=self.user) + self.topic = Topic.objects.create( + name="Web", description="", difficulty="intermediate", icon="🌐" + ) + self.course = Course.objects.create( + title="HTML", description="", topic=self.topic, difficulty="beginner" + ) + self.lesson = Lesson.objects.create( + course=self.course, + title="Intro", + content="HTML intro", + lesson_type="theory", + order=1, + ) + + def test_end_session(self): + session = LearningSession.objects.create(learner=self.profile, lesson=self.lesson) + self.assertTrue(session.is_active) + session.end_session() + session.refresh_from_db() + self.assertFalse(session.is_active) + self.assertIsNotNone(session.ended_at) + + def test_duration_seconds_when_active(self): + session = LearningSession.objects.create(learner=self.profile, lesson=self.lesson) + duration = session.duration_seconds() + self.assertGreaterEqual(duration, 0) + + def test_message_creation(self): + session = LearningSession.objects.create(learner=self.profile, lesson=self.lesson) + msg = Message.objects.create(session=session, role="user", content="Hello") + self.assertIn("Hello", str(msg)) diff --git a/tests/test_tutor.py b/tests/test_tutor.py new file mode 100644 index 0000000..51f9b43 --- /dev/null +++ b/tests/test_tutor.py @@ -0,0 +1,92 @@ +""" +Tests for learning.ai.tutor.IntelligentTutor +""" + +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from learning.ai.tutor import IntelligentTutor, _parse_evaluation + + +class ParseEvaluationTest(TestCase): + """Unit tests for the evaluation response parser.""" + + def test_parses_well_formed_response(self): + raw = ( + "SCORE: 0.85\n" + "FEEDBACK: Great answer! You correctly identified the key concept.\n" + "CORRECT_ANSWER: A function that calls itself with a base case." + ) + result = _parse_evaluation(raw) + self.assertAlmostEqual(result["score"], 0.85) + self.assertIn("Great answer", result["feedback"]) + self.assertIn("base case", result["correct_answer"]) + + def test_falls_back_on_malformed_score(self): + raw = "SCORE: not-a-number\nFEEDBACK: OK" + result = _parse_evaluation(raw) + # Default score preserved + self.assertEqual(result["score"], 0.5) + self.assertEqual(result["feedback"], "OK") + + def test_returns_defaults_for_empty_response(self): + result = _parse_evaluation("") + self.assertEqual(result["score"], 0.5) + self.assertEqual(result["correct_answer"], "") + + +class IntelligentTutorTest(TestCase): + """Integration-level tests using a mock AI client.""" + + def _make_tutor(self, ai_response="Mock AI response"): + mock_client = MagicMock() + mock_client.chat.return_value = ai_response + return IntelligentTutor(ai_client=mock_client) + + def test_explain_concept_calls_chat(self): + tutor = self._make_tutor("Here is the explanation.") + result = tutor.explain_concept("recursion", skill_level="beginner") + self.assertEqual(result, "Here is the explanation.") + tutor.ai.chat.assert_called_once() + + def test_explain_concept_includes_skill_level_in_prompt(self): + tutor = self._make_tutor() + tutor.explain_concept("sorting", skill_level="advanced", learning_style="kinesthetic") + call_args = tutor.ai.chat.call_args + messages = call_args.kwargs.get("messages") or call_args[1].get("messages", []) + full_text = " ".join(m["content"] for m in messages) + self.assertIn("advanced", full_text) + self.assertIn("sorting", full_text) + + def test_generate_practice_question(self): + tutor = self._make_tutor("**Question:** What is a loop?") + result = tutor.generate_practice_question("Python loops", difficulty="beginner") + self.assertIn("Question", result) + + def test_evaluate_answer_returns_dict(self): + raw = "SCORE: 0.9\nFEEDBACK: Excellent!\nCORRECT_ANSWER: Correct." + tutor = self._make_tutor(raw) + result = tutor.evaluate_answer("What is a variable?", "A named storage location") + self.assertIsInstance(result, dict) + self.assertIn("score", result) + self.assertIn("feedback", result) + + def test_continue_conversation_passes_history(self): + tutor = self._make_tutor("Follow-up response") + history = [ + {"role": "user", "content": "Explain lists"}, + {"role": "assistant", "content": "Lists are ordered collections."}, + ] + result = tutor.continue_conversation(history, "Give me an example") + self.assertEqual(result, "Follow-up response") + tutor.ai.chat.assert_called_once() + + def test_generate_session_summary(self): + tutor = self._make_tutor("Session covered recursion and loops.") + conversation = [ + {"role": "user", "content": "What is recursion?"}, + {"role": "assistant", "content": "Recursion is self-referential."}, + ] + result = tutor.generate_session_summary(conversation, "Python Loops") + self.assertEqual(result, "Session covered recursion and loops.") diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..8b46054 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,220 @@ +""" +Tests for learning views (HTTP-level) +""" + +import json +from unittest.mock import MagicMock, patch + +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse + +from learning.models import ( + Course, + Lesson, + LearnerProfile, + LearningSession, + Message, + Progress, + Topic, +) + + +class BaseViewTest(TestCase): + """Common fixtures for view tests.""" + + def setUp(self): + self.client = Client() + self.user = User.objects.create_user(username="viewer", password="viewpass") + self.profile = LearnerProfile.objects.create(user=self.user) + self.topic = Topic.objects.create( + name="Python", description="Learn Python", difficulty="beginner", icon="🐍" + ) + self.course = Course.objects.create( + title="Python 101", + description="Basics", + topic=self.topic, + difficulty="beginner", + ) + self.lesson = Lesson.objects.create( + course=self.course, + title="Variables", + content="A variable stores a value.", + lesson_type="theory", + order=1, + xp_reward=10, + ) + + def login(self): + self.client.login(username="viewer", password="viewpass") + + +class HomeViewTest(BaseViewTest): + def test_home_redirects_authenticated_user(self): + self.login() + resp = self.client.get(reverse("home")) + self.assertRedirects(resp, reverse("dashboard")) + + def test_home_renders_for_anonymous(self): + resp = self.client.get(reverse("home")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "LearnPilot") + + +class DashboardViewTest(BaseViewTest): + def test_dashboard_requires_login(self): + resp = self.client.get(reverse("dashboard")) + self.assertEqual(resp.status_code, 302) + + def test_dashboard_renders_for_logged_in_user(self): + self.login() + resp = self.client.get(reverse("dashboard")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "viewer") + + +class CourseListViewTest(BaseViewTest): + def test_course_list_renders(self): + self.login() + resp = self.client.get(reverse("course_list")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Python 101") + + def test_course_list_filters_by_topic(self): + self.login() + resp = self.client.get(reverse("course_list") + f"?topic={self.topic.pk}") + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Python 101") + + +class CourseDetailViewTest(BaseViewTest): + def test_course_detail_renders(self): + self.login() + resp = self.client.get(reverse("course_detail", args=[self.course.pk])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Variables") + + +class StartSessionViewTest(BaseViewTest): + @patch("learning.views.get_tutor") + def test_start_session_creates_session(self, mock_get_tutor): + mock_tutor = MagicMock() + mock_tutor.explain_concept.return_value = "Welcome to the lesson!" + mock_get_tutor.return_value = mock_tutor + + self.login() + resp = self.client.get(reverse("start_session", args=[self.lesson.pk])) + + # Should redirect to session + self.assertEqual(resp.status_code, 302) + session = LearningSession.objects.filter(learner=self.profile, lesson=self.lesson).first() + self.assertIsNotNone(session) + + @patch("learning.views.get_tutor") + def test_start_session_reuses_active_session(self, mock_get_tutor): + mock_tutor = MagicMock() + mock_tutor.explain_concept.return_value = "Welcome to the lesson!" + mock_get_tutor.return_value = mock_tutor + + self.login() + # First request – creates a session + self.client.get(reverse("start_session", args=[self.lesson.pk])) + count_after_first = LearningSession.objects.filter(learner=self.profile, lesson=self.lesson).count() + + # Second request – should reuse + self.client.get(reverse("start_session", args=[self.lesson.pk])) + count_after_second = LearningSession.objects.filter(learner=self.profile, lesson=self.lesson).count() + + self.assertEqual(count_after_first, count_after_second) + + +class TutorChatApiTest(BaseViewTest): + def _make_session(self): + return LearningSession.objects.create(learner=self.profile, lesson=self.lesson) + + @patch("learning.views.get_tutor") + def test_chat_api_returns_response(self, mock_get_tutor): + mock_tutor = MagicMock() + mock_tutor.continue_conversation.return_value = "Great question!" + mock_get_tutor.return_value = mock_tutor + + self.login() + session = self._make_session() + + resp = self.client.post( + reverse("tutor_chat_api", args=[session.pk]), + data=json.dumps({"message": "What is a variable?"}), + content_type="application/json", + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data["response"], "Great question!") + + def test_chat_api_requires_post(self): + self.login() + session = self._make_session() + resp = self.client.get(reverse("tutor_chat_api", args=[session.pk])) + self.assertEqual(resp.status_code, 405) + + def test_chat_api_rejects_empty_message(self): + self.login() + session = self._make_session() + resp = self.client.post( + reverse("tutor_chat_api", args=[session.pk]), + data=json.dumps({"message": " "}), + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400) + + +class EvaluateAnswerApiTest(BaseViewTest): + @patch("learning.views.get_tutor") + def test_evaluate_returns_score(self, mock_get_tutor): + mock_tutor = MagicMock() + mock_tutor.evaluate_answer.return_value = { + "score": 0.9, + "feedback": "Excellent!", + "correct_answer": "A named storage.", + } + mock_get_tutor.return_value = mock_tutor + + self.login() + resp = self.client.post( + reverse("evaluate_answer_api"), + data=json.dumps( + { + "question": "What is a variable?", + "answer": "A container for data.", + "lesson_id": self.lesson.pk, + } + ), + content_type="application/json", + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertAlmostEqual(float(data["score"]), 0.9) + + def test_evaluate_rejects_missing_fields(self): + self.login() + resp = self.client.post( + reverse("evaluate_answer_api"), + data=json.dumps({"question": "What is X?"}), + content_type="application/json", + ) + self.assertEqual(resp.status_code, 400) + + +class ProgressViewTest(BaseViewTest): + def test_progress_renders(self): + self.login() + resp = self.client.get(reverse("progress")) + self.assertEqual(resp.status_code, 200) + + def test_progress_shows_completed_lessons(self): + prog = Progress.objects.create( + learner=self.profile, lesson=self.lesson, score=0.85, completed=True + ) + self.login() + resp = self.client.get(reverse("progress")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Variables") diff --git a/workers/README.md b/workers/README.md new file mode 100644 index 0000000..b946514 --- /dev/null +++ b/workers/README.md @@ -0,0 +1,64 @@ +# LearnPilot AI Worker + +This directory contains the **Cloudflare Python Worker** that powers +LearnPilot's AI features at the edge. + +## Architecture + +``` +LearnPilot Django app + β”‚ + β”‚ HTTP (when CLOUDFLARE_WORKER_URL is set) + β–Ό +Cloudflare Python Worker (workers/src/worker.py) + β”‚ + β”‚ Workers AI binding (env.AI) + β–Ό +Cloudflare Workers AI (@cf/meta/llama-3.1-8b-instruct) +``` + +The worker exposes these endpoints: + +| Method | Path | Description | +|--------|----------------|------------------------------------------| +| POST | `/ai/chat` | Continue a tutoring conversation | +| POST | `/ai/explain` | Explain a concept at the learner's level | +| POST | `/ai/practice` | Generate a practice question | +| POST | `/ai/evaluate` | Evaluate a learner's answer | +| POST | `/ai/path` | Generate a personalised learning path | +| GET | `/health` | Health check | + +## Requirements + +- [Node.js](https://nodejs.org/) β‰₯ 18 (for Wrangler CLI) +- A Cloudflare account with Workers AI enabled + +## Deploy + +```bash +# Install Wrangler CLI +npm install -g wrangler + +# Authenticate +wrangler login + +# Deploy the worker +cd workers +wrangler deploy +``` + +After deployment Wrangler will print the worker URL, e.g. +`https://learnpilot-ai..workers.dev`. + +Set this as `CLOUDFLARE_WORKER_URL` in the Django `.env` file to route +AI requests through the edge worker instead of calling the Cloudflare +AI REST API directly. + +## Local Development + +```bash +cd workers +wrangler dev +``` + +The worker will start on `http://localhost:8787`. diff --git a/workers/src/worker.py b/workers/src/worker.py new file mode 100644 index 0000000..554674c --- /dev/null +++ b/workers/src/worker.py @@ -0,0 +1,366 @@ +# LearnPilot AI Worker +# +# A Cloudflare Python Worker that exposes an AI tutoring API backed +# by Cloudflare Workers AI. Deploy with: +# +# cd workers && npx wrangler deploy +# +# The worker uses the Workers AI binding (env.AI) to run inference +# on Cloudflare's global edge network, providing low-latency responses. + +import json + + +async def on_fetch(request, env): + """Entry point for all incoming HTTP requests.""" + url = request.url + method = request.method + + # CORS preflight + if method == "OPTIONS": + return _cors_response("", 204) + + # Route dispatch + if "/ai/chat" in url and method == "POST": + return await _handle_chat(request, env) + + if "/ai/explain" in url and method == "POST": + return await _handle_explain(request, env) + + if "/ai/practice" in url and method == "POST": + return await _handle_practice(request, env) + + if "/ai/evaluate" in url and method == "POST": + return await _handle_evaluate(request, env) + + if "/ai/path" in url and method == "POST": + return await _handle_generate_path(request, env) + + if "/health" in url: + return _cors_response(json.dumps({"status": "ok", "service": "learnpilot-ai"}), 200) + + return _cors_response(json.dumps({"error": "Not found"}), 404) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +async def _handle_chat(request, env): + """ + Continue a tutoring conversation. + + Request body: + { + "messages": [{"role": "user"|"assistant"|"system", "content": "…"}, …], + "lesson_context": "…", // optional + "max_tokens": 1024 // optional + } + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + messages = body.get("messages", []) + lesson_context = body.get("lesson_context", "") + max_tokens = int(body.get("max_tokens", 1024)) + + if not messages: + return _error("messages is required", 400) + + system_prompt = _tutor_system_prompt(lesson_context) + full_messages = [{"role": "system", "content": system_prompt}] + messages[-10:] + + result = await env.AI.run( + "@cf/meta/llama-3.1-8b-instruct", + {"messages": full_messages, "max_tokens": max_tokens}, + ) + response_text = result.get("response", "") if isinstance(result, dict) else "" + return _cors_response(json.dumps({"response": response_text}), 200) + + +async def _handle_explain(request, env): + """ + Explain a concept at the learner's level. + + Request body: + { + "concept": "recursion", + "skill_level": "beginner", + "learning_style": "visual", + "context": "…" // optional + } + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + concept = body.get("concept", "").strip() + if not concept: + return _error("concept is required", 400) + + skill_level = body.get("skill_level", "beginner") + learning_style = body.get("learning_style", "visual") + context = body.get("context", "") + + style_hints = { + "visual": "Use text-described diagrams and visual metaphors.", + "auditory": "Explain conversationally as if speaking aloud.", + "reading": "Use numbered lists and clear definitions.", + "kinesthetic": "Emphasise hands-on examples and step-by-step tasks.", + } + style_hint = style_hints.get(learning_style, "") + context_section = f"\n\nLesson context:\n{context}" if context else "" + + prompt = ( + f"Explain the following concept to a {skill_level}-level learner.\n" + f"Learning style: {learning_style}. {style_hint}\n\n" + f"Concept: {concept}{context_section}\n\n" + "Structure your response as:\n" + "1. **Core Explanation** (2–4 sentences)\n" + "2. **Analogy** – a memorable real-world comparison\n" + "3. **Key Points** – 3–5 bullet points\n" + "4. **Quick Example** – a short, concrete illustration" + ) + + result = await env.AI.run( + "@cf/meta/llama-3.1-8b-instruct", + { + "messages": [ + {"role": "system", "content": _tutor_system_prompt()}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 1024, + }, + ) + text = result.get("response", "") if isinstance(result, dict) else "" + return _cors_response(json.dumps({"explanation": text}), 200) + + +async def _handle_practice(request, env): + """ + Generate a practice question. + + Request body: + { + "topic": "…", + "difficulty": "beginner|intermediate|advanced", + "question_type": "open-ended|multiple-choice|true-false" + } + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + topic = body.get("topic", "").strip() + if not topic: + return _error("topic is required", 400) + + difficulty = body.get("difficulty", "beginner") + question_type = body.get("question_type", "open-ended") + + prompt = ( + f"Generate a {difficulty}-level {question_type} practice question about: \"{topic}\"\n\n" + "Format:\n" + "- **Question:** \n" + "- **Hint:** \n" + "- **Expected Answer:** " + ) + + result = await env.AI.run( + "@cf/meta/llama-3.1-8b-instruct", + { + "messages": [ + {"role": "system", "content": _tutor_system_prompt()}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 512, + }, + ) + text = result.get("response", "") if isinstance(result, dict) else "" + return _cors_response(json.dumps({"question": text}), 200) + + +async def _handle_evaluate(request, env): + """ + Evaluate a learner's answer. + + Request body: + { + "question": "…", + "answer": "…", + "expected_answer": "…", // optional + "topic": "…" // optional + } + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + question = body.get("question", "").strip() + answer = body.get("answer", "").strip() + if not question or not answer: + return _error("question and answer are required", 400) + + expected = body.get("expected_answer", "") + topic = body.get("topic", "") + + context = f"Topic: {topic}\n" if topic else "" + expected_section = f"Expected answer context: {expected}\n" if expected else "" + + prompt = ( + f"{context}Question: {question}\n" + f"{expected_section}\n" + f"Learner's answer: {answer}\n\n" + "Evaluate this answer and respond in exactly this format:\n" + "SCORE: \n" + "FEEDBACK: <2-3 sentences of constructive feedback>\n" + "CORRECT_ANSWER: " + ) + + result = await env.AI.run( + "@cf/meta/llama-3.1-8b-instruct", + { + "messages": [ + {"role": "system", "content": _tutor_system_prompt()}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 512, + }, + ) + raw = result.get("response", "") if isinstance(result, dict) else "" + parsed = _parse_evaluation(raw) + return _cors_response(json.dumps(parsed), 200) + + +async def _handle_generate_path(request, env): + """ + Generate a personalised learning path. + + Request body: + { + "topic": "…", + "skill_level": "…", + "learning_style": "…", + "available_lessons": [{id, title, type, difficulty}, …], + "goals": "…" // optional + } + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + topic = body.get("topic", "").strip() + skill_level = body.get("skill_level", "beginner") + learning_style = body.get("learning_style", "visual") + available_lessons = body.get("available_lessons", []) + goals = body.get("goals", "") + + if not topic: + return _error("topic is required", 400) + + goals_section = f"\nLearner goals: {goals}" if goals else "" + lesson_list = json.dumps(available_lessons, indent=2) + + prompt = ( + f"Create a personalised learning path for:\n" + f"- Topic: {topic}\n" + f"- Skill level: {skill_level}\n" + f"- Learning style: {learning_style}{goals_section}\n\n" + f"Available lessons (JSON):\n{lesson_list}\n\n" + 'Return a JSON object with exactly two keys:\n' + '{\n' + ' "ordered_lesson_ids": [],\n' + ' "rationale": "<2-3 sentence explanation of the path design>"\n' + '}\n\n' + "Only include lessons appropriate for this learner." + ) + + result = await env.AI.run( + "@cf/meta/llama-3.1-8b-instruct", + { + "messages": [ + {"role": "system", "content": _curriculum_system_prompt()}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 1024, + }, + ) + raw = result.get("response", "") if isinstance(result, dict) else "" + + # Extract JSON from the response + start = raw.find("{") + end = raw.rfind("}") + if start != -1 and end > start: + try: + path_data = json.loads(raw[start : end + 1]) + return _cors_response(json.dumps(path_data), 200) + except (json.JSONDecodeError, ValueError): + pass + + return _cors_response(json.dumps({"ordered_lesson_ids": [], "rationale": raw}), 200) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _tutor_system_prompt(lesson_context: str = "") -> str: + base = ( + "You are LearnPilot, an expert AI tutor specialising in personalised education. " + "You adapt your explanations to the learner's skill level and preferred learning style. " + "You are patient, encouraging, and precise.\n\n" + "Guidelines:\n" + "- Keep explanations clear, structured, and appropriately concise.\n" + "- Use analogies and real-world examples.\n" + "- When a learner struggles, break concepts into smaller steps.\n" + "- Acknowledge correct answers warmly; redirect incorrect ones gently.\n" + "- Always end with an invitation to ask follow-up questions." + ) + if lesson_context: + base += f"\n\nCurrent lesson material:\n{lesson_context}" + return base + + +def _curriculum_system_prompt() -> str: + return ( + "You are an expert curriculum designer. " + "You create highly personalised, adaptive learning paths that maximise " + "learner engagement and knowledge retention based on evidence-based " + "learning principles such as spaced repetition and scaffolded instruction." + ) + + +def _parse_evaluation(raw: str) -> dict: + result = {"score": 0.5, "feedback": raw, "correct_answer": ""} + for line in raw.splitlines(): + line = line.strip() + if line.startswith("SCORE:"): + try: + result["score"] = float(line.split(":", 1)[1].strip()) + except ValueError: + pass + elif line.startswith("FEEDBACK:"): + result["feedback"] = line.split(":", 1)[1].strip() + elif line.startswith("CORRECT_ANSWER:"): + result["correct_answer"] = line.split(":", 1)[1].strip() + return result + + +def _cors_response(body: str, status: int): + headers = { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + } + return Response(body, status=status, headers=headers) + + +def _error(message: str, status: int): + return _cors_response(json.dumps({"error": message}), status) diff --git a/workers/wrangler.toml b/workers/wrangler.toml new file mode 100644 index 0000000..cfaf75f --- /dev/null +++ b/workers/wrangler.toml @@ -0,0 +1,15 @@ +name = "learnpilot-ai" +main = "src/worker.py" +compatibility_date = "2024-09-23" +compatibility_flags = ["python_workers"] + +[ai] +binding = "AI" + +[vars] +ENVIRONMENT = "production" + +# Optional: bind a KV namespace for caching AI responses +# [[kv_namespaces]] +# binding = "CACHE" +# id = "" From 487b1f4f0917a9a87ebea42a7aed0b1d77b6f69e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 23:08:39 +0000 Subject: [PATCH 3/3] Fix README capitalization (Uses Cloudflare AI Python workers) Co-authored-by: A1L13N <193832434+A1L13N@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fa40e5..698542d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # learnpilot -AI-powered personalized learning lab that adapts to each learner in real time. Combines natural language processing, adaptive curricula, intelligent tutoring, and progress tracking to create a dynamic educational experience with interactive explanations, guided practice, and continuous feedback. uses Cloudflare AI python workers +AI-powered personalized learning lab that adapts to each learner in real time. Combines natural language processing, adaptive curricula, intelligent tutoring, and progress tracking to create a dynamic educational experience with interactive explanations, guided practice, and continuous feedback. Uses Cloudflare AI Python workers ## Features