diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c09681a --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# 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: override the default AI model +# CLOUDFLARE_AI_MODEL=@cf/meta/llama-3.1-8b-instruct diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f31273 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd + +# Wrangler / Cloudflare Workers +.wrangler/ +.dev.vars + +# Node (Wrangler CLI) +node_modules/ +package-lock.json + +# Pytest +.pytest_cache/ +.coverage +htmlcov/ + +# Environment +.env + +# Editor +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store diff --git a/README.md b/README.md index 0d1aecb..df20d5a 100644 --- a/README.md +++ b/README.md @@ -1,2 +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. +# LearnPilot + +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. + +Uses **Cloudflare AI Python Workers** (`@cf/meta/llama-3.1-8b-instruct`). + +--- + +## Architecture + +``` +Client ──POST──▶ Cloudflare Python Worker (src/worker.py) + │ + │ env.AI (Workers AI binding) + ▼ + @cf/meta/llama-3.1-8b-instruct +``` + +The entire application runs at the edge as a single Cloudflare Python Worker. +No server infrastructure, no databases, no external dependencies. + +--- + +## API Endpoints + +| 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…" +} +``` + +### `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." +} +``` + +### `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." +} +``` + +--- + +## 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 +``` + +--- + +## 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 +# Install Wrangler CLI +npm install -g wrangler + +# Authenticate with Cloudflare +wrangler login + +# Deploy the worker to the edge +npx wrangler deploy +``` + +Wrangler will print the live URL, e.g. +`https://learnpilot-ai..workers.dev`. + +### Local Development + +```bash +npx wrangler dev +``` + +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 +# Install test dependencies +pip install -r requirements-dev.txt + +# Run tests +pytest tests/ -v +``` + +--- + +## 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/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/src/worker.py b/src/worker.py new file mode 100644 index 0000000..c65b96a --- /dev/null +++ b/src/worker.py @@ -0,0 +1,578 @@ +# LearnPilot AI Worker +# +# A Cloudflare Python Worker that exposes an AI tutoring API backed +# by Cloudflare Workers AI. Deploy with: +# +# 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 + + +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 "/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) + + 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) + + +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 +# --------------------------------------------------------------------------- + +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/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/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 }