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/4] 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/4] 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/4] 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 From f5ff2da6cde9579ae27be79b1e6c228f6e1ad78a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:22:36 +0000 Subject: [PATCH 4/4] Remove Django, restructure as pure Cloudflare Python Worker project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete all Django code (manage.py, learnpilot/, learning/, templates/, static/, requirements.txt, Django test suite) - Move workers/src/worker.py β†’ src/worker.py - Move workers/wrangler.toml β†’ wrangler.toml (root) - Remove workers/ subdirectory - Add /ai/progress, /ai/adapt, /ai/summary endpoints to worker - Add tests/test_worker.py: 39 pytest unit tests (no CF runtime needed) - Add requirements-dev.txt (pytest only) - Update .gitignore, .env.example, README.md for pure CF Workers project Co-authored-by: A1L13N <193832434+A1L13N@users.noreply.github.com> --- .env.example | 11 +- .gitignore | 41 +- README.md | 245 ++++++--- 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-dev.txt | 4 + requirements.txt | 6 - {workers/src => src}/worker.py | 214 +++++++- 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 -------- tests/test_worker.py | 507 ++++++++++++++++++ workers/README.md | 64 --- workers/wrangler.toml | 15 - wrangler.toml | 23 + 54 files changed, 944 insertions(+), 4075 deletions(-) delete mode 100644 learning/__init__.py delete mode 100644 learning/admin.py delete mode 100644 learning/ai/__init__.py delete mode 100644 learning/ai/adaptive.py delete mode 100644 learning/ai/cloudflare_ai.py delete mode 100644 learning/ai/tutor.py delete mode 100644 learning/apps.py delete mode 100644 learning/auth_urls.py delete mode 100644 learning/auth_views.py delete mode 100644 learning/management/__init__.py delete mode 100644 learning/management/commands/__init__.py delete mode 100644 learning/management/commands/seed_data.py delete mode 100644 learning/migrations/0001_initial.py delete mode 100644 learning/migrations/0002_alter_learnerprofile_last_active.py delete mode 100644 learning/migrations/__init__.py delete mode 100644 learning/models.py delete mode 100644 learning/urls.py delete mode 100644 learning/views.py delete mode 100644 learnpilot/__init__.py delete mode 100644 learnpilot/asgi.py delete mode 100644 learnpilot/settings.py delete mode 100644 learnpilot/urls.py delete mode 100644 learnpilot/wsgi.py delete mode 100644 manage.py create mode 100644 requirements-dev.txt delete mode 100644 requirements.txt rename {workers/src => src}/worker.py (64%) delete mode 100644 static/css/main.css delete mode 100644 static/js/tutor.js delete mode 100644 templates/base.html delete mode 100644 templates/learning/adaptive_path.html delete mode 100644 templates/learning/course_detail.html delete mode 100644 templates/learning/course_list.html delete mode 100644 templates/learning/dashboard.html delete mode 100644 templates/learning/generate_path.html delete mode 100644 templates/learning/home.html delete mode 100644 templates/learning/progress.html delete mode 100644 templates/learning/session.html delete mode 100644 templates/learning/session_end.html delete mode 100644 templates/registration/login.html delete mode 100644 templates/registration/register.html delete mode 100644 tests/__init__.py delete mode 100644 tests/test_adaptive.py delete mode 100644 tests/test_cloudflare_ai.py delete mode 100644 tests/test_models.py delete mode 100644 tests/test_tutor.py delete mode 100644 tests/test_views.py create mode 100644 tests/test_worker.py delete mode 100644 workers/README.md delete mode 100644 workers/wrangler.toml create mode 100644 wrangler.toml diff --git a/.env.example b/.env.example index 00815bd..c09681a 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,6 @@ -# Django settings -SECRET_KEY=your-secret-key-here -DEBUG=True -ALLOWED_HOSTS=localhost,127.0.0.1 - -# Cloudflare AI credentials +# Cloudflare credentials (used by Wrangler CLI – NOT stored in the worker itself) 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 +# Optional: override the default AI model +# CLOUDFLARE_AI_MODEL=@cf/meta/llama-3.1-8b-instruct diff --git a/.gitignore b/.gitignore index 2426314..4f31273 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,30 @@ +# Python __pycache__/ *.py[cod] *.pyo *.pyd -*.so -*.egg -*.egg-info/ -dist/ -build/ -.eggs/ + +# Wrangler / Cloudflare Workers +.wrangler/ +.dev.vars + +# Node (Wrangler CLI) +node_modules/ +package-lock.json + +# Pytest +.pytest_cache/ +.coverage +htmlcov/ + +# Environment .env -.venv/ -venv/ -env/ -ENV/ -db.sqlite3 -*.sqlite3 -*.log -.DS_Store -staticfiles/ -media/ + +# Editor .idea/ .vscode/ *.swp *.swo -node_modules/ -.node_modules/ -workers/.wrangler/ -workers/node_modules/ + +# macOS +.DS_Store diff --git a/README.md b/README.md index 698542d..df20d5a 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,207 @@ -# 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 +# LearnPilot -## Features +AI-powered personalised 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. -- 🧠 **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. +Uses **Cloudflare AI Python Workers** (`@cf/meta/llama-3.1-8b-instruct`). + +--- ## 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) +Client ──POST──▢ Cloudflare Python Worker (src/worker.py) + β”‚ + β”‚ env.AI (Workers AI binding) + β–Ό + @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`. +The entire application runs at the edge as a single Cloudflare Python Worker. +No server infrastructure, no databases, no external dependencies. -## Quick Start +--- -### 1. Clone & install dependencies +## API Endpoints -```bash -git clone https://github.com/alphaonelabs/learnpilot.git -cd learnpilot -pip install -r requirements.txt +| Method | Path | Description | +|--------|-----------------|--------------------------------------------------------| +| POST | `/ai/chat` | Continue a real-time tutoring conversation | +| POST | `/ai/explain` | Explain a concept (adapts to skill level + style) | +| POST | `/ai/practice` | Generate a practice question | +| POST | `/ai/evaluate` | Evaluate a learner's answer (returns score 0–1) | +| POST | `/ai/path` | Generate a personalised ordered learning path | +| POST | `/ai/progress` | Produce AI-driven progress insights | +| POST | `/ai/adapt` | Recommend a difficulty adjustment from recent scores | +| POST | `/ai/summary` | Summarise a completed tutoring session | +| GET | `/health` | Liveness check | + +All endpoints return JSON and support CORS. + +--- + +## Request / Response Examples + +### `POST /ai/explain` + +```json +// Request +{ + "concept": "recursion", + "skill_level": "beginner", + "learning_style": "visual", + "context": "We are studying Python functions." +} + +// Response +{ + "explanation": "1. **Core Explanation** – Recursion is when a function calls itself…" +} +``` + +### `POST /ai/evaluate` + +```json +// Request +{ + "question": "What is a Python decorator?", + "answer": "A function that wraps another function to add behaviour.", + "topic": "Python Functions" +} + +// Response +{ + "score": 0.85, + "feedback": "Excellent! You captured the core concept. Consider also mentioning…", + "correct_answer": "A decorator is a higher-order function that takes a function…" +} ``` -### 2. Configure environment +### `POST /ai/path` + +```json +// Request +{ + "topic": "Python Programming", + "skill_level": "beginner", + "learning_style": "kinesthetic", + "goals": "I want to build web apps", + "available_lessons": [ + {"id": 1, "title": "Variables", "type": "theory", "difficulty": "beginner"}, + {"id": 2, "title": "Functions", "type": "theory", "difficulty": "beginner"}, + {"id": 3, "title": "Mini-project", "type": "project", "difficulty": "beginner"} + ] +} + +// Response +{ + "ordered_lesson_ids": [1, 2, 3], + "rationale": "Start with variables to build a foundation, then functions for reuse, then apply both in a mini-project." +} +``` -```bash -cp .env.example .env -# Edit .env and set: -# SECRET_KEY, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN +### `POST /ai/adapt` + +```json +// Request +{ + "topic": "Python", + "current_difficulty": "beginner", + "recent_scores": [0.9, 0.95, 0.88] +} + +// Response +{ + "new_difficulty": "intermediate", + "action": "increase", + "reasoning": "Consistently high scores indicate readiness for more complex material." +} ``` -### 3. Initialise the database +--- -```bash -python manage.py migrate -python manage.py seed_data # Loads sample topics, courses, and lessons -python manage.py createsuperuser +## Project Structure + +``` +learnpilot/ +β”œβ”€β”€ src/ +β”‚ └── worker.py # Cloudflare Python Worker (all logic lives here) +β”œβ”€β”€ tests/ +β”‚ └── test_worker.py # Unit tests (pytest, no Cloudflare runtime needed) +β”œβ”€β”€ wrangler.toml # Cloudflare Workers deployment config +β”œβ”€β”€ requirements-dev.txt # Dev/test dependencies (pytest only) +β”œβ”€β”€ .env.example # Example environment variables for Wrangler CLI +β”œβ”€β”€ LICENSE +└── README.md ``` -### 4. Run the development server +--- + +## Getting Started + +### Prerequisites + +- [Node.js](https://nodejs.org/) β‰₯ 18 (for the Wrangler CLI) +- A [Cloudflare account](https://dash.cloudflare.com/sign-up) with Workers AI enabled + +### Deploy ```bash -python manage.py runserver +# Install Wrangler CLI +npm install -g wrangler + +# Authenticate with Cloudflare +wrangler login + +# Deploy the worker to the edge +npx wrangler deploy ``` -Open [http://127.0.0.1:8000/](http://127.0.0.1:8000/) in your browser. +Wrangler will print the live URL, e.g. +`https://learnpilot-ai..workers.dev`. -## Deploy Cloudflare Python Worker (optional) +### Local Development -See [`workers/README.md`](workers/README.md) for step-by-step deployment instructions. +```bash +npx wrangler dev +``` -Once deployed, set `CLOUDFLARE_WORKER_URL` in your `.env` to route AI -requests through the edge worker for lower latency. +The worker starts on `http://localhost:8787`. All AI calls are proxied to +Cloudflare's remote AI service automatically during development. + +--- ## Running Tests +The tests use only the Python standard library and `pytest`. No Cloudflare +runtime is required – a minimal `Response` shim is injected before importing +`worker.py`. + ```bash -python manage.py test tests +# Install test dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest tests/ -v ``` -## 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 -``` +## Environment Variables + +Copy `.env.example` to `.env` and fill in your credentials. These are only +needed by the **Wrangler CLI** on your local machine; they are never bundled +into the deployed worker. + +| Variable | Description | +|---------------------------|------------------------------------------| +| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | +| `CLOUDFLARE_API_TOKEN` | API token with Workers AI permission | + +--- + +## License +GNU General Public License v2 – see [LICENSE](LICENSE). diff --git a/learning/__init__.py b/learning/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/learning/admin.py b/learning/admin.py deleted file mode 100644 index 0c1a0c4..0000000 --- a/learning/admin.py +++ /dev/null @@ -1,69 +0,0 @@ -"""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 deleted file mode 100644 index e69de29..0000000 diff --git a/learning/ai/adaptive.py b/learning/ai/adaptive.py deleted file mode 100644 index 7e0b832..0000000 --- a/learning/ai/adaptive.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -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 deleted file mode 100644 index b7e5856..0000000 --- a/learning/ai/cloudflare_ai.py +++ /dev/null @@ -1,151 +0,0 @@ -""" -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 deleted file mode 100644 index 76d58c4..0000000 --- a/learning/ai/tutor.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -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 deleted file mode 100644 index 0116a79..0000000 --- a/learning/apps.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index 21c01aa..0000000 --- a/learning/auth_urls.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index 55d4d9b..0000000 --- a/learning/auth_views.py +++ /dev/null @@ -1,23 +0,0 @@ -"""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 deleted file mode 100644 index e69de29..0000000 diff --git a/learning/management/commands/__init__.py b/learning/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/learning/management/commands/seed_data.py b/learning/management/commands/seed_data.py deleted file mode 100644 index 7e0281d..0000000 --- a/learning/management/commands/seed_data.py +++ /dev/null @@ -1,311 +0,0 @@ -"""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 deleted file mode 100644 index 4ff7628..0000000 --- a/learning/migrations/0001_initial.py +++ /dev/null @@ -1,185 +0,0 @@ -# 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 deleted file mode 100644 index a39fb5d..0000000 --- a/learning/migrations/0002_alter_learnerprofile_last_active.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index e69de29..0000000 diff --git a/learning/models.py b/learning/models.py deleted file mode 100644 index 2920343..0000000 --- a/learning/models.py +++ /dev/null @@ -1,235 +0,0 @@ -"""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 deleted file mode 100644 index 67c3a8f..0000000 --- a/learning/urls.py +++ /dev/null @@ -1,28 +0,0 @@ -"""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 deleted file mode 100644 index 8a69cec..0000000 --- a/learning/views.py +++ /dev/null @@ -1,507 +0,0 @@ -"""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 deleted file mode 100644 index e69de29..0000000 diff --git a/learnpilot/asgi.py b/learnpilot/asgi.py deleted file mode 100644 index 2001b86..0000000 --- a/learnpilot/asgi.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index f75e7f9..0000000 --- a/learnpilot/settings.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -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 deleted file mode 100644 index 43cf00a..0000000 --- a/learnpilot/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -"""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 deleted file mode 100644 index 77e1373..0000000 --- a/learnpilot/wsgi.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index 9b51450..0000000 --- a/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/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-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b43bad1 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development / testing dependencies only. +# The worker itself runs on Cloudflare's edge runtime and has no pip deps. +pytest>=8.0 +pytest-asyncio>=0.23 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 01f2e4d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -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/workers/src/worker.py b/src/worker.py similarity index 64% rename from workers/src/worker.py rename to src/worker.py index 554674c..c65b96a 100644 --- a/workers/src/worker.py +++ b/src/worker.py @@ -3,10 +3,19 @@ # A Cloudflare Python Worker that exposes an AI tutoring API backed # by Cloudflare Workers AI. Deploy with: # -# cd workers && npx wrangler deploy +# 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. +# +# Endpoints: +# 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 +# POST /ai/progress – produce personalised progress insights +# GET /health – liveness check import json @@ -36,6 +45,15 @@ async def on_fetch(request, env): if "/ai/path" in url and method == "POST": return await _handle_generate_path(request, env) + if "/ai/progress" in url and method == "POST": + return await _handle_progress_insights(request, env) + + if "/ai/adapt" in url and method == "POST": + return await _handle_adapt_difficulty(request, env) + + if "/ai/summary" in url and method == "POST": + return await _handle_session_summary(request, env) + if "/health" in url: return _cors_response(json.dumps({"status": "ok", "service": "learnpilot-ai"}), 200) @@ -306,6 +324,200 @@ async def _handle_generate_path(request, env): return _cors_response(json.dumps({"ordered_lesson_ids": [], "rationale": raw}), 200) +async def _handle_progress_insights(request, env): + """ + Generate personalised progress insights for a learner. + + Request body: + { + "learner_name": "Alice", + "topic": "Python Programming", + "progress_data": [ + {"lesson": "Variables", "score": 0.9, "completed": true, "attempts": 1}, + … + ] + } + + Returns: + {"insights": "<4-6 sentence progress report>"} + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + learner_name = body.get("learner_name", "the learner") + topic = body.get("topic", "").strip() + progress_data = body.get("progress_data", []) + + if not topic: + return _error("topic is required", 400) + if not progress_data: + return _error("progress_data is required", 400) + + prompt = ( + f"Analyse {learner_name}'s learning progress in \"{topic}\":\n\n" + f"{json.dumps(progress_data, indent=2)}\n\n" + "Write a concise progress report (4–6 sentences) that:\n" + "1. Summarises overall performance.\n" + "2. Identifies strengths.\n" + "3. Pinpoints areas needing improvement.\n" + "4. Recommends a concrete next action." + ) + + 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": 512, + }, + ) + text = result.get("response", "") if isinstance(result, dict) else "" + return _cors_response(json.dumps({"insights": text}), 200) + + +async def _handle_adapt_difficulty(request, env): + """ + Recommend a difficulty adjustment based on recent performance. + + Request body: + { + "topic": "Python Programming", + "current_difficulty": "beginner", + "recent_scores": [0.9, 0.85, 0.95], + "struggles": ["recursion", "decorators"] // optional + } + + Returns: + {"new_difficulty": "intermediate", "action": "increase", "reasoning": "…"} + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + topic = body.get("topic", "").strip() + current_difficulty = body.get("current_difficulty", "beginner") + recent_scores = body.get("recent_scores", []) + struggles = body.get("struggles", []) + + if not topic: + return _error("topic is required", 400) + if not recent_scores: + return _error("recent_scores is required", 400) + + avg = sum(recent_scores) / len(recent_scores) + struggle_text = "" + if struggles: + struggle_text = f"\nTopics the learner struggled with: {', '.join(struggles)}" + + prompt = ( + f"A learner studying \"{topic}\" at {current_difficulty} difficulty " + f"has achieved an average score of {avg:.0%} over their last " + f"{len(recent_scores)} attempt(s).{struggle_text}\n\n" + "Should the difficulty change? Respond with JSON:\n" + "{\n" + ' "new_difficulty": "",\n' + ' "action": "",\n' + ' "reasoning": ""\n' + "}" + ) + + 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": 256, + }, + ) + raw = result.get("response", "") if isinstance(result, dict) else "" + + start = raw.find("{") + end = raw.rfind("}") + if start != -1 and end > start: + try: + adapt_data = json.loads(raw[start : end + 1]) + return _cors_response(json.dumps(adapt_data), 200) + except (json.JSONDecodeError, ValueError): + pass + + return _cors_response( + json.dumps( + { + "new_difficulty": current_difficulty, + "action": "maintain", + "reasoning": raw, + } + ), + 200, + ) + + +async def _handle_session_summary(request, env): + """ + Summarise a completed tutoring session. + + Request body: + { + "lesson_title": "Python Variables", + "conversation": [ + {"role": "user", "content": "…"}, + {"role": "assistant", "content": "…"}, + … + ] + } + + Returns: + {"summary": "<3-5 sentence session summary with takeaways and next steps>"} + """ + try: + body = await request.json() + except Exception: + return _error("Invalid JSON", 400) + + lesson_title = body.get("lesson_title", "").strip() + conversation = body.get("conversation", []) + + if not lesson_title: + return _error("lesson_title is required", 400) + if not conversation: + return _error("conversation is required", 400) + + dialogue = "\n".join( + f"{m.get('role', 'user').upper()}: {m.get('content', '')}" + for m in conversation + if m.get("role") != "system" + ) + + prompt = ( + f"A tutoring session on \"{lesson_title}\" just ended.\n" + f"Conversation:\n{dialogue}\n\n" + "Write a concise session summary (3–5 sentences) that:\n" + "1. Highlights the key concepts covered.\n" + "2. Notes any misconceptions that were corrected.\n" + "3. Suggests 1–2 concrete next steps for the learner." + ) + + 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({"summary": text}), 200) + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/static/css/main.css b/static/css/main.css deleted file mode 100644 index 52c764a..0000000 --- a/static/css/main.css +++ /dev/null @@ -1,23 +0,0 @@ -/* 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 deleted file mode 100644 index 696935d..0000000 --- a/static/js/tutor.js +++ /dev/null @@ -1,234 +0,0 @@ -/** - * 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 deleted file mode 100644 index 41be822..0000000 --- a/templates/base.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - {% 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 deleted file mode 100644 index a2c10e3..0000000 --- a/templates/learning/adaptive_path.html +++ /dev/null @@ -1,45 +0,0 @@ -{% 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 deleted file mode 100644 index 6ba25d3..0000000 --- a/templates/learning/course_detail.html +++ /dev/null @@ -1,67 +0,0 @@ -{% 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 deleted file mode 100644 index 9b61403..0000000 --- a/templates/learning/course_list.html +++ /dev/null @@ -1,56 +0,0 @@ -{% 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 deleted file mode 100644 index 1ba05b4..0000000 --- a/templates/learning/dashboard.html +++ /dev/null @@ -1,125 +0,0 @@ -{% 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 deleted file mode 100644 index 1f24ea0..0000000 --- a/templates/learning/generate_path.html +++ /dev/null @@ -1,41 +0,0 @@ -{% 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 deleted file mode 100644 index c755c9e..0000000 --- a/templates/learning/home.html +++ /dev/null @@ -1,46 +0,0 @@ -{% 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 deleted file mode 100644 index 78f0a3e..0000000 --- a/templates/learning/progress.html +++ /dev/null @@ -1,80 +0,0 @@ -{% 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 deleted file mode 100644 index 04fd210..0000000 --- a/templates/learning/session.html +++ /dev/null @@ -1,127 +0,0 @@ -{% 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 deleted file mode 100644 index 773d1b2..0000000 --- a/templates/learning/session_end.html +++ /dev/null @@ -1,52 +0,0 @@ -{% 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 deleted file mode 100644 index dc4d86f..0000000 --- a/templates/registration/login.html +++ /dev/null @@ -1,45 +0,0 @@ -{% 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 deleted file mode 100644 index 03d2b4c..0000000 --- a/templates/registration/register.html +++ /dev/null @@ -1,46 +0,0 @@ -{% 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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_adaptive.py b/tests/test_adaptive.py deleted file mode 100644 index 0894a43..0000000 --- a/tests/test_adaptive.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -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 deleted file mode 100644 index dc8ac0d..0000000 --- a/tests/test_cloudflare_ai.py +++ /dev/null @@ -1,106 +0,0 @@ -""" -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 deleted file mode 100644 index 199b0df..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -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 deleted file mode 100644 index 51f9b43..0000000 --- a/tests/test_tutor.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -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 deleted file mode 100644 index 8b46054..0000000 --- a/tests/test_views.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -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/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 0000000..6aa7ed3 --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,507 @@ +""" +Unit tests for src/worker.py helper functions. + +These tests cover all pure-Python logic in the worker – the async handlers +are tested via mocked env.AI objects so they can run without a live +Cloudflare environment. +""" + +import asyncio +import json +import sys +import types +import unittest +from unittest.mock import AsyncMock, MagicMock + +# --------------------------------------------------------------------------- +# Shim: make the `Response` built-in available to worker.py without a real +# Cloudflare runtime. We define a minimal Response class and inject it into +# builtins before importing the module. +# --------------------------------------------------------------------------- + +class Response: # noqa: D101 + def __init__(self, body="", *, status=200, headers=None): + self.body = body + self.status = status + self.headers = headers or {} + + +import builtins + +builtins.Response = Response # type: ignore[attr-defined] + +# Now it is safe to import the worker module +sys.path.insert(0, "src") +import worker # noqa: E402 (import after sys.path manipulation) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run(coro): + """Run a coroutine synchronously.""" + return asyncio.run(coro) + + +def _make_env(ai_response: dict) -> MagicMock: + """Return a mock env whose AI.run returns *ai_response*.""" + env = MagicMock() + env.AI.run = AsyncMock(return_value=ai_response) + return env + + +def _make_request(method: str, url: str, body: dict | None = None) -> MagicMock: + """Return a mock Request object.""" + req = MagicMock() + req.method = method + req.url = url + if body is not None: + req.json = AsyncMock(return_value=body) + else: + req.json = AsyncMock(side_effect=ValueError("no body")) + return req + + +# =========================================================================== +# _parse_evaluation +# =========================================================================== + +class TestParseEvaluation(unittest.TestCase): + def test_parses_all_fields(self): + raw = ( + "SCORE: 0.85\n" + "FEEDBACK: Great answer, you covered the main points.\n" + "CORRECT_ANSWER: A function that calls itself with a base case." + ) + result = worker._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: Okay" + result = worker._parse_evaluation(raw) + self.assertEqual(result["score"], 0.5) + self.assertEqual(result["feedback"], "Okay") + + def test_returns_defaults_for_empty_string(self): + result = worker._parse_evaluation("") + self.assertEqual(result["score"], 0.5) + self.assertEqual(result["feedback"], "") + self.assertEqual(result["correct_answer"], "") + + def test_score_field_only(self): + result = worker._parse_evaluation("SCORE: 1.0") + self.assertAlmostEqual(result["score"], 1.0) + + def test_correct_answer_field_only(self): + result = worker._parse_evaluation("CORRECT_ANSWER: Recursion terminates at the base case.") + self.assertIn("base case", result["correct_answer"]) + + +# =========================================================================== +# _tutor_system_prompt +# =========================================================================== + +class TestTutorSystemPrompt(unittest.TestCase): + def test_contains_learnpilot(self): + prompt = worker._tutor_system_prompt() + self.assertIn("LearnPilot", prompt) + + def test_appends_lesson_context(self): + prompt = worker._tutor_system_prompt("Variables store values.") + self.assertIn("Variables store values.", prompt) + self.assertIn("lesson material", prompt) + + def test_no_context_by_default(self): + prompt = worker._tutor_system_prompt() + self.assertNotIn("lesson material", prompt) + + +# =========================================================================== +# _curriculum_system_prompt +# =========================================================================== + +class TestCurriculumSystemPrompt(unittest.TestCase): + def test_contains_curriculum_keywords(self): + prompt = worker._curriculum_system_prompt() + self.assertIn("curriculum", prompt.lower()) + self.assertIn("learning", prompt.lower()) + + +# =========================================================================== +# _cors_response / _error +# =========================================================================== + +class TestCorsResponse(unittest.TestCase): + def test_status_code_is_preserved(self): + resp = worker._cors_response('{"ok": true}', 201) + self.assertEqual(resp.status, 201) + + def test_cors_headers_present(self): + resp = worker._cors_response("{}", 200) + self.assertIn("Access-Control-Allow-Origin", resp.headers) + self.assertEqual(resp.headers["Access-Control-Allow-Origin"], "*") + + def test_error_returns_json_with_error_key(self): + resp = worker._error("Bad request", 400) + self.assertEqual(resp.status, 400) + data = json.loads(resp.body) + self.assertIn("error", data) + self.assertEqual(data["error"], "Bad request") + + +# =========================================================================== +# on_fetch – routing +# =========================================================================== + +class TestRouting(unittest.TestCase): + def test_options_returns_204(self): + req = _make_request("OPTIONS", "https://example.com/ai/chat") + resp = run(worker.on_fetch(req, MagicMock())) + self.assertEqual(resp.status, 204) + + def test_health_returns_200(self): + req = _make_request("GET", "https://example.com/health") + resp = run(worker.on_fetch(req, MagicMock())) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertEqual(data["status"], "ok") + + def test_unknown_route_returns_404(self): + req = _make_request("GET", "https://example.com/unknown") + resp = run(worker.on_fetch(req, MagicMock())) + self.assertEqual(resp.status, 404) + + +# =========================================================================== +# /ai/chat handler +# =========================================================================== + +class TestHandleChat(unittest.TestCase): + def test_returns_ai_response(self): + env = _make_env({"response": "Recursion is when a function calls itself."}) + req = _make_request( + "POST", + "https://w.example.com/ai/chat", + {"messages": [{"role": "user", "content": "Explain recursion"}]}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertIn("Recursion", data["response"]) + + def test_missing_messages_returns_400(self): + env = _make_env({}) + req = _make_request("POST", "https://w.example.com/ai/chat", {}) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + def test_invalid_json_returns_400(self): + env = _make_env({}) + req = _make_request("POST", "https://w.example.com/ai/chat") + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + +# =========================================================================== +# /ai/explain handler +# =========================================================================== + +class TestHandleExplain(unittest.TestCase): + def test_returns_explanation(self): + env = _make_env({"response": "Recursion means a function calls itself."}) + req = _make_request( + "POST", + "https://w.example.com/ai/explain", + {"concept": "recursion", "skill_level": "beginner"}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertIn("explanation", data) + + def test_missing_concept_returns_400(self): + env = _make_env({}) + req = _make_request("POST", "https://w.example.com/ai/explain", {"skill_level": "beginner"}) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + def test_learning_style_included_in_prompt(self): + env = _make_env({"response": "Hands-on example …"}) + req = _make_request( + "POST", + "https://w.example.com/ai/explain", + {"concept": "loops", "skill_level": "intermediate", "learning_style": "kinesthetic"}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + # Verify the AI.run call contained the style hint + call_payload = env.AI.run.call_args[0][1] + full_text = " ".join(m["content"] for m in call_payload["messages"]) + self.assertIn("kinesthetic", full_text.lower()) + + +# =========================================================================== +# /ai/practice handler +# =========================================================================== + +class TestHandlePractice(unittest.TestCase): + def test_returns_question(self): + env = _make_env({"response": "**Question:** What is a for loop?"}) + req = _make_request( + "POST", + "https://w.example.com/ai/practice", + {"topic": "Python loops", "difficulty": "beginner"}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertIn("question", data) + + def test_missing_topic_returns_400(self): + env = _make_env({}) + req = _make_request("POST", "https://w.example.com/ai/practice", {"difficulty": "beginner"}) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + +# =========================================================================== +# /ai/evaluate handler +# =========================================================================== + +class TestHandleEvaluate(unittest.TestCase): + def test_returns_score_and_feedback(self): + ai_raw = "SCORE: 0.9\nFEEDBACK: Excellent!\nCORRECT_ANSWER: A named storage location." + env = _make_env({"response": ai_raw}) + req = _make_request( + "POST", + "https://w.example.com/ai/evaluate", + {"question": "What is a variable?", "answer": "A box that holds a value"}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertAlmostEqual(float(data["score"]), 0.9) + self.assertIn("feedback", data) + self.assertIn("correct_answer", data) + + def test_missing_question_returns_400(self): + env = _make_env({}) + req = _make_request("POST", "https://w.example.com/ai/evaluate", {"answer": "something"}) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + def test_missing_answer_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", "https://w.example.com/ai/evaluate", {"question": "What is X?"} + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + +# =========================================================================== +# /ai/path handler +# =========================================================================== + +class TestHandleGeneratePath(unittest.TestCase): + def test_returns_ordered_lesson_ids(self): + ai_json = '{"ordered_lesson_ids": [3, 1, 2], "rationale": "Start simple."}' + env = _make_env({"response": ai_json}) + req = _make_request( + "POST", + "https://w.example.com/ai/path", + { + "topic": "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": "Intro", "type": "theory", "difficulty": "beginner"}, + ], + }, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertIn("ordered_lesson_ids", data) + self.assertEqual(data["ordered_lesson_ids"], [3, 1, 2]) + + def test_falls_back_gracefully_on_malformed_json(self): + env = _make_env({"response": "Sorry, cannot generate path."}) + req = _make_request( + "POST", + "https://w.example.com/ai/path", + {"topic": "Python", "skill_level": "beginner", "learning_style": "visual", "available_lessons": []}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertIn("ordered_lesson_ids", data) + self.assertEqual(data["ordered_lesson_ids"], []) + + def test_missing_topic_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", + "https://w.example.com/ai/path", + {"skill_level": "beginner", "available_lessons": []}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + +# =========================================================================== +# /ai/progress handler +# =========================================================================== + +class TestHandleProgressInsights(unittest.TestCase): + def test_returns_insights(self): + env = _make_env({"response": "You're making steady progress. Keep practising loops."}) + req = _make_request( + "POST", + "https://w.example.com/ai/progress", + { + "learner_name": "Alice", + "topic": "Python", + "progress_data": [ + {"lesson": "Variables", "score": 0.9, "completed": True, "attempts": 1} + ], + }, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertIn("insights", data) + + def test_missing_topic_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", + "https://w.example.com/ai/progress", + {"learner_name": "Alice", "progress_data": [{"lesson": "x", "score": 1.0}]}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + def test_missing_progress_data_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", + "https://w.example.com/ai/progress", + {"learner_name": "Alice", "topic": "Python"}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + +# =========================================================================== +# /ai/adapt handler +# =========================================================================== + +class TestHandleAdaptDifficulty(unittest.TestCase): + def test_returns_adapt_recommendation(self): + ai_json = '{"new_difficulty": "intermediate", "action": "increase", "reasoning": "High scores."}' + env = _make_env({"response": ai_json}) + req = _make_request( + "POST", + "https://w.example.com/ai/adapt", + {"topic": "Python", "current_difficulty": "beginner", "recent_scores": [0.9, 0.95]}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertEqual(data["action"], "increase") + self.assertEqual(data["new_difficulty"], "intermediate") + + def test_missing_topic_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", + "https://w.example.com/ai/adapt", + {"current_difficulty": "beginner", "recent_scores": [0.9]}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + def test_missing_recent_scores_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", + "https://w.example.com/ai/adapt", + {"topic": "Python", "current_difficulty": "beginner"}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + +# =========================================================================== +# /ai/summary handler +# =========================================================================== + +class TestHandleSessionSummary(unittest.TestCase): + def test_returns_summary(self): + env = _make_env({"response": "The session covered variables and loops."}) + req = _make_request( + "POST", + "https://w.example.com/ai/summary", + { + "lesson_title": "Python Variables", + "conversation": [ + {"role": "user", "content": "What is a variable?"}, + {"role": "assistant", "content": "A variable is a named storage location."}, + ], + }, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + data = json.loads(resp.body) + self.assertIn("summary", data) + + def test_missing_lesson_title_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", + "https://w.example.com/ai/summary", + {"conversation": [{"role": "user", "content": "Hi"}]}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + def test_missing_conversation_returns_400(self): + env = _make_env({}) + req = _make_request( + "POST", + "https://w.example.com/ai/summary", + {"lesson_title": "Python Variables"}, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 400) + + def test_system_messages_excluded_from_dialogue(self): + env = _make_env({"response": "Good session."}) + req = _make_request( + "POST", + "https://w.example.com/ai/summary", + { + "lesson_title": "Test", + "conversation": [ + {"role": "system", "content": "SECRET INSTRUCTIONS"}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + ], + }, + ) + resp = run(worker.on_fetch(req, env)) + self.assertEqual(resp.status, 200) + # Verify the AI was NOT passed the system message content in the user prompt + call_payload = env.AI.run.call_args[0][1] + user_prompt = call_payload["messages"][-1]["content"] + self.assertNotIn("SECRET INSTRUCTIONS", user_prompt) + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/README.md b/workers/README.md deleted file mode 100644 index b946514..0000000 --- a/workers/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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/wrangler.toml b/workers/wrangler.toml deleted file mode 100644 index cfaf75f..0000000 --- a/workers/wrangler.toml +++ /dev/null @@ -1,15 +0,0 @@ -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 = "" diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..9c3770c --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,23 @@ +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. +# Create a namespace with: wrangler kv:namespace create CACHE +# [[kv_namespaces]] +# binding = "CACHE" +# id = "" + +# Optional: rate-limit binding +# [[unsafe.bindings]] +# name = "RATE_LIMITER" +# type = "ratelimit" +# namespace_id = "1001" +# simple = { limit = 100, period = 60 }