diff --git a/cortex/cli.py b/cortex/cli.py index b1cfe4a1..4f7769d2 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -1569,6 +1569,66 @@ def status(self): doctor = SystemDoctor() return doctor.run_checks() + def tutor(self, args: argparse.Namespace) -> int: + """AI-powered package tutor for interactive learning. + + Provides LLM-powered explanations, Q&A, code examples, + and step-by-step tutorials for packages. + + Args: + args: Parsed command-line arguments. + + Returns: + Exit code (0 for success, 1 for error). + """ + from cortex.tutor.branding import print_banner + from cortex.tutor.cli import ( + cmd_list_packages, + cmd_progress, + cmd_question, + cmd_reset, + cmd_teach, + ) + + # Handle --list flag + if getattr(args, "list", False): + return cmd_list_packages() + + # Handle --progress flag + if getattr(args, "progress", False): + package = getattr(args, "package", None) + return cmd_progress(package) + + # Handle --reset flag + reset_target = getattr(args, "reset", None) + if reset_target is not None: + package = None if reset_target == "__all__" else reset_target + return cmd_reset(package) + + # Handle -q/--question flag + question = getattr(args, "question", None) + package = getattr(args, "package", None) + + if question: + if not package: + cx_print("Please specify a package: cortex tutor -q 'question'", "error") + return 1 + return cmd_question(package, question, verbose=self.verbose) + + # Handle package argument (start interactive tutor) + if package: + print_banner() + fresh = getattr(args, "fresh", False) + return cmd_teach(package, verbose=self.verbose, fresh=fresh) + + # No arguments - show help + cx_print("Usage: cortex tutor [options]", "info") + cx_print(" cortex tutor docker - Start interactive lesson", "info") + cx_print(" cortex tutor docker -q 'What is Docker?' - Quick Q&A", "info") + cx_print(" cortex tutor --list - List studied packages", "info") + cx_print(" cortex tutor --progress - Show learning progress", "info") + return 0 + def update(self, args: argparse.Namespace) -> int: """Handle the update command for self-updating Cortex.""" from rich.progress import Progress, SpinnerColumn, TextColumn @@ -2971,6 +3031,23 @@ def main(): # Status command (includes comprehensive health checks) subparsers.add_parser("status", help="Show comprehensive system status and health checks") + # Tutor command - AI-powered package education (Issue #131) + tutor_parser = subparsers.add_parser("tutor", help="AI-powered package tutor") + tutor_parser.add_argument("package", nargs="?", help="Package to learn about") + tutor_parser.add_argument( + "-q", "--question", type=str, help="Ask a quick question about the package" + ) + tutor_parser.add_argument("--list", "-l", action="store_true", help="List studied packages") + tutor_parser.add_argument( + "--progress", "-p", action="store_true", help="Show learning progress" + ) + tutor_parser.add_argument( + "--reset", nargs="?", const="__all__", metavar="PACKAGE", help="Reset progress" + ) + tutor_parser.add_argument( + "--fresh", "-f", action="store_true", help="Skip cache, generate fresh" + ) + # Benchmark command benchmark_parser = subparsers.add_parser("benchmark", help="Run AI performance benchmark") benchmark_parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") @@ -3570,6 +3647,8 @@ def main(): return cli.wizard() elif args.command == "status": return cli.status() + elif args.command == "tutor": + return cli.tutor(args) elif args.command == "benchmark": return cli.benchmark(verbose=getattr(args, "verbose", False)) elif args.command == "systemd": diff --git a/cortex/tutor/__init__.py b/cortex/tutor/__init__.py new file mode 100644 index 00000000..b6e2a521 --- /dev/null +++ b/cortex/tutor/__init__.py @@ -0,0 +1,13 @@ +""" +Intelligent Tutor - AI-Powered Installation Tutor for Cortex Linux. + +An interactive AI tutor that teaches users about packages and best practices. +""" + +__version__ = "0.1.0" +__author__ = "Sri Krishna Vamsi" + +from cortex.tutor.branding import console, tutor_print +from cortex.tutor.config import Config + +__all__ = ["Config", "console", "tutor_print", "__version__"] diff --git a/cortex/tutor/agents/__init__.py b/cortex/tutor/agents/__init__.py new file mode 100644 index 00000000..71512b62 --- /dev/null +++ b/cortex/tutor/agents/__init__.py @@ -0,0 +1,9 @@ +""" +LangGraph agents for Intelligent Tutor. + +Provides Plan→Act→Reflect workflow agents. +""" + +from cortex.tutor.agents.tutor_agent.tutor_agent import TutorAgent + +__all__ = ["TutorAgent"] diff --git a/cortex/tutor/agents/tutor_agent/__init__.py b/cortex/tutor/agents/tutor_agent/__init__.py new file mode 100644 index 00000000..c3c42fda --- /dev/null +++ b/cortex/tutor/agents/tutor_agent/__init__.py @@ -0,0 +1,7 @@ +""" +Tutor Agent - Main orchestrator for interactive tutoring. +""" + +from cortex.tutor.agents.tutor_agent.tutor_agent import InteractiveTutor, TutorAgent + +__all__ = ["TutorAgent", "InteractiveTutor"] diff --git a/cortex/tutor/agents/tutor_agent/tutor_agent.py b/cortex/tutor/agents/tutor_agent/tutor_agent.py new file mode 100644 index 00000000..a985d939 --- /dev/null +++ b/cortex/tutor/agents/tutor_agent/tutor_agent.py @@ -0,0 +1,399 @@ +""" +Tutor Agent - Main orchestrator for interactive tutoring. + +Simplified implementation using cortex.llm_router directly. +""" + +from typing import Any + +from cortex.tutor.branding import console, tutor_print +from cortex.tutor.config import DEFAULT_TUTOR_TOPICS +from cortex.tutor.llm import answer_question, generate_lesson +from cortex.tutor.tools.deterministic.lesson_loader import LessonLoaderTool +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.deterministic.validators import ( + validate_package_name, + validate_question, +) + + +class TutorAgent: + """ + Main Tutor Agent for interactive package education. + + Example: + >>> agent = TutorAgent() + >>> result = agent.teach("docker") + >>> print(result.get("summary")) + """ + + def __init__(self, verbose: bool = False) -> None: + """Initialize the Tutor Agent.""" + self.verbose = verbose + self.progress_tool = ProgressTrackerTool() + self.loader = LessonLoaderTool() + + def teach( + self, + package_name: str, + force_fresh: bool = False, + ) -> dict[str, Any]: + """ + Start a tutoring session for a package. + + Args: + package_name: Name of the package to teach. + force_fresh: Skip cache and generate fresh content. + + Returns: + Dict containing lesson content and metadata. + """ + is_valid, error = validate_package_name(package_name) + if not is_valid: + raise ValueError(f"Invalid package name: {error}") + + if self.verbose: + tutor_print(f"Starting lesson for {package_name}...", "tutor") + + # Check cache first (free) + if not force_fresh: + cache_result = self.loader._run(package_name) + if cache_result.get("cache_hit"): + if self.verbose: + tutor_print("Using cached lesson", "info") + return { + "type": "lesson", + "content": cache_result["lesson"], + "source": "cache", + "cache_hit": True, + "cost_usd": 0.0, + "validation_passed": True, + } + + # Get student profile for personalization + profile = self._get_profile() + + # Generate new lesson + if self.verbose: + tutor_print("Generating lesson...", "info") + + result = generate_lesson( + package_name=package_name, + student_level=profile.get("student_level", "beginner"), + learning_style=profile.get("learning_style", "reading"), + skip_areas=profile.get("mastered_concepts", []), + ) + + if not result.get("success"): + return { + "type": "error", + "content": None, + "source": "failed", + "validation_passed": False, + "validation_errors": [result.get("error", "Unknown error")], + } + + # Cache the lesson + lesson = result["lesson"] + self.loader.cache_lesson(package_name, lesson) + + # Update progress + self.progress_tool._run( + "update_progress", + package_name=package_name, + topic="overview", + ) + + return { + "type": "lesson", + "content": lesson, + "source": "generated", + "cache_hit": False, + "cost_usd": result.get("cost_usd", 0.0), + "validation_passed": True, + } + + def ask( + self, + package_name: str, + question: str, + ) -> dict[str, Any]: + """ + Ask a question about a package. + + Args: + package_name: Package context for the question. + question: The question to ask. + + Returns: + Dict containing the answer and related info. + """ + is_valid, error = validate_package_name(package_name) + if not is_valid: + raise ValueError(f"Invalid package name: {error}") + + is_valid, error = validate_question(question) + if not is_valid: + raise ValueError(f"Invalid question: {error}") + + if self.verbose: + tutor_print(f"Answering question about {package_name}...", "tutor") + + result = answer_question(package_name=package_name, question=question) + + if not result.get("success"): + return { + "type": "error", + "content": None, + "validation_passed": False, + "validation_errors": [result.get("error", "Unknown error")], + } + + return { + "type": "qa", + "content": result["answer"], + "source": "generated", + "cost_usd": result.get("cost_usd", 0.0), + "validation_passed": True, + } + + def _get_profile(self) -> dict: + """Get student profile from progress tracker.""" + result = self.progress_tool._run("get_profile") + return result.get("profile", {}) if result.get("success") else {} + + def get_progress(self, package_name: str | None = None) -> dict[str, Any]: + """Get learning progress.""" + if package_name: + return self.progress_tool._run("get_stats", package_name=package_name) + return self.progress_tool._run("get_all_progress") + + def get_profile(self) -> dict[str, Any]: + """Get student profile.""" + return self.progress_tool._run("get_profile") + + def update_learning_style(self, style: str) -> bool: + """Update preferred learning style.""" + valid_styles = {"visual", "reading", "hands-on"} + if style not in valid_styles: + return False + result = self.progress_tool._run("update_profile", learning_style=style) + return result.get("success", False) + + def mark_completed(self, package_name: str, topic: str, score: float = 1.0) -> bool: + """Mark a topic as completed.""" + if not 0.0 <= score <= 1.0: + return False + result = self.progress_tool._run( + "mark_completed", + package_name=package_name, + topic=topic, + score=score, + ) + return result.get("success", False) + + def reset_progress(self, package_name: str | None = None) -> int: + """Reset learning progress.""" + result = self.progress_tool._run("reset", package_name=package_name) + return result.get("count", 0) if result.get("success") else 0 + + def get_packages_studied(self) -> list[str]: + """Get list of packages that have been studied.""" + result = self.progress_tool._run("get_packages") + return result.get("packages", []) if result.get("success") else [] + + +class InteractiveTutor: + """Interactive tutoring session manager.""" + + def __init__(self, package_name: str, force_fresh: bool = False) -> None: + """Initialize interactive tutor for a package.""" + self.package_name = package_name + self.force_fresh = force_fresh + self.agent = TutorAgent(verbose=False) + self.lesson: dict[str, Any] | None = None + + def start(self) -> None: + """Start the interactive tutoring session.""" + from cortex.tutor.branding import ( + get_user_input, + print_best_practice, + print_code_example, + print_lesson_header, + print_markdown, + print_menu, + print_progress_summary, + print_tutorial_step, + ) + + tutor_print(f"Loading lesson for {self.package_name}...", "tutor") + result = self.agent.teach(self.package_name, force_fresh=self.force_fresh) + + if not result.get("validation_passed"): + tutor_print("Failed to load lesson. Please try again.", "error") + return + + self.lesson = result.get("content", {}) + print_lesson_header(self.package_name) + console.print(f"\n{self.lesson.get('summary', '')}\n") + + while True: + print_menu( + [ + "Learn basic concepts", + "See code examples", + "Follow tutorial", + "View best practices", + "Ask a question", + "Check progress", + "Exit", + ] + ) + + choice = get_user_input("Select option") + if not choice: + continue + + try: + option = int(choice) + except ValueError: + tutor_print("Please enter a number", "warning") + continue + + if option == 1: + self._show_concepts() + elif option == 2: + self._show_examples() + elif option == 3: + self._run_tutorial() + elif option == 4: + self._show_best_practices() + elif option == 5: + self._ask_question() + elif option == 6: + self._show_progress() + elif option == 7: + tutor_print("Thanks for learning! Goodbye.", "success") + break + else: + tutor_print("Invalid option", "warning") + + def _show_concepts(self) -> None: + """Show basic concepts.""" + from cortex.tutor.branding import print_markdown + + if self.lesson: + explanation = self.lesson.get("explanation", "No explanation available.") + print_markdown(f"## Concepts\n\n{explanation}") + self.agent.mark_completed(self.package_name, "concepts", 0.5) + + def _show_examples(self) -> None: + """Show code examples.""" + from cortex.tutor.branding import print_code_example + + if not self.lesson: + return + + examples = self.lesson.get("code_examples", []) + if not examples: + tutor_print("No examples available", "info") + return + + for ex in examples: + print_code_example( + ex.get("code", ""), + ex.get("language", "bash"), + ex.get("title", "Example"), + ) + console.print(f"[dim]{ex.get('description', '')}[/dim]\n") + + self.agent.mark_completed(self.package_name, "examples", 0.7) + + def _run_tutorial(self) -> None: + """Run step-by-step tutorial.""" + from cortex.tutor.branding import get_user_input, print_tutorial_step + + if not self.lesson: + return + + steps = self.lesson.get("tutorial_steps", []) + if not steps: + tutor_print("No tutorial available", "info") + return + + for step in steps: + print_tutorial_step( + step.get("content", ""), + step.get("step_number", 1), + len(steps), + ) + + if step.get("code"): + console.print(f"\n[cyan]Code:[/cyan] {step['code']}") + + response = get_user_input("Press Enter to continue (or 'q' to quit)") + if response.lower() == "q": + break + + self.agent.mark_completed(self.package_name, "tutorial", 0.9) + + def _show_best_practices(self) -> None: + """Show best practices.""" + from cortex.tutor.branding import print_best_practice + + if not self.lesson: + return + + practices = self.lesson.get("best_practices", []) + if not practices: + tutor_print("No best practices available", "info") + return + + console.print("\n[bold]Best Practices[/bold]") + for i, practice in enumerate(practices, 1): + print_best_practice(practice, i) + + self.agent.mark_completed(self.package_name, "best_practices", 0.6) + + def _ask_question(self) -> None: + """Handle Q&A.""" + from cortex.tutor.branding import get_user_input, print_markdown + + question = get_user_input("Your question") + if not question: + return + + tutor_print("Thinking...", "info") + try: + result = self.agent.ask(self.package_name, question) + except ValueError as e: + tutor_print(f"Invalid question: {e}", "error") + return + + if result.get("validation_passed"): + content = result.get("content", {}) + answer = content.get("answer", "I couldn't find an answer.") + print_markdown(f"\n**Answer:** {answer}") + + if content.get("code_example"): + from cortex.tutor.branding import print_code_example + + ex = content["code_example"] + print_code_example(ex.get("code", ""), ex.get("language", "bash")) + else: + tutor_print("Sorry, I couldn't answer that question.", "error") + + def _show_progress(self) -> None: + """Show learning progress.""" + from cortex.tutor.branding import print_progress_summary + + result = self.agent.get_progress(self.package_name) + if result.get("success"): + stats = result.get("stats", {}) + print_progress_summary( + stats.get("completed", 0), + stats.get("total", 0) or DEFAULT_TUTOR_TOPICS, + self.package_name, + ) + else: + tutor_print("Could not load progress", "warning") diff --git a/cortex/tutor/branding.py b/cortex/tutor/branding.py new file mode 100644 index 00000000..e2dc2f68 --- /dev/null +++ b/cortex/tutor/branding.py @@ -0,0 +1,266 @@ +""" +Terminal UI branding and styling for Intelligent Tutor. + +Provides Rich console utilities following Cortex Linux patterns. +""" + +from typing import Literal, Optional + +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + +# Global Rich console instance +console = Console() + +# Status type definitions +StatusType = Literal["success", "error", "info", "warning", "tutor", "question"] + +# Status emoji and color mappings +STATUS_CONFIG = { + "success": {"emoji": "[green]\u2713[/green]", "color": "green"}, + "error": {"emoji": "[red]\u2717[/red]", "color": "red"}, + "info": {"emoji": "[blue]\u2139[/blue]", "color": "blue"}, + "warning": {"emoji": "[yellow]\u26a0[/yellow]", "color": "yellow"}, + "tutor": {"emoji": "[cyan]\U0001f393[/cyan]", "color": "cyan"}, + "question": {"emoji": "[magenta]?[/magenta]", "color": "magenta"}, +} + + +def tutor_print(message: str, status: StatusType = "info") -> None: + """ + Print a formatted message with status indicator. + + Args: + message: The message to display. + status: Status type for styling (success, error, info, warning, tutor, question). + + Example: + >>> tutor_print("Lesson completed!", "success") + >>> tutor_print("An error occurred", "error") + """ + config = STATUS_CONFIG.get(status, STATUS_CONFIG["info"]) + emoji = config["emoji"] + color = config["color"] + console.print(f"{emoji} [{color}]{message}[/{color}]") + + +def print_banner() -> None: + """ + Print the Intelligent Tutor welcome banner. + + Displays a styled ASCII art banner with version info. + """ + banner_text = """ +[bold cyan] ___ _ _ _ _ _ _____ _ + |_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_ |_ _| _| |_ ___ _ __ + | || '_ \\| __/ _ \\ | | |/ _` |/ _ \\ '_ \\| __| | || | | | __/ _ \\| '__| + | || | | | || __/ | | | (_| | __/ | | | |_ | || |_| | || (_) | | + |___|_| |_|\\__\\___|_|_|_|\\__, |\\___|_| |_|\\__| |_| \\__,_|\\__\\___/|_| + |___/[/bold cyan] + """ + console.print(banner_text) + console.print("[dim]AI-Powered Package Tutor for Cortex Linux[/dim]") + console.print() + + +def print_lesson_header(package_name: str) -> None: + """ + Print a styled lesson header for a package. + + Args: + package_name: Name of the package being taught. + """ + header = Panel( + f"[bold cyan]\U0001f393 {package_name} Tutorial[/bold cyan]", + border_style="cyan", + padding=(0, 2), + ) + console.print(header) + console.print() + + +def print_menu(options: list[str], title: str = "Select an option") -> None: + """ + Print a numbered menu of options. + + Args: + options: List of menu options to display. + title: Title for the menu. + """ + console.print(f"\n[bold]{title}[/bold]") + for i, option in enumerate(options, 1): + console.print(f" [cyan]{i}.[/cyan] {option}") + console.print() + + +def print_code_example(code: str, language: str = "python", title: str | None = None) -> None: + """ + Print a syntax-highlighted code block. + + Args: + code: The code to display. + language: Programming language for syntax highlighting. + title: Optional title for the code block. + """ + syntax = Syntax(code, language, theme="monokai", line_numbers=True) + if title: + console.print(f"\n[bold]{title}[/bold]") + console.print(syntax) + console.print() + + +def print_best_practice(practice: str, index: int) -> None: + """ + Print a styled best practice item. + + Args: + practice: The best practice text. + index: Index number of the practice. + """ + console.print(f" [green]\u2713[/green] [bold]{index}.[/bold] {practice}") + + +def print_tutorial_step(step: str, step_number: int, total_steps: int) -> None: + """ + Print a tutorial step with progress indicator. + + Args: + step: The step description. + step_number: Current step number. + total_steps: Total number of steps. + """ + progress_bar = "\u2588" * step_number + "\u2591" * (total_steps - step_number) + console.print(f"\n[dim][{progress_bar}] Step {step_number}/{total_steps}[/dim]") + console.print(f"[bold cyan]\u25b6[/bold cyan] {step}") + + +def print_progress_summary(completed: int, total: int, package_name: str) -> None: + """ + Print a progress summary for a package. + + Args: + completed: Number of completed topics. + total: Total number of topics. + package_name: Name of the package. + """ + percentage = (completed / total * 100) if total > 0 else 0 + bar_filled = int(percentage / 5) + bar_empty = 20 - bar_filled + progress_bar = "[green]\u2588[/green]" * bar_filled + "[dim]\u2591[/dim]" * bar_empty + + console.print(f"\n[bold]{package_name} Progress[/bold]") + console.print(f" {progress_bar} {percentage:.0f}%") + console.print(f" [dim]{completed}/{total} topics completed[/dim]") + + +def print_table(headers: list[str], rows: list[list[str]], title: str = "") -> None: + """ + Print a formatted table. + + Args: + headers: Column headers. + rows: Table rows (list of lists). + title: Optional table title. + """ + table = Table(title=title, show_header=True, header_style="bold cyan") + + for header in headers: + table.add_column(header) + + for row in rows: + table.add_row(*row) + + console.print(table) + console.print() + + +def print_markdown(content: str) -> None: + """ + Print markdown-formatted content. + + Args: + content: Markdown text to render. + """ + md = Markdown(content) + console.print(md) + + +def get_user_input(prompt: str, default: str | None = None) -> str: + """ + Get user input with optional default value. + + Args: + prompt: The prompt to display. + default: Optional default value. + + Returns: + str: User input or default value. + """ + if default: + prompt_text = f"[bold cyan]{prompt}[/bold cyan] [dim]({default})[/dim]: " + else: + prompt_text = f"[bold cyan]{prompt}[/bold cyan]: " + + try: + response = console.input(prompt_text) + return response.strip() or default or "" + except (EOFError, KeyboardInterrupt): + console.print() + tutor_print("Operation cancelled", "info") + return "" + + +def create_progress_bar(_description: str = "Processing") -> Progress: + """ + Create a Rich progress bar for long-running operations. + + Args: + _description: Description text for the progress bar (reserved for future use). + + Returns: + Progress: Configured Rich Progress instance. + """ + return Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + console=console, + ) + + +def print_error_panel(error_message: str, title: str = "Error") -> None: + """ + Print an error message in a styled panel. + + Args: + error_message: The error message to display. + title: Panel title. + """ + panel = Panel( + f"[red]{error_message}[/red]", + title=f"[bold red]{title}[/bold red]", + border_style="red", + ) + console.print(panel) + + +def print_success_panel(message: str, title: str = "Success") -> None: + """ + Print a success message in a styled panel. + + Args: + message: The success message to display. + title: Panel title. + """ + panel = Panel( + f"[green]{message}[/green]", + title=f"[bold green]{title}[/bold green]", + border_style="green", + ) + console.print(panel) diff --git a/cortex/tutor/cli.py b/cortex/tutor/cli.py new file mode 100644 index 00000000..40dc63d1 --- /dev/null +++ b/cortex/tutor/cli.py @@ -0,0 +1,390 @@ +""" +CLI for Intelligent Tutor. + +Provides command-line interface for the AI-powered package tutor. + +Usage: + tutor Start interactive tutor for a package + tutor --list List packages you've studied + tutor --progress View learning progress + tutor --reset [package] Reset progress +""" + +import argparse +import sys + +from cortex.tutor import __version__ +from cortex.tutor.branding import ( + console, + get_user_input, + print_banner, + print_error_panel, + print_progress_summary, + print_success_panel, + print_table, + tutor_print, +) +from cortex.tutor.config import DEFAULT_TUTOR_TOPICS, Config +from cortex.tutor.memory.sqlite_store import SQLiteStore +from cortex.tutor.tools.deterministic.validators import validate_package_name + + +def create_parser() -> argparse.ArgumentParser: + """ + Create the argument parser for the CLI. + + Returns: + Configured ArgumentParser. + """ + parser = argparse.ArgumentParser( + prog="tutor", + description="AI-Powered Installation Tutor for Cortex Linux", + epilog="Example: tutor docker", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # Version + parser.add_argument( + "-V", + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + + # Verbose mode + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + + # Package name (positional, optional) + parser.add_argument( + "package", + nargs="?", + help="Package name to learn about", + ) + + # List packages studied + parser.add_argument( + "--list", + action="store_true", + help="List packages you've studied", + ) + + # Progress + parser.add_argument( + "--progress", + action="store_true", + help="View learning progress", + ) + + # Reset progress + parser.add_argument( + "--reset", + nargs="?", + const="__all__", + help="Reset progress (optionally for a specific package)", + ) + + # Force fresh (no cache) + parser.add_argument( + "--fresh", + action="store_true", + help="Skip cache and generate fresh content", + ) + + # Quick question mode + parser.add_argument( + "-q", + "--question", + type=str, + help="Ask a quick question about the package", + ) + + return parser + + +def cmd_teach(package: str, verbose: bool = False, fresh: bool = False) -> int: + """ + Start an interactive tutoring session. + + Args: + package: Package name to teach. + verbose: Enable verbose output. + fresh: Skip cache. + + Returns: + Exit code (0 for success, 1 for failure). + """ + # Validate package name + is_valid, error = validate_package_name(package) + if not is_valid: + print_error_panel(f"Invalid package name: {error}") + return 1 + + try: + # Lazy import - only load when needed (requires API key) + from cortex.tutor.agents.tutor_agent import InteractiveTutor + + # Start interactive tutor + interactive = InteractiveTutor(package, force_fresh=fresh) + interactive.start() + return 0 + + except ValueError as e: + print_error_panel(str(e)) + return 1 + except KeyboardInterrupt: + console.print() + tutor_print("Session ended", "info") + return 0 + except Exception as e: + print_error_panel(f"An error occurred: {e}") + if verbose: + import traceback + + console.print_exception() + return 1 + + +def cmd_question(package: str, question: str, verbose: bool = False) -> int: + """ + Answer a quick question about a package. + + Args: + package: Package context. + question: The question. + verbose: Enable verbose output. + + Returns: + Exit code. + """ + # Validate inputs + is_valid, error = validate_package_name(package) + if not is_valid: + print_error_panel(f"Invalid package name: {error}") + return 1 + + try: + # Lazy import - only load when needed (requires API key) + from cortex.tutor.agents.tutor_agent import TutorAgent + + agent = TutorAgent(verbose=verbose) + result = agent.ask(package, question) + + if result.get("validation_passed"): + content = result.get("content", {}) + + # Print answer + console.print("\n[bold cyan]Answer:[/bold cyan]") + console.print(content.get("answer", "No answer available")) + + # Print code example if available + if content.get("code_example"): + from cortex.tutor.branding import print_code_example + + ex = content["code_example"] + print_code_example( + ex.get("code", ""), + ex.get("language", "bash"), + "Example", + ) + + # Print related topics + related = content.get("related_topics", []) + if related: + console.print(f"\n[dim]Related topics: {', '.join(related)}[/dim]") + + return 0 + else: + print_error_panel("Could not answer the question") + return 1 + + except Exception as e: + print_error_panel(f"Error: {e}") + return 1 + + +def cmd_list_packages(_verbose: bool = False) -> int: + """ + List packages that have been studied. + + Args: + _verbose: Enable verbose output (reserved for future use). + + Returns: + Exit code. + """ + try: + # Use SQLiteStore directly - no API key needed + config = Config.from_env(require_api_key=False) + store = SQLiteStore(config.get_db_path()) + packages = store.get_packages_studied() + + if not packages: + tutor_print("You haven't studied any packages yet.", "info") + tutor_print("Try: cortex tutor docker", "info") + return 0 + + console.print("\n[bold]Packages Studied:[/bold]") + for pkg in packages: + console.print(f" \u2022 {pkg}") + + console.print(f"\n[dim]Total: {len(packages)} packages[/dim]") + return 0 + + except Exception as e: + print_error_panel(f"Error: {e}") + return 1 + + +def _show_package_progress(store: SQLiteStore, package: str) -> None: + """Display progress for a specific package.""" + stats = store.get_completion_stats(package) + if stats: + print_progress_summary( + stats.get("completed", 0), + stats.get("total", 0) or DEFAULT_TUTOR_TOPICS, + package, + ) + console.print(f"[dim]Average score: {stats.get('avg_score', 0):.0%}[/dim]") + console.print(f"[dim]Total time: {stats.get('total_time_seconds', 0) // 60} minutes[/dim]") + else: + tutor_print(f"No progress found for {package}", "info") + + +def _show_all_progress(store: SQLiteStore) -> bool: + """Display progress for all packages. Returns True if progress exists.""" + progress_list = store.get_all_progress() + + if not progress_list: + tutor_print("No learning progress yet.", "info") + return False + + # Group by package (progress_list contains Pydantic models) + by_package: dict[str, list] = {} + for p in progress_list: + pkg = p.package_name + if pkg not in by_package: + by_package[pkg] = [] + by_package[pkg].append(p) + + # Build table rows + rows = [] + for pkg, topics in by_package.items(): + completed = sum(1 for t in topics if t.completed) + total = len(topics) + avg_score = sum(t.score for t in topics) / total if total else 0 + rows.append([pkg, f"{completed}/{total}", f"{avg_score:.0%}"]) + + print_table(["Package", "Progress", "Avg Score"], rows, "Learning Progress") + return True + + +def cmd_progress(package: str | None = None, _verbose: bool = False) -> int: + """ + Show learning progress. + + Args: + package: Optional package filter. + _verbose: Enable verbose output (reserved for future use). + + Returns: + Exit code. + """ + try: + config = Config.from_env(require_api_key=False) + store = SQLiteStore(config.get_db_path()) + + if package: + _show_package_progress(store, package) + else: + _show_all_progress(store) + + return 0 + + except Exception as e: + print_error_panel(f"Error: {e}") + return 1 + + +def cmd_reset(package: str | None = None, _verbose: bool = False) -> int: + """ + Reset learning progress. + + Args: + package: Optional package to reset. If None, resets all. + _verbose: Enable verbose output (reserved for future use). + + Returns: + Exit code. + """ + try: + # Confirm reset + scope = package if package and package != "__all__" else "all packages" + + confirm = get_user_input(f"Reset progress for {scope}? (y/N)") + if confirm.lower() != "y": + tutor_print("Reset cancelled", "info") + return 0 + + # Use SQLiteStore directly - no API key needed + config = Config.from_env(require_api_key=False) + store = SQLiteStore(config.get_db_path()) + + pkg = package if package != "__all__" else None + count = store.reset_progress(pkg) + + print_success_panel(f"Reset {count} progress records") + return 0 + + except Exception as e: + print_error_panel(f"Error: {e}") + return 1 + + +def main(args: list[str] | None = None) -> int: + """ + Main entry point for the CLI. + + Args: + args: Command line arguments (uses sys.argv if None). + + Returns: + Exit code. + """ + parser = create_parser() + parsed = parser.parse_args(args) + + # Print banner for interactive mode + if parsed.package and not parsed.question: + print_banner() + + # Handle commands + if parsed.list: + return cmd_list_packages(parsed.verbose) + + if parsed.progress: + return cmd_progress(parsed.package, parsed.verbose) + + if parsed.reset: + return cmd_reset( + parsed.reset if parsed.reset != "__all__" else None, + parsed.verbose, + ) + + if parsed.package and parsed.question: + return cmd_question(parsed.package, parsed.question, parsed.verbose) + + if parsed.package: + return cmd_teach(parsed.package, parsed.verbose, parsed.fresh) + + # No command specified - show help + parser.print_help() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cortex/tutor/config.py b/cortex/tutor/config.py new file mode 100644 index 00000000..4dd87104 --- /dev/null +++ b/cortex/tutor/config.py @@ -0,0 +1,149 @@ +""" +Configuration management for Intelligent Tutor. + +Handles API keys, settings, and environment variables securely. +""" + +import os +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv +from pydantic import BaseModel, Field, field_validator + +# Load environment variables from .env file +load_dotenv() + +# Default number of topics for progress tracking +DEFAULT_TUTOR_TOPICS = 5 + + +class Config(BaseModel): + """ + Configuration settings for Intelligent Tutor. + + Attributes: + anthropic_api_key: Anthropic API key for Claude access. + openai_api_key: Optional OpenAI API key for fallback. + model: LLM model to use for tutoring. + data_dir: Directory for storing tutor data. + debug: Enable debug mode for verbose logging. + """ + + anthropic_api_key: str | None = Field( + default=None, description="Anthropic API key for Claude access" + ) + openai_api_key: str | None = Field( + default=None, description="Optional OpenAI API key for fallback" + ) + model: str = Field( + default="claude-sonnet-4-20250514", description="LLM model to use for tutoring" + ) + data_dir: Path = Field( + default=Path.home() / ".cortex", description="Directory for storing tutor data" + ) + debug: bool = Field(default=False, description="Enable debug mode for verbose logging") + db_path: Path | None = Field(default=None, description="Path to SQLite database") + + def model_post_init(self, __context) -> None: + """Initialize computed fields after model creation.""" + if self.db_path is None: + self.db_path = self.data_dir / "tutor_progress.db" + + @field_validator("data_dir", mode="before") + @classmethod + def expand_data_dir(cls, v: str | Path) -> Path: + """Expand user home directory in path.""" + if isinstance(v, str): + return Path(v).expanduser() + return v.expanduser() + + @classmethod + def from_env(cls, require_api_key: bool = True) -> "Config": + """ + Create configuration from environment variables. + + Args: + require_api_key: If True, raises error when API key missing. + + Returns: + Config: Configuration instance with values from environment. + + Raises: + ValueError: If required API key is not set and require_api_key=True. + """ + api_key = os.getenv("ANTHROPIC_API_KEY") + if not api_key and require_api_key: + raise ValueError( + "ANTHROPIC_API_KEY environment variable is required. " + "Set it in your environment or create a .env file." + ) + + data_dir_str = os.getenv("TUTOR_DATA_DIR", str(Path.home() / ".cortex")) + data_dir = Path(data_dir_str).expanduser() + + return cls( + anthropic_api_key=api_key, + openai_api_key=os.getenv("OPENAI_API_KEY"), + model=os.getenv("TUTOR_MODEL", "claude-sonnet-4-20250514"), + data_dir=data_dir, + debug=os.getenv("TUTOR_DEBUG", "false").lower() == "true", + ) + + def ensure_data_dir(self) -> None: + """ + Ensure the data directory exists with proper permissions. + + Creates the directory if it doesn't exist with 0o700 permissions + for security (owner read/write/execute only). + """ + if not self.data_dir.exists(): + self.data_dir.mkdir(parents=True, mode=0o700) + + def get_db_path(self) -> Path: + """ + Get the full path to the SQLite database. + + Returns: + Path: Full path to tutor_progress.db + """ + self.ensure_data_dir() + return self.db_path + + def validate_api_key(self) -> bool: + """ + Validate that the API key is properly configured. + + Returns: + bool: True if API key is valid format, False otherwise. + """ + if not self.anthropic_api_key: + return False + # Anthropic API keys start with 'sk-ant-' + return self.anthropic_api_key.startswith("sk-ant-") + + +# Global configuration instance (lazy loaded) +_config: Config | None = None + + +def get_config() -> Config: + """ + Get the global configuration instance. + + Returns: + Config: Global configuration singleton. + + Raises: + ValueError: If configuration cannot be loaded. + """ + global _config + if _config is None: + _config = Config.from_env() + return _config + + +def reset_config() -> None: + """Reset the global configuration (useful for testing).""" + global _config + _config = None diff --git a/cortex/tutor/contracts/__init__.py b/cortex/tutor/contracts/__init__.py new file mode 100644 index 00000000..af33c658 --- /dev/null +++ b/cortex/tutor/contracts/__init__.py @@ -0,0 +1,10 @@ +""" +Pydantic contracts for Intelligent Tutor. + +Provides typed output schemas for all agent operations. +""" + +from cortex.tutor.contracts.lesson_context import LessonContext +from cortex.tutor.contracts.progress_context import ProgressContext + +__all__ = ["LessonContext", "ProgressContext"] diff --git a/cortex/tutor/contracts/lesson_context.py b/cortex/tutor/contracts/lesson_context.py new file mode 100644 index 00000000..ba541bb6 --- /dev/null +++ b/cortex/tutor/contracts/lesson_context.py @@ -0,0 +1,178 @@ +""" +Lesson Context - Pydantic contract for lesson generation output. + +Defines the structured output schema for lesson content. +""" + +from datetime import datetime, timezone +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class CodeExample(BaseModel): + """A code example with description.""" + + title: str = Field(..., description="Title of the code example") + code: str = Field(..., description="The actual code snippet") + language: str = Field( + default="bash", description="Programming language for syntax highlighting" + ) + description: str = Field(..., description="Explanation of what the code does") + + +class TutorialStep(BaseModel): + """A step in a tutorial sequence.""" + + step_number: int = Field(..., ge=1, description="Step number in sequence") + title: str = Field(..., description="Brief title for this step") + content: str = Field(..., description="Detailed instruction for this step") + code: str | None = Field(default=None, description="Optional code for this step") + expected_output: str | None = Field( + default=None, description="Expected output if code is executed" + ) + + +class LessonContext(BaseModel): + """ + Output contract for lesson generation. + + Contains all the content generated for a package lesson including + explanations, best practices, code examples, and tutorials. + """ + + # Core content + package_name: str = Field(..., description="Name of the package being taught") + summary: str = Field( + ..., + description="Brief 1-2 sentence summary of what the package does", + max_length=500, + ) + explanation: str = Field( + ..., + description="Detailed explanation of the package functionality", + max_length=5000, + ) + use_cases: list[str] = Field( + default_factory=list, + description="Common use cases for this package", + max_length=10, + ) + best_practices: list[str] = Field( + default_factory=list, + description="Best practices when using this package", + max_length=10, + ) + code_examples: list[CodeExample] = Field( + default_factory=list, + description="Code examples demonstrating package usage", + max_length=5, + ) + tutorial_steps: list[TutorialStep] = Field( + default_factory=list, + description="Step-by-step tutorial for hands-on learning", + max_length=10, + ) + + # Package metadata + installation_command: str = Field( + ..., description="Command to install the package (apt, pip, etc.)" + ) + official_docs_url: str | None = Field(default=None, description="URL to official documentation") + related_packages: list[str] = Field( + default_factory=list, + description="Related packages the user might want to learn", + max_length=5, + ) + + # Metadata + confidence: float = Field( + ..., + description="Confidence score (0-1) based on knowledge quality", + ge=0.0, + le=1.0, + ) + cached: bool = Field(default=False, description="Whether result came from cache") + cost_gbp: float = Field(default=0.0, description="Cost for LLM calls", ge=0.0) + generated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="Timestamp of generation (UTC)", + ) + + def to_json(self) -> str: + """Serialize to JSON for caching.""" + return self.model_dump_json() + + @classmethod + def from_json(cls, json_str: str) -> "LessonContext": + """Deserialize from JSON cache.""" + return cls.model_validate_json(json_str) + + def get_total_steps(self) -> int: + """Get total number of tutorial steps.""" + return len(self.tutorial_steps) + + def get_practice_count(self) -> int: + """Get count of best practices.""" + return len(self.best_practices) + + def to_display_dict(self) -> dict[str, Any]: + """Convert to dictionary for display purposes.""" + return { + "package": self.package_name, + "summary": self.summary, + "explanation": self.explanation, + "use_cases": self.use_cases, + "best_practices": self.best_practices, + "examples_count": len(self.code_examples), + "tutorial_steps_count": len(self.tutorial_steps), + "installation": self.installation_command, + "confidence": f"{self.confidence:.0%}", + } + + +class LessonPlanOutput(BaseModel): + """Output from the PLAN phase for lesson generation.""" + + strategy: Literal["use_cache", "generate_full", "generate_quick"] = Field( + ..., description="Strategy chosen for lesson generation" + ) + cached_data: dict[str, Any] | None = Field( + default=None, description="Cached lesson data if strategy is use_cache" + ) + estimated_cost: float = Field( + default=0.0, description="Estimated cost for this strategy", ge=0.0 + ) + reasoning: str = Field(..., description="Explanation for why this strategy was chosen") + + +class LessonReflectionOutput(BaseModel): + """Output from the REFLECT phase for lesson validation.""" + + confidence: float = Field( + ..., + description="Overall confidence in the lesson quality", + ge=0.0, + le=1.0, + ) + quality_score: float = Field( + ..., + description="Quality score based on completeness and accuracy", + ge=0.0, + le=1.0, + ) + insights: list[str] = Field( + default_factory=list, + description="Key insights about the generated lesson", + ) + improvements: list[str] = Field( + default_factory=list, + description="Suggested improvements for future iterations", + ) + validation_passed: bool = Field( + ..., description="Whether the lesson passed all validation checks" + ) + validation_errors: list[str] = Field( + default_factory=list, + description="List of validation errors if any", + ) diff --git a/cortex/tutor/contracts/progress_context.py b/cortex/tutor/contracts/progress_context.py new file mode 100644 index 00000000..855da9c3 --- /dev/null +++ b/cortex/tutor/contracts/progress_context.py @@ -0,0 +1,186 @@ +""" +Progress Context - Pydantic contract for learning progress output. + +Defines the structured output schema for progress tracking. +""" + +from datetime import datetime, timezone +from typing import Any + +from pydantic import BaseModel, Field, computed_field + + +class TopicProgress(BaseModel): + """Progress for a single topic within a package.""" + + topic: str = Field(..., description="Name of the topic") + completed: bool = Field(default=False, description="Whether topic is completed") + score: float = Field(default=0.0, description="Score achieved (0-1)", ge=0.0, le=1.0) + time_spent_seconds: int = Field(default=0, description="Time spent on topic", ge=0) + last_accessed: datetime | None = Field(default=None, description="Last access time") + + +class PackageProgress(BaseModel): + """Progress for a complete package.""" + + package_name: str = Field(..., description="Name of the package") + topics: list[TopicProgress] = Field(default_factory=list, description="Progress per topic") + started_at: datetime | None = Field(default=None, description="When learning started") + last_session: datetime | None = Field(default=None, description="Last learning session") + + @computed_field + @property + def completion_percentage(self) -> float: + """Calculate overall completion percentage.""" + if not self.topics: + return 0.0 + completed = sum(1 for t in self.topics if t.completed) + return (completed / len(self.topics)) * 100 + + @computed_field + @property + def average_score(self) -> float: + """Calculate average score across topics.""" + if not self.topics: + return 0.0 + return sum(t.score for t in self.topics) / len(self.topics) + + @computed_field + @property + def total_time_seconds(self) -> int: + """Calculate total time spent.""" + return sum(t.time_spent_seconds for t in self.topics) + + def get_next_topic(self) -> str | None: + """Get the next uncompleted topic.""" + for topic in self.topics: + if not topic.completed: + return topic.topic + return None + + def is_complete(self) -> bool: + """Check if all topics are completed.""" + return all(t.completed for t in self.topics) if self.topics else False + + +class ProgressContext(BaseModel): + """ + Output contract for progress tracking operations. + + Contains comprehensive learning progress data. + """ + + # Student info + student_id: str = Field(default="default", description="Unique student identifier") + learning_style: str = Field( + default="reading", + description="Preferred learning style: visual, reading, hands-on", + ) + + # Progress data + packages: list[PackageProgress] = Field( + default_factory=list, description="Progress for each package" + ) + mastered_concepts: list[str] = Field( + default_factory=list, description="Concepts the student has mastered" + ) + weak_concepts: list[str] = Field( + default_factory=list, description="Concepts the student struggles with" + ) + + # Statistics + total_packages_started: int = Field(default=0, description="Number of packages started") + total_packages_completed: int = Field(default=0, description="Number of packages completed") + total_time_learning_seconds: int = Field(default=0, description="Total learning time", ge=0) + streak_days: int = Field(default=0, description="Current learning streak", ge=0) + + # Metadata + last_updated: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="Last update timestamp", + ) + + def get_package_progress(self, package_name: str) -> PackageProgress | None: + """Get progress for a specific package.""" + for pkg in self.packages: + if pkg.package_name == package_name: + return pkg + return None + + def get_overall_completion(self) -> float: + """Calculate overall completion percentage across all packages.""" + if not self.packages: + return 0.0 + return sum(p.completion_percentage for p in self.packages) / len(self.packages) + + def get_recommendations(self) -> list[str]: + """Get learning recommendations based on progress.""" + recommendations = [] + + # Recommend reviewing weak concepts + if self.weak_concepts: + recommendations.append(f"Review these concepts: {', '.join(self.weak_concepts[:3])}") + + # Recommend continuing incomplete packages + for pkg in self.packages: + if not pkg.is_complete(): + next_topic = pkg.get_next_topic() + if next_topic: + recommendations.append(f"Continue learning {pkg.package_name}: {next_topic}") + break + + return recommendations + + def to_summary_dict(self) -> dict[str, Any]: + """Create a summary dictionary for display.""" + return { + "packages_started": self.total_packages_started, + "packages_completed": self.total_packages_completed, + "overall_completion": f"{self.get_overall_completion():.1f}%", + "total_time_hours": round(self.total_time_learning_seconds / 3600, 1), + "streak_days": self.streak_days, + "mastered_count": len(self.mastered_concepts), + "weak_count": len(self.weak_concepts), + } + + +class QuizContext(BaseModel): + """Output contract for quiz/assessment results.""" + + package_name: str = Field(..., description="Package the quiz is about") + questions_total: int = Field(..., description="Total number of questions", ge=1) + questions_correct: int = Field(..., description="Number of correct answers", ge=0) + score_percentage: float = Field(..., description="Score as percentage", ge=0.0, le=100.0) + passed: bool = Field(..., description="Whether the quiz was passed (>=70%)") + feedback: str = Field(..., description="Feedback on quiz performance") + weak_areas: list[str] = Field(default_factory=list, description="Areas that need improvement") + strong_areas: list[str] = Field(default_factory=list, description="Areas of strength") + timestamp: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="Quiz completion time", + ) + + @classmethod + def from_results( + cls, + package_name: str, + correct: int, + total: int, + feedback: str = "", + ) -> "QuizContext": + """Create QuizContext from raw results.""" + if total < 1: + raise ValueError("total must be at least 1") + if correct < 0: + raise ValueError("correct must be non-negative") + if correct > total: + raise ValueError("correct cannot exceed total") + score = (correct / total) * 100 + return cls( + package_name=package_name, + questions_total=total, + questions_correct=correct, + score_percentage=score, + passed=score >= 70, + feedback=feedback or f"You scored {score:.0f}%", + ) diff --git a/cortex/tutor/llm.py b/cortex/tutor/llm.py new file mode 100644 index 00000000..87b1f25c --- /dev/null +++ b/cortex/tutor/llm.py @@ -0,0 +1,136 @@ +""" +LLM functions for the Intelligent Tutor. + +Uses cortex.llm_router for LLM calls. +""" + +import json +import logging + +from cortex.llm_router import LLMRouter, TaskType + +# Suppress verbose logging from llm_router +logging.getLogger("cortex.llm_router").setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) +_router: LLMRouter | None = None + + +def get_router() -> LLMRouter: + """Get or create the LLM router instance.""" + global _router + if _router is None: + _router = LLMRouter() + return _router + + +def _parse_json_response(content: str) -> dict: + """Parse JSON from LLM response, handling markdown fences.""" + content = content.strip() + if content.startswith("```"): + content = content.split("```")[1] + if content.startswith("json"): + content = content[4:] + content = content.strip() + return json.loads(content) + + +def generate_lesson( + package_name: str, + student_level: str = "beginner", + learning_style: str = "reading", + skip_areas: list[str] | None = None, +) -> dict: + """Generate a lesson for a package.""" + router = get_router() + + system_prompt = """You are an expert technical educator. +Generate a comprehensive lesson about a software package. +Return valid JSON only, no markdown fences.""" + + user_prompt = f"""Generate a lesson for: {package_name} + +Student level: {student_level} +Learning style: {learning_style} +Skip topics: {', '.join(skip_areas or []) or 'none'} + +Return JSON: +{{ + "summary": "1-2 sentence overview", + "explanation": "What the package does and why it's useful", + "use_cases": ["use case 1", "use case 2"], + "best_practices": ["practice 1", "practice 2"], + "code_examples": [ + {{"title": "Example", "code": "code here", "language": "bash", "description": "what it does"}} + ], + "tutorial_steps": [ + {{"step_number": 1, "title": "Step", "content": "instruction", "code": "optional"}} + ], + "installation_command": "apt install {package_name}", + "related_packages": ["related1"] +}}""" + + try: + response = router.complete( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + task_type=TaskType.CODE_GENERATION, + temperature=0.3, + max_tokens=4096, + ) + lesson = _parse_json_response(response.content) + return {"success": True, "lesson": lesson, "cost_usd": response.cost_usd} + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse lesson JSON: {e}") + return {"success": False, "error": str(e), "lesson": None} + except Exception as e: + logger.error(f"Failed to generate lesson: {e}") + return {"success": False, "error": str(e), "lesson": None} + + +def answer_question( + package_name: str, + question: str, + context: str | None = None, +) -> dict: + """Answer a question about a package.""" + router = get_router() + + system_prompt = """You are a helpful technical assistant. +Answer questions about software packages clearly and accurately. +Return valid JSON only, no markdown fences.""" + + user_prompt = f"""Package: {package_name} +Question: {question} +{f'Context: {context}' if context else ''} + +Return JSON: +{{ + "answer": "your detailed answer", + "code_example": {{"code": "example if relevant", "language": "bash"}} or null, + "related_topics": ["topic1", "topic2"], + "confidence": 0.9 +}}""" + + try: + response = router.complete( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + task_type=TaskType.USER_CHAT, + temperature=0.5, + max_tokens=2048, + ) + answer = _parse_json_response(response.content) + return {"success": True, "answer": answer, "cost_usd": response.cost_usd} + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse answer JSON: {e}") + return {"success": False, "error": str(e), "answer": None} + except Exception as e: + logger.error(f"Failed to answer question: {e}") + return {"success": False, "error": str(e), "answer": None} diff --git a/cortex/tutor/memory/__init__.py b/cortex/tutor/memory/__init__.py new file mode 100644 index 00000000..9d22829c --- /dev/null +++ b/cortex/tutor/memory/__init__.py @@ -0,0 +1,9 @@ +""" +Memory and persistence layer for Intelligent Tutor. + +Provides SQLite storage for learning progress and caching. +""" + +from cortex.tutor.memory.sqlite_store import SQLiteStore + +__all__ = ["SQLiteStore"] diff --git a/cortex/tutor/memory/sqlite_store.py b/cortex/tutor/memory/sqlite_store.py new file mode 100644 index 00000000..9e7bfa0e --- /dev/null +++ b/cortex/tutor/memory/sqlite_store.py @@ -0,0 +1,595 @@ +""" +SQLite storage for Intelligent Tutor learning progress. + +Provides persistence for learning progress, quiz results, and student profiles. +""" + +import json +import sqlite3 +from collections.abc import Generator +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from threading import RLock +from typing import Any + +from pydantic import BaseModel + + +class LearningProgress(BaseModel): + """Model for learning progress records.""" + + id: int | None = None + package_name: str + topic: str + completed: bool = False + score: float = 0.0 + last_accessed: str | None = None + total_time_seconds: int = 0 + + +class QuizResult(BaseModel): + """Model for quiz result records.""" + + id: int | None = None + package_name: str + question: str + user_answer: str | None = None + correct: bool = False + timestamp: str | None = None + + +class StudentProfile(BaseModel): + """Model for student profile.""" + + id: int | None = None + mastered_concepts: list[str] = [] + weak_concepts: list[str] = [] + learning_style: str = "reading" # visual, reading, hands-on + last_session: str | None = None + + +class SQLiteStore: + """ + SQLite-based storage for learning progress and student data. + + Thread-safe implementation with connection pooling. + + Attributes: + db_path: Path to the SQLite database file. + """ + + # SQL schema for database initialization + SCHEMA = """ + CREATE TABLE IF NOT EXISTS learning_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_name TEXT NOT NULL, + topic TEXT NOT NULL, + completed BOOLEAN DEFAULT FALSE, + score REAL DEFAULT 0.0, + last_accessed TEXT, + total_time_seconds INTEGER DEFAULT 0, + UNIQUE(package_name, topic) + ); + + CREATE TABLE IF NOT EXISTS quiz_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_name TEXT NOT NULL, + question TEXT NOT NULL, + user_answer TEXT, + correct BOOLEAN DEFAULT FALSE, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS student_profile ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mastered_concepts TEXT DEFAULT '[]', + weak_concepts TEXT DEFAULT '[]', + learning_style TEXT DEFAULT 'reading', + last_session TEXT + ); + + CREATE TABLE IF NOT EXISTS lesson_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_name TEXT NOT NULL UNIQUE, + content TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_progress_package ON learning_progress(package_name); + CREATE INDEX IF NOT EXISTS idx_quiz_package ON quiz_results(package_name); + CREATE INDEX IF NOT EXISTS idx_cache_package ON lesson_cache(package_name); + """ + + # SQL query constants to avoid duplication + _SELECT_PROFILE = "SELECT * FROM student_profile LIMIT 1" + + def __init__(self, db_path: Path) -> None: + """ + Initialize SQLite store. + + Args: + db_path: Path to the SQLite database file. + """ + self.db_path = db_path + self._lock = RLock() # Re-entrant lock to allow nested calls + self._init_database() + + def _init_database(self) -> None: + """Initialize the database schema.""" + # Ensure parent directory exists + self.db_path.parent.mkdir(parents=True, exist_ok=True) + + with self._get_connection() as conn: + conn.executescript(self.SCHEMA) + conn.commit() + + @contextmanager + def _get_connection(self) -> Generator[sqlite3.Connection, None, None]: + """ + Get a thread-safe database connection. + + Yields: + sqlite3.Connection: Database connection. + """ + with self._lock: + conn = sqlite3.connect(str(self.db_path)) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + # ==================== Learning Progress Methods ==================== + + def get_progress(self, package_name: str, topic: str) -> LearningProgress | None: + """ + Get learning progress for a specific package and topic. + + Args: + package_name: Name of the package. + topic: Topic within the package. + + Returns: + LearningProgress or None if not found. + """ + with self._get_connection() as conn: + cursor = conn.execute( + "SELECT * FROM learning_progress WHERE package_name = ? AND topic = ?", + (package_name, topic), + ) + row = cursor.fetchone() + if row: + return LearningProgress( + id=row["id"], + package_name=row["package_name"], + topic=row["topic"], + completed=bool(row["completed"]), + score=row["score"], + last_accessed=row["last_accessed"], + total_time_seconds=row["total_time_seconds"], + ) + return None + + def get_all_progress(self, package_name: str | None = None) -> list[LearningProgress]: + """ + Get all learning progress records. + + Args: + package_name: Optional filter by package name. + + Returns: + List of LearningProgress records. + """ + with self._get_connection() as conn: + if package_name: + cursor = conn.execute( + "SELECT * FROM learning_progress WHERE package_name = ? ORDER BY topic", + (package_name,), + ) + else: + cursor = conn.execute( + "SELECT * FROM learning_progress ORDER BY package_name, topic" + ) + + return [ + LearningProgress( + id=row["id"], + package_name=row["package_name"], + topic=row["topic"], + completed=bool(row["completed"]), + score=row["score"], + last_accessed=row["last_accessed"], + total_time_seconds=row["total_time_seconds"], + ) + for row in cursor.fetchall() + ] + + def upsert_progress(self, progress: LearningProgress) -> int: + """ + Insert or update learning progress. + + Args: + progress: LearningProgress record to save. + + Returns: + Row ID of the inserted/updated record. + """ + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + cursor = conn.execute( + """ + INSERT INTO learning_progress + (package_name, topic, completed, score, last_accessed, total_time_seconds) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(package_name, topic) DO UPDATE SET + completed = excluded.completed, + score = excluded.score, + last_accessed = excluded.last_accessed, + total_time_seconds = excluded.total_time_seconds + """, + ( + progress.package_name, + progress.topic, + progress.completed, + progress.score, + now, + progress.total_time_seconds, + ), + ) + conn.commit() + # lastrowid returns 0 on UPDATE, so fetch the actual ID + if cursor.lastrowid == 0: + id_cursor = conn.execute( + "SELECT id FROM learning_progress WHERE package_name = ? AND topic = ?", + (progress.package_name, progress.topic), + ) + row = id_cursor.fetchone() + return row["id"] if row else 0 + return cursor.lastrowid + + def mark_topic_completed(self, package_name: str, topic: str, score: float = 1.0) -> None: + """ + Mark a topic as completed. + + Args: + package_name: Name of the package. + topic: Topic to mark as completed. + score: Score achieved (0.0 to 1.0). + """ + progress = LearningProgress( + package_name=package_name, + topic=topic, + completed=True, + score=score, + ) + self.upsert_progress(progress) + + def get_completion_stats(self, package_name: str) -> dict[str, Any]: + """ + Get completion statistics for a package. + + Args: + package_name: Name of the package. + + Returns: + Dict with completion statistics. + """ + with self._get_connection() as conn: + cursor = conn.execute( + """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN completed THEN 1 ELSE 0 END) as completed, + AVG(score) as avg_score, + SUM(total_time_seconds) as total_time + FROM learning_progress + WHERE package_name = ? + """, + (package_name,), + ) + row = cursor.fetchone() + return { + "total": row["total"] or 0, + "completed": row["completed"] or 0, + "avg_score": row["avg_score"] or 0.0, + "total_time_seconds": row["total_time"] or 0, + } + + # ==================== Quiz Results Methods ==================== + + def add_quiz_result(self, result: QuizResult) -> int: + """ + Add a quiz result. + + Args: + result: QuizResult to save. + + Returns: + Row ID of the inserted record. + """ + with self._get_connection() as conn: + cursor = conn.execute( + """ + INSERT INTO quiz_results (package_name, question, user_answer, correct) + VALUES (?, ?, ?, ?) + """, + (result.package_name, result.question, result.user_answer, result.correct), + ) + conn.commit() + return cursor.lastrowid + + def get_quiz_results(self, package_name: str) -> list[QuizResult]: + """ + Get quiz results for a package. + + Args: + package_name: Name of the package. + + Returns: + List of QuizResult records. + """ + with self._get_connection() as conn: + cursor = conn.execute( + "SELECT * FROM quiz_results WHERE package_name = ? ORDER BY timestamp DESC", + (package_name,), + ) + return [ + QuizResult( + id=row["id"], + package_name=row["package_name"], + question=row["question"], + user_answer=row["user_answer"], + correct=bool(row["correct"]), + timestamp=row["timestamp"], + ) + for row in cursor.fetchall() + ] + + # ==================== Student Profile Methods ==================== + + def get_student_profile(self) -> StudentProfile: + """ + Get the student profile (singleton). + + Returns: + StudentProfile record. + """ + with self._get_connection() as conn: + cursor = conn.execute(self._SELECT_PROFILE) + row = cursor.fetchone() + if row: + return StudentProfile( + id=row["id"], + mastered_concepts=json.loads(row["mastered_concepts"]), + weak_concepts=json.loads(row["weak_concepts"]), + learning_style=row["learning_style"], + last_session=row["last_session"], + ) + # Create default profile if not exists + return self._create_default_profile() + + def _create_default_profile(self) -> StudentProfile: + """Create and return a default student profile (thread-safe).""" + profile = StudentProfile() + with self._get_connection() as conn: + # Use INSERT OR IGNORE to handle race conditions + conn.execute( + """ + INSERT OR IGNORE INTO student_profile (mastered_concepts, weak_concepts, learning_style) + VALUES (?, ?, ?) + """, + ( + json.dumps(profile.mastered_concepts), + json.dumps(profile.weak_concepts), + profile.learning_style, + ), + ) + conn.commit() + # Re-fetch to return actual profile (in case another thread created it) + cursor = conn.execute(self._SELECT_PROFILE) + row = cursor.fetchone() + if row: + return StudentProfile( + id=row["id"], + mastered_concepts=json.loads(row["mastered_concepts"]), + weak_concepts=json.loads(row["weak_concepts"]), + learning_style=row["learning_style"], + last_session=row["last_session"], + ) + return profile + + def update_student_profile(self, profile: StudentProfile) -> None: + """ + Update the student profile. + + Args: + profile: StudentProfile to save. + """ + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + conn.execute( + """ + UPDATE student_profile SET + mastered_concepts = ?, + weak_concepts = ?, + learning_style = ?, + last_session = ? + WHERE id = (SELECT id FROM student_profile LIMIT 1) + """, + ( + json.dumps(profile.mastered_concepts), + json.dumps(profile.weak_concepts), + profile.learning_style, + now, + ), + ) + conn.commit() + + def add_mastered_concept(self, concept: str) -> None: + """ + Add a mastered concept to the student profile (atomic operation). + + Args: + concept: Concept that was mastered. + """ + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + # Atomic read-modify-write within single connection + cursor = conn.execute(self._SELECT_PROFILE) + row = cursor.fetchone() + if not row: + self._create_default_profile() + cursor = conn.execute(self._SELECT_PROFILE) + row = cursor.fetchone() + + mastered = json.loads(row["mastered_concepts"]) + weak = json.loads(row["weak_concepts"]) + + if concept not in mastered: + mastered.append(concept) + # Remove from weak concepts if present + if concept in weak: + weak.remove(concept) + + conn.execute( + """UPDATE student_profile SET + mastered_concepts = ?, + weak_concepts = ?, + last_session = ? + WHERE id = ?""", + (json.dumps(mastered), json.dumps(weak), now, row["id"]), + ) + conn.commit() + + def add_weak_concept(self, concept: str) -> None: + """ + Add a weak concept to the student profile (atomic operation). + + Args: + concept: Concept the student struggles with. + """ + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + # Atomic read-modify-write within single connection + cursor = conn.execute(self._SELECT_PROFILE) + row = cursor.fetchone() + if not row: + self._create_default_profile() + cursor = conn.execute(self._SELECT_PROFILE) + row = cursor.fetchone() + + mastered = json.loads(row["mastered_concepts"]) + weak = json.loads(row["weak_concepts"]) + + if concept not in weak and concept not in mastered: + weak.append(concept) + conn.execute( + """UPDATE student_profile SET + weak_concepts = ?, + last_session = ? + WHERE id = ?""", + (json.dumps(weak), now, row["id"]), + ) + conn.commit() + + # ==================== Lesson Cache Methods ==================== + + def cache_lesson(self, package_name: str, content: dict[str, Any], ttl_hours: int = 24) -> None: + """ + Cache lesson content. + + Args: + package_name: Name of the package. + content: Lesson content to cache. + ttl_hours: Time-to-live in hours. + """ + now = datetime.now(timezone.utc) + expires_at = (now + timedelta(hours=ttl_hours)).isoformat() + + with self._get_connection() as conn: + conn.execute( + """ + INSERT INTO lesson_cache (package_name, content, expires_at) + VALUES (?, ?, ?) + ON CONFLICT(package_name) DO UPDATE SET + content = excluded.content, + created_at = CURRENT_TIMESTAMP, + expires_at = excluded.expires_at + """, + (package_name, json.dumps(content), expires_at), + ) + conn.commit() + + def get_cached_lesson(self, package_name: str) -> dict[str, Any] | None: + """ + Get cached lesson content if not expired. + + Args: + package_name: Name of the package. + + Returns: + Cached lesson content or None if not found/expired. + """ + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + cursor = conn.execute( + """ + SELECT content FROM lesson_cache + WHERE package_name = ? AND expires_at > ? + """, + (package_name, now), + ) + row = cursor.fetchone() + if row: + return json.loads(row["content"]) + return None + + def clear_expired_cache(self) -> int: + """ + Clear expired cache entries. + + Returns: + Number of entries cleared. + """ + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + cursor = conn.execute("DELETE FROM lesson_cache WHERE expires_at <= ?", (now,)) + conn.commit() + return cursor.rowcount + + # ==================== Utility Methods ==================== + + def reset_progress(self, package_name: str | None = None) -> int: + """ + Reset learning progress. + + Args: + package_name: Optional filter by package. If None, resets all. + + Returns: + Number of records deleted. + """ + with self._get_connection() as conn: + if package_name: + cursor = conn.execute( + "DELETE FROM learning_progress WHERE package_name = ?", (package_name,) + ) + else: + cursor = conn.execute("DELETE FROM learning_progress") + conn.commit() + return cursor.rowcount + + def get_packages_studied(self) -> list[str]: + """ + Get list of all packages that have been studied. + + Returns: + List of unique package names. + """ + with self._get_connection() as conn: + cursor = conn.execute( + "SELECT DISTINCT package_name FROM learning_progress ORDER BY package_name" + ) + return [row["package_name"] for row in cursor.fetchall()] diff --git a/cortex/tutor/prompts/agents/tutor/system.md b/cortex/tutor/prompts/agents/tutor/system.md new file mode 100644 index 00000000..7cf99995 --- /dev/null +++ b/cortex/tutor/prompts/agents/tutor/system.md @@ -0,0 +1,302 @@ +# Intelligent Tutor Agent - System Prompt + +## Layer 1: IDENTITY + +You are an **Intelligent Tutor Agent**, an AI-powered educational assistant specialized in teaching users about software packages, tools, and best practices. You are part of the Cortex Linux ecosystem. + +**You ARE:** +- A patient, knowledgeable teacher +- An expert in explaining technical concepts clearly +- A guide for hands-on learning experiences +- A provider of practical, actionable advice + +**You are NOT:** +- A replacement for official documentation +- A system administrator that can execute commands +- An installer that modifies the user's system +- A source of absolute truth - you acknowledge uncertainty + +--- + +## Layer 2: ROLE & BOUNDARIES + +### What You CAN Do: +- Explain what packages do and how they work +- Teach best practices for using software +- Provide code examples and snippets +- Create step-by-step tutorials +- Answer questions about package functionality +- Track learning progress and adapt difficulty +- Suggest related packages to learn + +### What You CANNOT Do: +- Execute commands on the user's system +- Install or uninstall software +- Access real-time package repositories +- Guarantee 100% accuracy for all packages +- Provide security audits or vulnerability assessments +- Replace professional training or certification + +### Scope Limits: +- Focus on one package at a time +- Keep explanations concise but complete +- Limit code examples to practical demonstrations +- Stay within the realm of publicly documented features + +--- + +## Layer 3: ANTI-HALLUCINATION RULES + +**CRITICAL - NEVER violate these rules:** + +1. **NEVER invent package features** + - ❌ Do NOT claim a package has functionality it doesn't have + - ❌ Do NOT make up command flags or options + - ✅ If unsure, say "I believe this feature exists, but please verify in the official documentation" + +2. **NEVER fabricate version information** + - ❌ Do NOT specify exact version numbers unless certain + - ❌ Do NOT claim features were added in specific versions + - ✅ Use phrases like "in recent versions" or "depending on your version" + +3. **NEVER create fake documentation URLs** + - ❌ Do NOT generate URLs that might not exist + - ✅ Suggest searching for "[package name] official documentation" + +4. **NEVER claim certainty when uncertain** + - ❌ Do NOT state guesses as facts + - ✅ Use confidence indicators: "I'm confident that...", "I believe...", "You should verify..." + +5. **Ground responses to context** + - ✅ Use pre-calculated progress data provided in context + - ✅ Reference actual student profile information + - ❌ Do NOT make up student history or preferences + +--- + +## Layer 4: CONTEXT & INPUTS + +You will receive the following context in each interaction: + +``` +Package Name: {package_name} +Student Profile: + - Learning Style: {learning_style} + - Mastered Concepts: {mastered_concepts} + - Weak Concepts: {weak_concepts} +Current Progress: + - Topics Completed: {completed_topics} + - Current Score: {current_score} +User Question (if Q&A mode): {user_question} +Session Type: {session_type} # lesson, qa, quiz, tutorial +``` + +**Use this context to:** +- Adapt explanation complexity to student level +- Build on mastered concepts +- Address weak areas with extra detail +- Personalize examples to learning style + +--- + +## Layer 5: TOOLS & USAGE + +### Available Tools: + +1. **progress_tracker** (Deterministic) + - **Purpose**: Read/write learning progress + - **When to use**: Starting lessons, completing topics, checking history + - **When NOT to use**: During explanation generation + +2. **lesson_loader** (Deterministic) + - **Purpose**: Load cached lesson content + - **When to use**: When cache hit is detected in PLAN phase + - **When NOT to use**: For fresh lesson generation + +3. **lesson_generator** (Agentic) + - **Purpose**: Generate new lesson content using LLM + - **When to use**: For new packages or cache miss + - **When NOT to use**: If valid cached content exists + +4. **examples_provider** (Agentic) + - **Purpose**: Generate contextual code examples + - **When to use**: When user requests examples or during tutorials + - **When NOT to use**: For simple concept explanations + +5. **qa_handler** (Agentic) + - **Purpose**: Handle free-form Q&A + - **When to use**: When user asks questions outside lesson flow + - **When NOT to use**: For structured lesson delivery + +### Tool Decision Tree: +``` +Is it a new lesson request? +├── YES → Check cache → Hit? → Use lesson_loader +│ └── Miss? → Use lesson_generator +└── NO → Is it a question? + ├── YES → Use qa_handler + └── NO → Is it practice/examples? + ├── YES → Use examples_provider + └── NO → Use progress_tracker +``` + +--- + +## Layer 6: WORKFLOW & REASONING + +### Chain-of-Thought Process: + +For each interaction, follow this reasoning chain: + +``` +1. UNDERSTAND + - What is the user asking for? + - What package are they learning about? + - What is their current progress? + +2. CONTEXTUALIZE + - What have they already learned? + - What is their learning style? + - Are there weak areas to address? + +3. PLAN + - Which tools are needed? + - What's the most efficient approach? + - Should I use cache or generate fresh? + +4. EXECUTE + - Call appropriate tools + - Gather necessary information + - Generate response content + +5. VALIDATE + - Does the response address the request? + - Is the complexity appropriate? + - Are there any hallucination risks? + +6. DELIVER + - Present information clearly + - Include relevant examples + - Suggest next steps +``` + +### Session Type Workflows: + +**LESSON Mode:** +1. Check for cached lesson +2. Generate/retrieve lesson content +3. Present overview and summary +4. Offer menu: concepts, examples, tutorial, quiz + +**Q&A Mode:** +1. Parse the question intent +2. Check if answer requires package knowledge +3. Generate contextual response +4. Offer related topics + +**TUTORIAL Mode:** +1. Load tutorial steps +2. Present step-by-step with progress +3. Include code at each step +4. Validate understanding before proceeding + +**QUIZ Mode:** +1. Generate questions based on lesson content +2. Present questions one at a time +3. Evaluate answers with explanations +4. Update progress and weak areas + +--- + +## Layer 7: OUTPUT FORMAT + +### Lesson Response Format: +```json +{ + "package_name": "string", + "summary": "1-2 sentence overview", + "explanation": "detailed explanation", + "use_cases": ["use case 1", "use case 2"], + "best_practices": ["practice 1", "practice 2"], + "code_examples": [ + { + "title": "Example title", + "code": "actual code", + "language": "bash", + "description": "what it does" + } + ], + "tutorial_steps": [ + { + "step_number": 1, + "title": "Step title", + "content": "Step instruction", + "code": "optional code" + } + ], + "installation_command": "apt install package", + "confidence": 0.95 +} +``` + +### Q&A Response Format: +```json +{ + "question_understood": "rephrased question", + "answer": "detailed answer", + "code_example": "optional code", + "related_topics": ["topic1", "topic2"], + "confidence": 0.90, + "sources_to_verify": ["suggestion1"] +} +``` + +### Progress Update Format: +```json +{ + "package_name": "string", + "topic_completed": "topic name", + "score": 0.85, + "time_spent_seconds": 300, + "next_recommended": "next topic" +} +``` + +--- + +## Example Interaction: + +**User:** Tell me about Docker + +**Agent Reasoning:** +``` +1. UNDERSTAND: User wants to learn about Docker package +2. CONTEXTUALIZE: Checking student profile... learning_style=hands-on +3. PLAN: Check cache for Docker lesson → Miss → Use lesson_generator +4. EXECUTE: Generating comprehensive Docker lesson +5. VALIDATE: Response covers core concepts, has examples, appropriate complexity +6. DELIVER: Presenting lesson with hands-on focus +``` + +**Response:** +``` +🎓 Docker Tutorial + +Docker is a containerization platform that packages applications +and their dependencies into isolated containers... + +Would you like to: +1. Learn basic concepts +2. See installation examples +3. Practice with exercises +4. Ask questions +``` + +--- + +## Compliance Notes: + +- **Data Privacy**: Never store personal information beyond session +- **Accuracy**: Always encourage verification of critical commands +- **Safety**: Warn about potentially destructive commands (rm -rf, etc.) +- **Accessibility**: Keep language clear and jargon-free when possible diff --git a/cortex/tutor/prompts/tools/examples_provider.md b/cortex/tutor/prompts/tools/examples_provider.md new file mode 100644 index 00000000..059322df --- /dev/null +++ b/cortex/tutor/prompts/tools/examples_provider.md @@ -0,0 +1,156 @@ +# Examples Provider Tool - System Prompt + +## Layer 1: IDENTITY + +You are a **Code Examples Generator**, a specialized AI component that creates practical, educational code examples for software packages and tools. + +**You ARE:** +- A creator of clear, runnable code examples +- An expert at demonstrating package functionality +- A provider of progressive complexity examples + +**You are NOT:** +- A code executor or runner +- A real-time documentation parser +- A source of production-ready code + +--- + +## Layer 2: ROLE & BOUNDARIES + +### Your Role: +Generate contextual code examples that: +- Demonstrate specific package features +- Progress from simple to complex +- Include clear explanations +- Are safe to run + +### Boundaries: +- Keep examples focused and concise +- Avoid destructive commands without warnings +- Do not generate credentials or secrets +- Focus on learning, not production code + +--- + +## Layer 3: ANTI-HALLUCINATION RULES + +**CRITICAL - Adhere strictly:** + +1. **NEVER invent command syntax** + - Only use flags/options you're certain exist + - If uncertain, note it in the description + +2. **NEVER generate fake output** + - Use realistic but generic output examples + - Mark expected output as illustrative + +3. **NEVER include real credentials** + - Use placeholder values: `your_username`, `your_api_key` + - Never generate realistic-looking secrets + +4. **Validate command safety** + - Flag potentially dangerous commands + - Add warnings for destructive operations + +--- + +## Layer 4: CONTEXT & INPUTS + +You will receive: +``` +{ + "package_name": "package to demonstrate", + "topic": "specific feature or concept", + "difficulty": "beginner|intermediate|advanced", + "learning_style": "visual|reading|hands-on", + "existing_knowledge": ["concepts already known"] +} +``` + +--- + +## Layer 5: TOOLS & USAGE + +This tool does NOT call other tools. Pure generation only. + +--- + +## Layer 6: WORKFLOW & REASONING + +### Generation Process: + +1. **Parse Request**: Understand the specific feature to demonstrate +2. **Plan Examples**: Design 2-4 examples with progressive complexity +3. **Write Code**: Create clean, commented code +4. **Add Context**: Include descriptions and expected output +5. **Safety Check**: Review for dangerous operations +6. **Assign Confidence**: Rate certainty of example accuracy + +--- + +## Layer 7: OUTPUT FORMAT + +```json +{ + "package_name": "string", + "topic": "specific topic being demonstrated", + "examples": [ + { + "title": "Example Title", + "difficulty": "beginner", + "code": "actual code here", + "language": "bash|python|yaml|etc", + "description": "What this example demonstrates", + "expected_output": "Sample output (optional)", + "warnings": ["Any safety warnings"], + "prerequisites": ["Required setup steps"] + } + ], + "tips": ["Additional usage tips"], + "common_mistakes": ["Mistakes to avoid"], + "confidence": 0.90 +} +``` + +--- + +## Example Output: + +```json +{ + "package_name": "git", + "topic": "branching", + "examples": [ + { + "title": "Create a New Branch", + "difficulty": "beginner", + "code": "git checkout -b feature/new-feature", + "language": "bash", + "description": "Creates and switches to a new branch named 'feature/new-feature'", + "expected_output": "Switched to a new branch 'feature/new-feature'", + "warnings": [], + "prerequisites": ["Git repository initialized"] + }, + { + "title": "Merge Branch", + "difficulty": "intermediate", + "code": "git checkout main\ngit merge feature/new-feature", + "language": "bash", + "description": "Switches to main branch and merges the feature branch into it", + "expected_output": "Merge made by the 'ort' strategy.", + "warnings": ["Resolve any merge conflicts before completing"], + "prerequisites": ["Feature branch has commits to merge"] + } + ], + "tips": [ + "Use descriptive branch names that indicate the purpose", + "Delete merged branches to keep repository clean" + ], + "common_mistakes": [ + "Forgetting to commit changes before switching branches", + "Merging into the wrong target branch" + ], + "confidence": 0.95 +} +``` diff --git a/cortex/tutor/prompts/tools/lesson_generator.md b/cortex/tutor/prompts/tools/lesson_generator.md new file mode 100644 index 00000000..925d3a92 --- /dev/null +++ b/cortex/tutor/prompts/tools/lesson_generator.md @@ -0,0 +1,224 @@ +# Lesson Generator Tool - System Prompt + +## Layer 1: IDENTITY + +You are a **Lesson Content Generator**, a specialized AI component responsible for creating comprehensive, educational content about software packages and tools. + +**You ARE:** +- A curriculum designer for technical education +- An expert at structuring learning materials +- A creator of practical examples and tutorials + +**You are NOT:** +- A live documentation fetcher +- A package installer or executor +- A source of real-time package information + +--- + +## Layer 2: ROLE & BOUNDARIES + +### Your Role: +Generate structured lesson content for a given package including: +- Clear explanations of functionality +- Practical use cases +- Best practices +- Code examples +- Step-by-step tutorials + +### Boundaries: +- Generate content based on well-known package knowledge +- Do not claim features you're uncertain about +- Focus on stable, documented functionality +- Keep examples safe and non-destructive + +--- + +## Layer 3: ANTI-HALLUCINATION RULES + +**CRITICAL - Adhere strictly:** + +1. **NEVER invent command flags** + - Only use flags you are certain exist + - When uncertain, use generic examples or note uncertainty + +2. **NEVER fabricate URLs** + - Do not generate specific documentation URLs + - Suggest "official documentation" or "man pages" instead + +3. **NEVER claim specific versions** + - Avoid version-specific features unless certain + - Use "recent versions" or "modern installations" + +4. **Express uncertainty clearly** + - Use confidence indicators in your output + - Mark uncertain information with caveats + +5. **Validate against common knowledge** + - Only include widely-known package information + - Avoid obscure features unless explicitly asked + +--- + +## Layer 4: CONTEXT & INPUTS + +You will receive: +``` +{ + "package_name": "name of the package to teach", + "student_level": "beginner|intermediate|advanced", + "learning_style": "visual|reading|hands-on", + "focus_areas": ["specific topics to emphasize"], + "skip_areas": ["topics already mastered"] +} +``` + +Use this context to: +- Adjust explanation depth +- Tailor examples to learning style +- Emphasize relevant focus areas +- Skip content the student already knows + +--- + +## Layer 5: TOOLS & USAGE + +This tool does NOT call other tools. It is a pure generation tool. + +**Input Processing:** +1. Parse the package name and context +2. Retrieve relevant knowledge about the package +3. Structure content according to student needs + +**Output Generation:** +1. Generate each section with appropriate depth +2. Create examples matching learning style +3. Build tutorial steps for hands-on learners + +--- + +## Layer 6: WORKFLOW & REASONING + +### Generation Process: + +``` +1. ANALYZE PACKAGE + - What category? (system tool, library, service, etc.) + - What is its primary purpose? + - What problems does it solve? + +2. STRUCTURE CONTENT + - Summary (1-2 sentences) + - Detailed explanation + - Use cases (3-5 practical scenarios) + - Best practices (5-7 guidelines) + - Code examples (2-4 practical snippets) + - Tutorial steps (5-8 hands-on steps) + +3. ADAPT TO STUDENT + - Beginner: More explanation, simpler examples + - Intermediate: Focus on practical usage + - Advanced: Cover edge cases, performance tips + +4. VALIDATE CONTENT + - Check for hallucination risks + - Ensure examples are safe + - Verify logical flow + +5. ASSIGN CONFIDENCE + - High (0.9-1.0): Well-known, stable packages + - Medium (0.7-0.9): Less common packages + - Low (0.5-0.7): Uncertain or niche packages +``` + +### Example Categories: + +**System Tools** (apt, systemctl, journalctl): +- Focus on command syntax +- Include common flags +- Show output interpretation + +**Development Tools** (git, docker, npm): +- Include workflow examples +- Show integration patterns +- Cover configuration + +**Services** (nginx, postgresql, redis): +- Explain architecture +- Show configuration files +- Include deployment patterns + +--- + +## Layer 7: OUTPUT FORMAT + +Return a structured JSON object: + +```json +{ + "package_name": "docker", + "summary": "Docker is a containerization platform that packages applications with their dependencies into portable containers.", + "explanation": "Docker enables developers to package applications...[detailed explanation]...", + "use_cases": [ + "Consistent development environments across team members", + "Microservices deployment and orchestration", + "CI/CD pipeline containerization", + "Application isolation and resource management" + ], + "best_practices": [ + "Use official base images when possible", + "Keep images small with multi-stage builds", + "Never store secrets in images", + "Use .dockerignore to exclude unnecessary files", + "Tag images with meaningful version identifiers" + ], + "code_examples": [ + { + "title": "Basic Container Run", + "code": "docker run -d -p 8080:80 nginx", + "language": "bash", + "description": "Runs an nginx container in detached mode, mapping port 8080 on host to port 80 in container" + }, + { + "title": "Building an Image", + "code": "docker build -t myapp:latest .", + "language": "bash", + "description": "Builds a Docker image from Dockerfile in current directory with tag 'myapp:latest'" + } + ], + "tutorial_steps": [ + { + "step_number": 1, + "title": "Verify Installation", + "content": "First, verify Docker is installed correctly by checking the version.", + "code": "docker --version", + "expected_output": "Docker version 24.x.x, build xxxxxxx" + }, + { + "step_number": 2, + "title": "Run Your First Container", + "content": "Let's run a simple hello-world container to test everything works.", + "code": "docker run hello-world", + "expected_output": "Hello from Docker! This message shows..." + } + ], + "installation_command": "apt install docker.io", + "official_docs_url": null, + "related_packages": ["docker-compose", "podman", "kubernetes"], + "confidence": 0.95 +} +``` + +--- + +## Quality Checklist: + +Before returning output, verify: +- [ ] Summary is concise (1-2 sentences) +- [ ] Explanation covers core functionality +- [ ] Use cases are practical and relatable +- [ ] Best practices are actionable +- [ ] Code examples are safe and correct +- [ ] Tutorial steps have logical progression +- [ ] Installation command is accurate +- [ ] Confidence reflects actual certainty diff --git a/cortex/tutor/prompts/tools/qa_handler.md b/cortex/tutor/prompts/tools/qa_handler.md new file mode 100644 index 00000000..d36e1e9e --- /dev/null +++ b/cortex/tutor/prompts/tools/qa_handler.md @@ -0,0 +1,231 @@ +# Q&A Handler Tool - System Prompt + +## Layer 1: IDENTITY + +You are a **Q&A Handler**, a specialized AI component that answers user questions about software packages and tools in an educational context. + +**You ARE:** +- A patient teacher answering student questions +- An expert at clarifying technical concepts +- A guide who builds on existing knowledge + +**You are NOT:** +- A search engine or documentation fetcher +- A system administrator +- A source of absolute truth + +--- + +## Layer 2: ROLE & BOUNDARIES + +### Your Role: +Answer questions about packages by: +- Understanding the user's actual question +- Providing clear, accurate responses +- Including relevant examples when helpful +- Suggesting related topics for exploration + +### Boundaries: +- Answer based on package knowledge +- Acknowledge uncertainty honestly +- Do not execute commands +- Stay focused on the learning context + +--- + +## Layer 3: ANTI-HALLUCINATION RULES + +**CRITICAL - Adhere strictly:** + +1. **NEVER fabricate features** + - Only describe functionality you're confident exists + - Say "I'm not certain" when unsure + +2. **NEVER invent comparison data** + - Don't make up benchmarks or statistics + - Use qualitative comparisons instead + +3. **NEVER generate fake URLs** + - Suggest searching for official docs + - Don't create specific URLs + +4. **Express confidence levels** + - High confidence: "Docker uses layers for..." + - Medium: "I believe nginx can..." + - Low: "You should verify this, but..." + +5. **Admit knowledge limits** + - "I don't have specific information about..." + - "The official documentation would be best for..." + +--- + +## Layer 4: CONTEXT & INPUTS + +You will receive: +``` +{ + "package_name": "current package context", + "question": "the user's question", + "student_profile": { + "learning_style": "visual|reading|hands-on", + "mastered_concepts": ["already learned"], + "weak_concepts": ["struggles with"] + }, + "lesson_context": "what they've learned so far" +} +``` + +Use context to: +- Frame answers relative to their knowledge +- Avoid re-explaining mastered concepts +- Provide extra detail for weak areas +- Match response style to learning preference + +--- + +## Layer 5: TOOLS & USAGE + +This tool does NOT call other tools. + +--- + +## Layer 6: WORKFLOW & REASONING + +### Answer Generation Process: + +``` +1. PARSE QUESTION + - What is the core question? + - Is it about concepts, usage, or comparison? + - What knowledge level does it assume? + +2. CHECK CONTEXT + - What does the student already know? + - What's their learning style? + - Any weak areas relevant to the question? + +3. FORMULATE ANSWER + - Start with direct answer + - Add explanation appropriate to level + - Include example if learning style is hands-on + +4. VALIDATE ACCURACY + - Am I confident in this answer? + - Are there caveats to mention? + - Should I suggest verification? + +5. ENHANCE LEARNING + - What related topics might interest them? + - Is there a follow-up concept to suggest? +``` + +### Question Types: + +**Conceptual** ("What is...?", "How does...work?"): +- Provide clear explanation +- Use analogies for visual learners +- Include simple examples + +**Practical** ("How do I...?", "What command...?"): +- Give direct answer first +- Include code example +- Note common variations + +**Comparative** ("What's the difference...?"): +- Structure as clear comparison +- Highlight key differences +- Note when each is preferred + +**Troubleshooting** ("Why does...?", "How to fix...?"): +- Identify likely causes +- Suggest diagnostic steps +- Provide solution approaches + +--- + +## Layer 7: OUTPUT FORMAT + +```json +{ + "question_understood": "Rephrased question for clarity", + "answer": "Main answer to the question", + "explanation": "Detailed explanation if needed", + "code_example": { + "code": "relevant code if applicable", + "language": "bash", + "description": "what the code does" + }, + "related_topics": ["related topic 1", "related topic 2"], + "follow_up_suggestions": [ + "Consider also learning about...", + "A related concept is..." + ], + "confidence": 0.90, + "verification_note": "Optional note to verify specific details" +} +``` + +--- + +## Example Interaction: + +**Input:** +```json +{ + "package_name": "docker", + "question": "What's the difference between a container and an image?", + "student_profile": { + "learning_style": "visual", + "mastered_concepts": ["basic linux commands"], + "weak_concepts": ["virtualization"] + } +} +``` + +**Output:** +```json +{ + "question_understood": "Difference between Docker containers and images", + "answer": "A Docker image is a read-only template containing the application and its dependencies, while a container is a running instance of that image.", + "explanation": "Think of an image like a recipe or blueprint - it defines what should exist but isn't running. A container is like a meal made from that recipe - it's an actual running instance. You can create many containers from a single image, and each container runs independently.", + "code_example": { + "code": "# View images (templates)\ndocker images\n\n# View running containers (instances)\ndocker ps", + "language": "bash", + "description": "Commands to see the difference - images are static templates, containers are running processes" + }, + "related_topics": ["Docker layers", "Dockerfile basics", "Container lifecycle"], + "follow_up_suggestions": [ + "Try creating a container from an image: docker run nginx", + "Notice how multiple containers can use the same image" + ], + "confidence": 0.95, + "verification_note": null +} +``` + +--- + +## Handling Difficult Questions: + +**If you don't know:** +```json +{ + "answer": "I don't have specific information about that feature.", + "explanation": "This might be a newer feature or less common functionality.", + "follow_up_suggestions": [ + "Check the official documentation for the most current information", + "The package's GitHub repository might have details" + ], + "confidence": 0.3 +} +``` + +**If question is unclear:** +```json +{ + "question_understood": "I want to clarify your question", + "answer": "Could you specify whether you're asking about [option A] or [option B]?", + "related_topics": ["relevant topic for context"] +} +``` diff --git a/cortex/tutor/tools/__init__.py b/cortex/tutor/tools/__init__.py new file mode 100644 index 00000000..16dccc07 --- /dev/null +++ b/cortex/tutor/tools/__init__.py @@ -0,0 +1,14 @@ +""" +Tools for Intelligent Tutor. + +Provides deterministic tools for the tutoring workflow. +""" + +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.deterministic.validators import validate_input, validate_package_name + +__all__ = [ + "ProgressTrackerTool", + "validate_package_name", + "validate_input", +] diff --git a/cortex/tutor/tools/deterministic/__init__.py b/cortex/tutor/tools/deterministic/__init__.py new file mode 100644 index 00000000..38dedd29 --- /dev/null +++ b/cortex/tutor/tools/deterministic/__init__.py @@ -0,0 +1,17 @@ +""" +Deterministic tools for Intelligent Tutor. + +These tools do NOT use LLM calls - they are fast, free, and predictable. +Used for: progress tracking, input validation, lesson loading. +""" + +from cortex.tutor.tools.deterministic.lesson_loader import LessonLoaderTool +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.deterministic.validators import validate_input, validate_package_name + +__all__ = [ + "ProgressTrackerTool", + "validate_package_name", + "validate_input", + "LessonLoaderTool", +] diff --git a/cortex/tutor/tools/deterministic/lesson_loader.py b/cortex/tutor/tools/deterministic/lesson_loader.py new file mode 100644 index 00000000..f7573ebd --- /dev/null +++ b/cortex/tutor/tools/deterministic/lesson_loader.py @@ -0,0 +1,194 @@ +""" +Lesson Loader Tool - Deterministic tool for loading cached lesson content. + +This tool does NOT use LLM calls - it retrieves pre-generated lessons from cache. +""" + +from pathlib import Path +from typing import Any + +from cortex.tutor.config import get_config +from cortex.tutor.memory.sqlite_store import SQLiteStore + + +class LessonLoaderTool: + """Deterministic tool for loading cached lesson content.""" + + def __init__(self, db_path: Path | None = None) -> None: + """Initialize the lesson loader tool.""" + if db_path is None: + config = get_config() + db_path = config.get_db_path() + self.store = SQLiteStore(db_path) + + def _run( + self, + package_name: str, + force_fresh: bool = False, + ) -> dict[str, Any]: + """Load cached lesson content.""" + if force_fresh: + return { + "success": True, + "cache_hit": False, + "lesson": None, + "reason": "Force fresh requested", + } + + try: + cached = self.store.get_cached_lesson(package_name) + + if cached: + return { + "success": True, + "cache_hit": True, + "lesson": cached, + "cost_saved_gbp": 0.02, + } + + return { + "success": True, + "cache_hit": False, + "lesson": None, + "reason": "No valid cache found", + } + + except Exception as e: + return { + "success": False, + "cache_hit": False, + "lesson": None, + "error": str(e), + } + + def cache_lesson( + self, + package_name: str, + lesson: dict[str, Any], + ttl_hours: int = 24, + ) -> bool: + """Cache a lesson for future retrieval.""" + try: + self.store.cache_lesson(package_name, lesson, ttl_hours) + return True + except Exception: + return False + + def clear_cache(self, package_name: str | None = None) -> int: + """Clear cached lessons.""" + if package_name: + try: + self.store.cache_lesson(package_name, {}, ttl_hours=0) + return 1 + except Exception: + return 0 + else: + return self.store.clear_expired_cache() + + +# Pre-built lesson templates for common packages +FALLBACK_LESSONS = { + "docker": { + "package_name": "docker", + "summary": "Docker is a containerization platform for packaging and running applications.", + "explanation": ( + "Docker enables you to package applications with their dependencies into " + "standardized units called containers. Containers are lightweight, portable, " + "and isolated from the host system, making deployment consistent across environments." + ), + "use_cases": [ + "Development environment consistency", + "Microservices deployment", + "CI/CD pipelines", + "Application isolation", + ], + "best_practices": [ + "Use official base images when possible", + "Keep images small with multi-stage builds", + "Never store secrets in images", + "Use .dockerignore to exclude unnecessary files", + ], + "installation_command": "apt install docker.io", + "confidence": 0.7, + }, + "git": { + "package_name": "git", + "summary": "Git is a distributed version control system for tracking code changes.", + "explanation": ( + "Git tracks changes to files over time, allowing you to recall specific versions " + "later. It supports collaboration through branching, merging, and remote repositories." + ), + "use_cases": [ + "Source code version control", + "Team collaboration", + "Code review workflows", + "Release management", + ], + "best_practices": [ + "Write clear, descriptive commit messages", + "Use feature branches for new work", + "Pull before push to avoid conflicts", + "Review changes before committing", + ], + "installation_command": "apt install git", + "confidence": 0.7, + }, + "nginx": { + "package_name": "nginx", + "summary": "Nginx is a high-performance web server and reverse proxy.", + "explanation": ( + "Nginx (pronounced 'engine-x') is known for its high performance, stability, " + "and low resource consumption. It can serve static content, act as a reverse proxy, " + "and handle load balancing." + ), + "use_cases": [ + "Static file serving", + "Reverse proxy for applications", + "Load balancing", + "SSL/TLS termination", + ], + "best_practices": [ + "Use separate config files for each site", + "Enable gzip compression", + "Configure proper caching headers", + "Set up SSL with strong ciphers", + ], + "installation_command": "apt install nginx", + "confidence": 0.7, + }, +} + + +def get_fallback_lesson(package_name: str) -> dict[str, Any] | None: + """Get a fallback lesson template for common packages.""" + return FALLBACK_LESSONS.get(package_name.lower()) + + +def load_lesson_with_fallback( + package_name: str, + db_path: Path | None = None, +) -> dict[str, Any]: + """Load lesson from cache with fallback to templates.""" + loader = LessonLoaderTool(db_path) + result = loader._run(package_name) + + if result.get("cache_hit") and result.get("lesson"): + return { + "source": "cache", + "lesson": result["lesson"], + "cost_saved_gbp": result.get("cost_saved_gbp", 0), + } + + fallback = get_fallback_lesson(package_name) + if fallback: + return { + "source": "fallback_template", + "lesson": fallback, + "cost_saved_gbp": 0.02, + } + + return { + "source": "none", + "lesson": None, + "needs_generation": True, + } diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py new file mode 100644 index 00000000..e1252f7f --- /dev/null +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -0,0 +1,289 @@ +""" +Progress Tracker Tool - Deterministic tool for learning progress management. + +This tool does NOT use LLM calls - it is fast, free, and predictable. +""" + +import logging +from pathlib import Path +from typing import Any + +from cortex.tutor.config import get_config +from cortex.tutor.memory.sqlite_store import ( + LearningProgress, + SQLiteStore, +) + +logger = logging.getLogger(__name__) + + +class ProgressTrackerTool: + """Deterministic tool for tracking learning progress.""" + + _ERR_PKG_TOPIC_REQUIRED: str = "package_name and topic required" + + def __init__(self, db_path: Path | None = None) -> None: + """Initialize the progress tracker tool.""" + if db_path is None: + config = get_config() + db_path = config.get_db_path() + self.store = SQLiteStore(db_path) + + def _run( + self, + action: str, + package_name: str | None = None, + topic: str | None = None, + score: float | None = None, + time_seconds: int | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Execute a progress tracking action.""" + actions = { + "get_progress": self._get_progress, + "get_all_progress": self._get_all_progress, + "mark_completed": self._mark_completed, + "update_progress": self._update_progress, + "get_stats": self._get_stats, + "get_profile": self._get_profile, + "update_profile": self._update_profile, + "add_mastered": self._add_mastered_concept, + "add_weak": self._add_weak_concept, + "reset": self._reset_progress, + "get_packages": self._get_packages_studied, + } + + if action not in actions: + return { + "success": False, + "error": f"Unknown action: {action}. Valid actions: {list(actions.keys())}", + } + + try: + return actions[action]( + package_name=package_name, + topic=topic, + score=score, + time_seconds=time_seconds, + **kwargs, + ) + except Exception as e: + logger.exception("Progress tracker action '%s' failed", action) + return {"success": False, "error": str(e)} + + def _get_progress( + self, + package_name: str | None, + topic: str | None, + **_kwargs: Any, + ) -> dict[str, Any]: + """Get progress for a specific package/topic.""" + if not package_name or not topic: + return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} + + progress = self.store.get_progress(package_name, topic) + if progress: + return { + "success": True, + "progress": { + "package_name": progress.package_name, + "topic": progress.topic, + "completed": progress.completed, + "score": progress.score, + "last_accessed": progress.last_accessed, + "total_time_seconds": progress.total_time_seconds, + }, + } + return {"success": True, "progress": None, "message": "No progress found"} + + def _get_all_progress( + self, + package_name: str | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + """Get all progress, optionally filtered by package.""" + progress_list = self.store.get_all_progress(package_name) + return { + "success": True, + "progress": [ + { + "package_name": p.package_name, + "topic": p.topic, + "completed": p.completed, + "score": p.score, + "total_time_seconds": p.total_time_seconds, + } + for p in progress_list + ], + "count": len(progress_list), + } + + def _mark_completed( + self, + package_name: str | None, + topic: str | None, + score: float | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + """Mark a topic as completed.""" + if not package_name or not topic: + return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} + + effective_score = score if score is not None else 1.0 + self.store.mark_topic_completed(package_name, topic, effective_score) + return { + "success": True, + "message": f"Marked {package_name}/{topic} as completed", + "score": effective_score, + } + + def _update_progress( + self, + package_name: str | None, + topic: str | None, + score: float | None = None, + time_seconds: int | None = None, + completed: bool | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + """Update progress for a topic.""" + if not package_name or not topic: + return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} + + existing = self.store.get_progress(package_name, topic) + total_time = (existing.total_time_seconds if existing else 0) + (time_seconds or 0) + + # Preserve existing values if not explicitly provided + if completed is not None: + final_completed = completed + else: + final_completed = existing.completed if existing else False + + if score is not None: + final_score = score + else: + final_score = existing.score if existing else 0.0 + + progress = LearningProgress( + package_name=package_name, + topic=topic, + completed=final_completed, + score=final_score, + total_time_seconds=total_time, + ) + row_id = self.store.upsert_progress(progress) + return { + "success": True, + "row_id": row_id, + "total_time_seconds": total_time, + } + + def _get_stats( + self, + package_name: str | None, + **_kwargs: Any, + ) -> dict[str, Any]: + """Get completion statistics for a package.""" + if not package_name: + return {"success": False, "error": "package_name required"} + + stats = self.store.get_completion_stats(package_name) + return { + "success": True, + "stats": stats, + "completion_percentage": ( + (stats["completed"] / stats["total"] * 100) if stats["total"] > 0 else 0 + ), + } + + def _get_profile(self, **_kwargs: Any) -> dict[str, Any]: + """Get student profile.""" + profile = self.store.get_student_profile() + return { + "success": True, + "profile": { + "mastered_concepts": profile.mastered_concepts, + "weak_concepts": profile.weak_concepts, + "learning_style": profile.learning_style, + "last_session": profile.last_session, + }, + } + + def _update_profile( + self, + learning_style: str | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + """Update student profile.""" + profile = self.store.get_student_profile() + if learning_style: + profile.learning_style = learning_style + self.store.update_student_profile(profile) + return {"success": True, "message": "Profile updated"} + + def _add_mastered_concept( + self, + concept: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Add a mastered concept to student profile.""" + concept = kwargs.get("concept") or concept + if not concept: + return {"success": False, "error": "concept required"} + self.store.add_mastered_concept(concept) + return {"success": True, "message": f"Added mastered concept: {concept}"} + + def _add_weak_concept( + self, + concept: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Add a weak concept to student profile.""" + concept = kwargs.get("concept") or concept + if not concept: + return {"success": False, "error": "concept required"} + self.store.add_weak_concept(concept) + return {"success": True, "message": f"Added weak concept: {concept}"} + + def _reset_progress( + self, + package_name: str | None = None, + **_kwargs: Any, + ) -> dict[str, Any]: + """Reset learning progress.""" + count = self.store.reset_progress(package_name) + scope = f"for {package_name}" if package_name else "all" + return { + "success": True, + "count": count, + "message": f"Reset {count} progress records {scope}", + } + + def _get_packages_studied(self, **_kwargs: Any) -> dict[str, Any]: + """Get list of packages that have been studied.""" + packages = self.store.get_packages_studied() + return {"success": True, "packages": packages, "count": len(packages)} + + +# Convenience functions for direct usage + + +def get_learning_progress(package_name: str, topic: str) -> dict[str, Any] | None: + """Get learning progress for a specific topic.""" + tool = ProgressTrackerTool() + result = tool._run("get_progress", package_name=package_name, topic=topic) + return result.get("progress") + + +def mark_topic_completed(package_name: str, topic: str, score: float = 1.0) -> bool: + """Mark a topic as completed.""" + tool = ProgressTrackerTool() + result = tool._run("mark_completed", package_name=package_name, topic=topic, score=score) + return result.get("success", False) + + +def get_package_stats(package_name: str) -> dict[str, Any]: + """Get completion statistics for a package.""" + tool = ProgressTrackerTool() + result = tool._run("get_stats", package_name=package_name) + return result.get("stats", {}) diff --git a/cortex/tutor/tools/deterministic/validators.py b/cortex/tutor/tools/deterministic/validators.py new file mode 100644 index 00000000..75975bd1 --- /dev/null +++ b/cortex/tutor/tools/deterministic/validators.py @@ -0,0 +1,367 @@ +""" +Validators - Deterministic input validation for Intelligent Tutor. + +Provides security-focused validation functions following Cortex patterns. +No LLM calls - pure rule-based validation. +""" + +import re + +# Maximum input length to prevent abuse +MAX_INPUT_LENGTH = 1000 +MAX_PACKAGE_NAME_LENGTH = 100 +MAX_QUESTION_LENGTH = 2000 + +# Blocked patterns for security (following Cortex TESTING.md) +BLOCKED_PATTERNS = [ + r"rm\s+-rf", # Destructive file operations + r"rm\s+-r\s+/", # Root deletion + r"mkfs\.", # Filesystem formatting + r"dd\s+if=", # Disk operations + r":\s*\(\)\s*\{", # Fork bomb + r">\s*/dev/sd", # Direct disk writes + r"chmod\s+-R\s+777", # Unsafe permissions + r"curl.*\|\s*sh", # Pipe to shell + r"wget.*\|\s*sh", # Pipe to shell + r"eval\s*\(", # Eval injection + r"exec\s*\(", # Exec injection + r"__import__", # Python import injection + r"subprocess", # Subprocess calls + r"os\.system", # System calls + r";\s*rm\s+", # Command injection with rm + r"&&\s*rm\s+", # Command injection with rm + r"\|\s*rm\s+", # Pipe to rm +] + +# Valid package name pattern (alphanumeric, hyphens, underscores, dots) +VALID_PACKAGE_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$") + +# Common package categories for validation hints +KNOWN_PACKAGE_CATEGORIES = [ + "system", # apt, systemctl, journalctl + "development", # git, docker, npm, pip + "database", # postgresql, mysql, redis, mongodb + "web", # nginx, apache, curl, wget + "security", # ufw, fail2ban, openssl + "networking", # ssh, netstat, iptables + "utilities", # vim, tmux, htop, grep +] + + +def validate_package_name(package_name: str) -> tuple[bool, str | None]: + """ + Validate a package name for safety and format. + + Args: + package_name: The package name to validate. + + Returns: + Tuple of (is_valid, error_message). + If valid, error_message is None. + + Examples: + >>> validate_package_name("docker") + (True, None) + >>> validate_package_name("rm -rf /") + (False, "Package name contains blocked pattern") + >>> validate_package_name("") + (False, "Package name cannot be empty") + """ + # Check for empty input + if not package_name or not package_name.strip(): + return False, "Package name cannot be empty" + + package_name = package_name.strip() + + # Check length + if len(package_name) > MAX_PACKAGE_NAME_LENGTH: + return False, f"Package name too long (max {MAX_PACKAGE_NAME_LENGTH} characters)" + + # Check for blocked patterns + for pattern in BLOCKED_PATTERNS: + if re.search(pattern, package_name, re.IGNORECASE): + return False, "Package name contains blocked pattern" + + # Check valid format + if not VALID_PACKAGE_PATTERN.match(package_name): + return ( + False, + "Package name must start with alphanumeric and contain only letters, " + "numbers, dots, hyphens, and underscores", + ) + + return True, None + + +def validate_input( + input_text: str, + max_length: int = MAX_INPUT_LENGTH, + allow_empty: bool = False, +) -> tuple[bool, str | None]: + """ + Validate general user input for safety. + + Args: + input_text: The input text to validate. + max_length: Maximum allowed length. + allow_empty: Whether empty input is allowed. + + Returns: + Tuple of (is_valid, error_message). + + Examples: + >>> validate_input("What is Docker?") + (True, None) + >>> validate_input("rm -rf / && steal_data") + (False, "Input contains blocked pattern") + """ + # Check for empty + if not input_text or not input_text.strip(): + if allow_empty: + return True, None + return False, "Input cannot be empty" + + input_text = input_text.strip() + + # Check length + if len(input_text) > max_length: + return False, f"Input too long (max {max_length} characters)" + + # Check for blocked patterns + for pattern in BLOCKED_PATTERNS: + if re.search(pattern, input_text, re.IGNORECASE): + return False, "Input contains blocked pattern" + + return True, None + + +def validate_question(question: str) -> tuple[bool, str | None]: + """ + Validate a user question for the Q&A system. + + Args: + question: The question to validate. + + Returns: + Tuple of (is_valid, error_message). + """ + return validate_input(question, max_length=MAX_QUESTION_LENGTH, allow_empty=False) + + +def validate_topic(topic: str) -> tuple[bool, str | None]: + """ + Validate a topic name. + + Args: + topic: The topic name to validate. + + Returns: + Tuple of (is_valid, error_message). + """ + if not topic or not topic.strip(): + return False, "Topic cannot be empty" + + topic = topic.strip() + + if len(topic) > 200: + return False, "Topic name too long (max 200 characters)" + + # Topics can have more characters than package names + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9\s._-]*$", topic): + return False, "Topic contains invalid characters" + + return True, None + + +def validate_score(score: float) -> tuple[bool, str | None]: + """ + Validate a score value. + + Args: + score: The score to validate (should be 0.0 to 1.0). + + Returns: + Tuple of (is_valid, error_message). + """ + if not isinstance(score, (int, float)): + return False, "Score must be a number" + + if score < 0.0 or score > 1.0: + return False, "Score must be between 0.0 and 1.0" + + return True, None + + +def validate_learning_style(style: str) -> tuple[bool, str | None]: + """ + Validate a learning style preference. + + Args: + style: The learning style to validate. + + Returns: + Tuple of (is_valid, error_message). + """ + valid_styles = ["visual", "reading", "hands-on"] + + if not style or style.lower() not in valid_styles: + return False, f"Learning style must be one of: {', '.join(valid_styles)}" + + return True, None + + +def sanitize_input(input_text: str | None) -> str: + """ + Sanitize user input by removing potentially dangerous content. + + Args: + input_text: The input to sanitize (can be None). + + Returns: + Sanitized input string. + """ + if not input_text: + return "" + + # Strip whitespace + sanitized = input_text.strip() + + # Remove null bytes + sanitized = sanitized.replace("\x00", "") + + # Limit length + if len(sanitized) > MAX_INPUT_LENGTH: + sanitized = sanitized[:MAX_INPUT_LENGTH] + + return sanitized + + +def extract_package_name(input_text: str | None) -> str | None: + """ + Extract a potential package name from user input. + + Args: + input_text: User input that may contain a package name (can be None). + + Returns: + Extracted package name or None. + + Examples: + >>> extract_package_name("Tell me about docker") + 'docker' + >>> extract_package_name("How do I use nginx?") + 'nginx' + """ + if not input_text: + return None + + # Common patterns for package references + patterns = [ + r"about\s+(\w[\w.-]*)", # "about docker" + r"learn\s+(\w[\w.-]*)", # "learn git" + r"teach\s+(?:me\s+)?(\w[\w.-]*)", # "teach me python" + r"explain\s+(\w[\w.-]*)", # "explain nginx" + r"how\s+(?:to\s+)?use\s+(\w[\w.-]*)", # "how to use curl" + r"what\s+is\s+(\w[\w.-]*)", # "what is redis" + r"^(\w[\w.-]*)$", # Just the package name + ] + + for pattern in patterns: + match = re.search(pattern, input_text, re.IGNORECASE) + if match: + candidate = match.group(1) + is_valid, _ = validate_package_name(candidate) + if is_valid: + return candidate.lower() + + return None + + +def get_validation_errors( + package_name: str | None = None, + topic: str | None = None, + question: str | None = None, + score: float | None = None, +) -> list[str]: + """ + Validate multiple inputs and return all errors. + + Args: + package_name: Optional package name to validate. + topic: Optional topic to validate. + question: Optional question to validate. + score: Optional score to validate. + + Returns: + List of error messages (empty if all valid). + """ + errors = [] + + if package_name is not None: + is_valid, error = validate_package_name(package_name) + if not is_valid: + errors.append(f"Package name: {error}") + + if topic is not None: + is_valid, error = validate_topic(topic) + if not is_valid: + errors.append(f"Topic: {error}") + + if question is not None: + is_valid, error = validate_question(question) + if not is_valid: + errors.append(f"Question: {error}") + + if score is not None: + is_valid, error = validate_score(score) + if not is_valid: + errors.append(f"Score: {error}") + + return errors + + +class ValidationResult: + """Result of a validation operation.""" + + def __init__(self, is_valid: bool, errors: list[str] | None = None) -> None: + """ + Initialize validation result. + + Args: + is_valid: Whether validation passed. + errors: List of error messages if validation failed. + """ + self.is_valid = is_valid + self.errors = errors or [] + + def __bool__(self) -> bool: + """Return True if validation passed.""" + return self.is_valid + + def __str__(self) -> str: + """Return string representation.""" + if self.is_valid: + return "Validation passed" + return f"Validation failed: {'; '.join(self.errors)}" + + +def validate_all( + package_name: str | None = None, + topic: str | None = None, + question: str | None = None, + score: float | None = None, +) -> ValidationResult: + """ + Validate all provided inputs and return a ValidationResult. + + Args: + package_name: Optional package name to validate. + topic: Optional topic to validate. + question: Optional question to validate. + score: Optional score to validate. + + Returns: + ValidationResult with is_valid and errors. + """ + errors = get_validation_errors(package_name, topic, question, score) + return ValidationResult(is_valid=len(errors) == 0, errors=errors) diff --git a/docs/AI_TUTOR.md b/docs/AI_TUTOR.md new file mode 100644 index 00000000..291fc0be --- /dev/null +++ b/docs/AI_TUTOR.md @@ -0,0 +1,741 @@ +# AI-Powered Installation Tutor + +> **Interactive package education system powered by Claude AI** + +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/licenses/Apache-2.0) + +--- + +## Table of Contents + +- [Overview](#overview) +- [Features](#features) +- [Quick Start](#quick-start) +- [Usage Examples](#usage-examples) +- [Architecture](#architecture) +- [Technical Design](#technical-design) +- [API Reference](#api-reference) +- [Configuration](#configuration) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) + +--- + +## Overview + +The AI Tutor is an intelligent learning system integrated into Cortex Linux that teaches users about packages, tools, and best practices through interactive, LLM-powered sessions. + +### Why AI Tutor? + +| Problem | Solution | +|---------|----------| +| Documentation is scattered across the web | Consolidated, contextual lessons | +| Hard to know best practices | AI-curated recommendations | +| Learning is passive (reading docs) | Interactive Q&A and tutorials | +| No way to track what you've learned | Built-in progress tracking | +| Generic tutorials don't fit your level | Adaptive content based on profile | + +--- + +## Features + +### Core Capabilities + +| Feature | Description | +|---------|-------------| +| **LLM-Powered Lessons** | Comprehensive explanations generated by Claude AI | +| **Interactive Q&A** | Ask any question and get contextual answers | +| **Code Examples** | Practical, runnable code snippets with syntax highlighting | +| **Step-by-Step Tutorials** | Hands-on walkthroughs for each package | +| **Best Practices** | Industry-standard recommendations and patterns | +| **Progress Tracking** | SQLite-backed learning history | +| **Smart Caching** | Lessons cached to reduce API costs | +| **Offline Fallbacks** | Pre-built lessons for common packages | + +### Supported Packages + +The tutor can teach about **any Linux package**, including: + +- **Containerization**: Docker, Podman, LXC +- **Version Control**: Git, SVN, Mercurial +- **Web Servers**: Nginx, Apache, Caddy +- **Databases**: PostgreSQL, MySQL, Redis, MongoDB +- **Programming**: Python, Node.js, Go, Rust +- **DevOps**: Ansible, Terraform, Kubernetes +- **And thousands more...** + +--- + +## Quick Start + +### Prerequisites + +```bash +# Ensure Cortex is installed +cortex --version + +# Ensure API key is configured +echo $ANTHROPIC_API_KEY +``` + +### Your First Lesson + +```bash +# Start an interactive Docker lesson +cortex tutor docker +``` + +### Output + +``` + ___ _ _ _ _ _ _____ _ + |_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_ |_ _| _| |_ ___ _ __ + | || '_ \| __/ _ \ | | |/ _` |/ _ \ '_ \| __| | || | | | __/ _ \| '__| + | || | | | || __/ | | | (_| | __/ | | | |_ | || |_| | || (_) | | + |___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__| |_| \__,_|\__\___/|_| + |___/ + +🎓 Loading lesson for docker... + +Docker is a containerization platform that packages applications +with their dependencies into portable, isolated containers. + +┌─────────────────────────────────────┐ +│ Select an option │ +├─────────────────────────────────────┤ +│ 1. Learn basic concepts │ +│ 2. See code examples │ +│ 3. Follow tutorial │ +│ 4. View best practices │ +│ 5. Ask a question │ +│ 6. Check progress │ +│ 7. Exit │ +└─────────────────────────────────────┘ + +Select option: _ +``` + +--- + +## Usage Examples + +### Interactive Lesson Mode + +Start a full interactive session with menus: + +```bash +# Learn about Docker +cortex tutor docker + +# Learn about Git +cortex tutor git + +# Learn about Nginx with fresh content (skip cache) +cortex tutor nginx --fresh +``` + +### Quick Q&A Mode + +Ask a single question without entering interactive mode: + +```bash +# Ask about Docker +cortex tutor docker -q "What's the difference between images and containers?" + +# Ask about Git +cortex tutor git -q "How do I undo the last commit?" + +# Ask about Nginx +cortex tutor nginx -q "How do I set up a reverse proxy?" +``` + +**Example Output:** + +```bash +$ cortex tutor docker -q "How do I reduce Docker image size?" + +🤔 Thinking... + +┌─────────────────────────────────────────────────────────────────┐ +│ 📝 Answer │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ To reduce Docker image size, follow these strategies: │ +│ │ +│ 1. Use slim/alpine base images: │ +│ ┌────────────────────────────────────────┐ │ +│ │ # Instead of: │ │ +│ │ FROM python:3.11 │ │ +│ │ │ │ +│ │ # Use: │ │ +│ │ FROM python:3.11-slim │ │ +│ │ # Or even smaller: │ │ +│ │ FROM python:3.11-alpine │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 2. Use multi-stage builds: │ +│ ┌────────────────────────────────────────┐ │ +│ │ FROM node:18 AS builder │ │ +│ │ WORKDIR /app │ │ +│ │ RUN npm ci && npm run build │ │ +│ │ │ │ +│ │ FROM nginx:alpine │ │ +│ │ COPY --from=builder /app/dist /usr/...│ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ 3. Combine RUN commands to reduce layers │ +│ 4. Use .dockerignore to exclude unnecessary files │ +│ 5. Clean up package manager cache in the same layer │ +│ │ +│ 💡 Tip: Use `docker history ` to analyze layer sizes │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Progress Tracking + +Track your learning journey: + +```bash +# View all progress +cortex tutor --progress + +# View progress for specific package +cortex tutor --progress docker + +# List all packages you've studied +cortex tutor --list +``` + +**Example Output:** + +```bash +$ cortex tutor --progress + +┌─────────────────────────────────────────────────────────────────┐ +│ 📊 Learning Progress │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Package Topics Completed Score Last Studied │ +│ ───────────────────────────────────────────────────────────── │ +│ docker 4/5 85% 2 hours ago │ +│ git 3/5 70% Yesterday │ +│ nginx 1/5 20% 3 days ago │ +│ │ +│ Total: 8 topics completed across 3 packages │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Reset Progress + +Start fresh when needed: + +```bash +# Reset all progress (requires confirmation) +cortex tutor --reset + +# Reset progress for specific package only +cortex tutor --reset docker +``` + +--- + +## Architecture + +### High-Level Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User Request │ +│ cortex tutor docker │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CLI Layer │ +│ (cortex/tutor/cli.py) │ +│ │ +│ • Parse arguments (package, --question, --progress, etc.) │ +│ • Route to appropriate handler │ +│ • Display Rich-formatted output │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ TutorAgent │ +│ (cortex/tutor/agents/tutor_agent/) │ +│ │ +│ • Orchestrates lesson generation and Q&A │ +│ • Uses cortex.llm_router for LLM calls │ +│ • Coordinates tools and caching │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Deterministic │ │ LLM Layer │ │ Memory │ +│ Tools │ │ (llm.py) │ │ Layer │ +│ │ │ │ │ │ +│ • validators │ │ • llm_router │ │ • SQLite store │ +│ • lesson_loader │ │ • generate_lesson│ │ • Cache mgmt │ +│ • progress_trk │ │ • answer_question│ │ • Progress DB │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ Claude API │ │ + │ │ (Anthropic) │ │ + │ └─────────────────┘ │ + │ │ │ + └───────────────────┼───────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Output Layer │ +│ (cortex/tutor/branding.py) │ +│ │ +│ • Rich console formatting │ +│ • Syntax-highlighted code blocks │ +│ • Progress bars and tables │ +│ • Interactive menus │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Cache-First Pattern + +The tutor uses a simple cache-first pattern to minimize API costs: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CHECK CACHE │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Check Cache │───▶│ Has Cache? │───▶│ Use Cached │ │ +│ │ (FREE) │ │ │ Y │ Lesson │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ N │ +│ ▼ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ GENERATE CONTENT │ +│ │ +│ ┌────────────────────────┐ ┌────────────────────────┐ │ +│ │ cortex.llm_router │───▶│ Claude API │ │ +│ │ │ │ │ │ +│ │ • generate_lesson() │ │ • Lesson content │ │ +│ │ • answer_question() │ │ • Q&A responses │ │ +│ └────────────────────────┘ └────────────────────────┘ │ +│ │ +│ Cost: ~$0.01-0.02 per request │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CACHE & RETURN │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Parse JSON │───▶│ Cache Result │───▶│ Return │ │ +│ │ Response │ │ (24h TTL) │ │ to User │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Tool Classification + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DETERMINISTIC TOOLS │ +│ (No LLM - Fast & Free) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ progress_tracker│ │ lesson_loader │ │ validators │ │ +│ │ │ │ │ │ │ │ +│ │ • Get progress │ │ • Load cache │ │ • Validate pkg │ │ +│ │ • Update score │ │ • Check expiry │ │ • Sanitize input│ │ +│ │ • Mark complete │ │ • Get fallback │ │ • Block patterns│ │ +│ │ • Reset data │ │ • Store lesson │ │ • Length limits │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ Speed: ~10ms | Cost: $0.00 | Reliability: 100% │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ LLM FUNCTIONS │ +│ (cortex/tutor/llm.py) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────┐ ┌─────────────────────────┐ │ +│ │ generate_lesson() │ │ answer_question() │ │ +│ │ │ │ │ │ +│ │ • Summary & explanation │ │ • Q&A responses │ │ +│ │ • Code examples │ │ • Code examples │ │ +│ │ • Tutorial steps │ │ • Related topics │ │ +│ │ • Best practices │ │ • Confidence score │ │ +│ └─────────────────────────────┘ └─────────────────────────┘ │ +│ │ +│ Uses cortex.llm_router for task-aware routing │ +│ Speed: 2-5s | Cost: ~$0.01-0.02 | Reliability: 95%+ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow Diagram + +``` +User Input Processing Output +───────── ────────── ────── + +"cortex tutor docker" + │ + ▼ +┌───────────────┐ +│ CLI Parser │ +└───────────────┘ + │ + ▼ +┌───────────────┐ ┌───────────────┐ +│ TutorAgent │────▶│ SQLite Cache │──── Cache Hit? ────┐ +└───────────────┘ └───────────────┘ │ + │ │ + │ Cache Miss │ + ▼ │ +┌───────────────┐ ┌───────────────┐ │ +│ llm_router │────▶│ Claude API │ │ +│ │ └───────────────┘ │ +└───────────────┘ │ + │ │ + │ Generated Content │ + ▼ │ +┌───────────────┐ │ +│ Validator │◀─────────────────────────────────────────┘ +└───────────────┘ + │ + │ Valid Content + ▼ +┌───────────────┐ ┌───────────────┐ +│ Cache Store │────▶│ Rich Console │───▶ Interactive Menu +└───────────────┘ └───────────────┘ +``` + +--- + +## Technical Design + +### Project Structure + +``` +cortex/tutor/ +├── __init__.py # Package exports +├── cli.py # CLI commands and argument parsing +├── config.py # Configuration management +├── branding.py # Rich console UI utilities +├── llm.py # LLM functions (uses cortex.llm_router) +│ +├── agents/ # Agent implementations +│ └── tutor_agent/ +│ ├── __init__.py +│ └── tutor_agent.py # TutorAgent & InteractiveTutor +│ +├── tools/ # Tool implementations +│ └── deterministic/ # No LLM required (fast, free) +│ ├── progress_tracker.py # SQLite progress operations +│ ├── lesson_loader.py # Cache and fallback loading +│ └── validators.py # Input validation +│ +├── contracts/ # Data structures +│ ├── lesson_context.py # Lesson data structure +│ └── progress_context.py # Progress data structure +│ +└── memory/ # Persistence layer + └── sqlite_store.py # SQLite operations +``` + +### Database Schema + +```sql +-- Learning progress per package/topic +CREATE TABLE learning_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_name TEXT NOT NULL, + topic TEXT NOT NULL, + completed BOOLEAN DEFAULT FALSE, + score REAL DEFAULT 0.0, + last_accessed TEXT, + total_time_seconds INTEGER DEFAULT 0, + UNIQUE(package_name, topic) +); + +-- Quiz/assessment results +CREATE TABLE quiz_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_name TEXT NOT NULL, + question TEXT NOT NULL, + user_answer TEXT, + correct BOOLEAN, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP +); + +-- Student profile for personalization +CREATE TABLE student_profile ( + id INTEGER PRIMARY KEY, + mastered_concepts TEXT, -- JSON array + weak_concepts TEXT, -- JSON array + learning_style TEXT, -- visual, reading, hands-on + last_session TEXT +); + +-- Cached lessons to reduce API costs +CREATE TABLE lesson_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_name TEXT UNIQUE NOT NULL, + content TEXT NOT NULL, -- JSON lesson data + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +``` + +--- + +## API Reference + +### TutorAgent Class + +```python +from cortex.tutor.agents.tutor_agent import TutorAgent + +# Initialize +agent = TutorAgent(verbose=False) + +# Start a lesson +result = agent.teach("docker", force_fresh=False) +# Returns: { +# "validation_passed": True, +# "content": { +# "summary": "...", +# "explanation": "...", +# "code_examples": [...], +# "tutorial_steps": [...], +# "best_practices": [...] +# }, +# "cache_hit": True, +# "cost_gbp": 0.0 +# } + +# Ask a question +answer = agent.ask("docker", "How do I build an image?") +# Returns: { +# "validation_passed": True, +# "content": { +# "answer": "...", +# "code_example": {...}, +# "related_topics": [...] +# } +# } + +# Get progress +progress = agent.get_progress("docker") +# Returns: { +# "success": True, +# "stats": { +# "completed": 3, +# "total": 5, +# "score": 0.75 +# } +# } + +# Update learning style +agent.update_learning_style("hands-on") # visual, reading, hands-on + +# Mark topic completed +agent.mark_completed("docker", "containers", score=0.9) + +# Reset progress +agent.reset_progress("docker") # or None for all +``` + +### InteractiveTutor Class + +```python +from cortex.tutor.agents.tutor_agent import InteractiveTutor + +# Start interactive session +tutor = InteractiveTutor("docker") +tutor.start() # Enters interactive menu loop +``` + +### CLI Functions + +```python +from cortex.tutor.cli import ( + cmd_teach, + cmd_question, + cmd_list_packages, + cmd_progress, + cmd_reset, +) + +# Start interactive lesson +exit_code = cmd_teach("docker", verbose=False, force_fresh=False) + +# Ask a question +exit_code = cmd_question("docker", "What is Docker?", verbose=False) + +# List packages +exit_code = cmd_list_packages() + +# Show progress +exit_code = cmd_progress(package_name=None) # All packages +exit_code = cmd_progress(package_name="docker") # Specific + +# Reset progress +exit_code = cmd_reset(package_name=None) # All (with confirmation) +exit_code = cmd_reset(package_name="docker") # Specific +``` + +--- + +## Configuration + +### Environment Variables + +```bash +# Required: API key for Claude +export ANTHROPIC_API_KEY=your-api-key-here + +# Optional: Enable debug output +export TUTOR_DEBUG=true + +# Optional: Custom data directory (default: ~/.cortex) +export TUTOR_DATA_DIR=~/.cortex + +# Optional: Force offline fallback mode (use pre-built lessons) +export TUTOR_OFFLINE=true +``` + +### Config File + +Configuration can also be set in `~/.cortex/config.yaml`: + +```yaml +tutor: + cache_ttl_hours: 24 + max_retries: 3 + debug: false + offline: false +``` + +--- + +## Testing + +### Run Tests + +```bash +# Run all tutor tests +pytest tests/tutor/ -v + +# Run with coverage +pytest tests/tutor/ -v --cov=cortex.tutor --cov-report=term-missing + +# Run specific test file +pytest tests/tutor/test_tutor_agent.py -v +``` + +--- + +## Troubleshooting + +### Common Issues + +
+"No API key found" + +```bash +# Set the API key +export ANTHROPIC_API_KEY=your-api-key-here + +# Or add to ~/.cortex/.env +echo 'ANTHROPIC_API_KEY=your-api-key-here' >> ~/.cortex/.env +``` +
+ +
+"Rate limit exceeded" + +The tutor caches lessons to minimize API calls. If you hit rate limits: + +```bash +# Wait a few minutes, or use cached content +cortex tutor docker # Will use cache if available + +# Check your Anthropic dashboard for rate limit status +``` +
+ +
+"Lesson generation failed" + +The tutor has fallback lessons for common packages: + +```bash +# These packages have offline fallbacks: +# docker, git, nginx, python, node, postgresql, redis + +# Force fallback mode +export TUTOR_OFFLINE=true +cortex tutor docker +``` +
+ +
+"Progress not saving" + +Check SQLite database permissions: + +```bash +# Check database location +ls -la ~/.cortex/tutor_progress.db + +# Fix permissions if needed +chmod 644 ~/.cortex/tutor_progress.db +``` +
+ +--- + +## Contributing + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines. + +### Development Setup + +```bash +# Clone and setup +git clone https://github.com/cortexlinux/cortex.git +cd cortex + +# Create venv and install dev dependencies +python3 -m venv venv +source venv/bin/activate +pip install -e ".[dev]" + +# Run tutor tests +pytest tests/tutor/ -v +``` + +--- + +## License + +Apache 2.0 - See [LICENSE](../LICENSE) for details. + +--- + +

+ Built with ❤️ for the Cortex Linux community +

diff --git a/pyproject.toml b/pyproject.toml index 2879e774..e2668fba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,7 @@ Discussions = "https://github.com/cortexlinux/cortex/discussions" Changelog = "https://github.com/cortexlinux/cortex/blob/main/CHANGELOG.md" [tool.setuptools] -packages = ["cortex", "cortex.sandbox", "cortex.utils", "cortex.llm", "cortex.kernel_features", "cortex.kernel_features.ebpf"] +packages = ["cortex", "cortex.sandbox", "cortex.utils", "cortex.llm", "cortex.kernel_features", "cortex.kernel_features.ebpf", "cortex.tutor", "cortex.tutor.agents", "cortex.tutor.agents.tutor_agent", "cortex.tutor.contracts", "cortex.tutor.memory", "cortex.tutor.tools", "cortex.tutor.tools.deterministic"] include-package-data = true [tool.setuptools.package-data] @@ -199,6 +199,7 @@ asyncio_mode = "auto" filterwarnings = [ "ignore::DeprecationWarning", "ignore::PendingDeprecationWarning", + "ignore::pytest.PytestConfigWarning", ] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", diff --git a/tests/tutor/__init__.py b/tests/tutor/__init__.py new file mode 100644 index 00000000..3d1f683a --- /dev/null +++ b/tests/tutor/__init__.py @@ -0,0 +1,5 @@ +""" +Tests for Intelligent Tutor. + +Provides unit and integration tests with >80% coverage target. +""" diff --git a/tests/tutor/test_cli.py b/tests/tutor/test_cli.py new file mode 100644 index 00000000..39e194d3 --- /dev/null +++ b/tests/tutor/test_cli.py @@ -0,0 +1,443 @@ +""" +Tests for CLI module. + +Comprehensive tests for command-line interface. +""" + +import os +import tempfile +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cortex.tutor.cli import ( + cmd_list_packages, + cmd_progress, + cmd_question, + cmd_reset, + cmd_teach, + create_parser, + main, +) +from cortex.tutor.config import reset_config + + +class TestCreateParser: + """Tests for argument parser creation.""" + + def test_creates_parser(self): + """Test parser is created successfully.""" + parser = create_parser() + assert parser is not None + assert parser.prog == "tutor" + + def test_package_argument(self): + """Test parsing package argument.""" + parser = create_parser() + args = parser.parse_args(["docker"]) + assert args.package == "docker" + + def test_version_flag(self): + """Test version flag exits.""" + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["--version"]) + + def test_verbose_flag(self): + """Test verbose flag.""" + parser = create_parser() + args = parser.parse_args(["-v", "docker"]) + assert args.verbose is True + + def test_list_flag(self): + """Test list flag.""" + parser = create_parser() + args = parser.parse_args(["--list"]) + assert args.list is True + + def test_progress_flag(self): + """Test progress flag.""" + parser = create_parser() + args = parser.parse_args(["--progress"]) + assert args.progress is True + + def test_reset_flag_all(self): + """Test reset flag without package.""" + parser = create_parser() + args = parser.parse_args(["--reset"]) + assert args.reset == "__all__" + + def test_reset_flag_package(self): + """Test reset flag with package.""" + parser = create_parser() + args = parser.parse_args(["--reset", "docker"]) + assert args.reset == "docker" + + def test_fresh_flag(self): + """Test fresh flag.""" + parser = create_parser() + args = parser.parse_args(["--fresh", "docker"]) + assert args.fresh is True + + def test_question_flag(self): + """Test question flag.""" + parser = create_parser() + args = parser.parse_args(["docker", "-q", "What is Docker?"]) + assert args.question == "What is Docker?" + assert args.package == "docker" + + +class TestCmdTeach: + """Tests for cmd_teach function.""" + + def test_invalid_package_name(self): + """Test teach with invalid package name.""" + with patch("cortex.tutor.cli.print_error_panel"): + result = cmd_teach("") + assert result == 1 + + def test_blocked_package_name(self): + """Test teach with blocked pattern.""" + with patch("cortex.tutor.cli.print_error_panel"): + result = cmd_teach("rm -rf /") + assert result == 1 + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.InteractiveTutor") + def test_successful_teach(self, mock_tutor_class): + """Test successful teach session.""" + reset_config() # Reset config singleton + + mock_tutor = Mock() + mock_tutor_class.return_value = mock_tutor + + result = cmd_teach("docker") + assert result == 0 + mock_tutor.start.assert_called_once() + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.InteractiveTutor") + def test_teach_with_value_error(self, mock_tutor_class): + """Test teach handles ValueError.""" + reset_config() + + mock_tutor_class.side_effect = ValueError("Test error") + + with patch("cortex.tutor.cli.print_error_panel"): + result = cmd_teach("docker") + assert result == 1 + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.InteractiveTutor") + def test_teach_with_keyboard_interrupt(self, mock_tutor_class): + """Test teach handles KeyboardInterrupt.""" + reset_config() + + mock_tutor = Mock() + mock_tutor.start.side_effect = KeyboardInterrupt() + mock_tutor_class.return_value = mock_tutor + + with patch("cortex.tutor.cli.console"): + with patch("cortex.tutor.cli.tutor_print"): + result = cmd_teach("docker") + assert result == 0 + + +class TestCmdQuestion: + """Tests for cmd_question function.""" + + def test_invalid_package(self): + """Test question with invalid package.""" + with patch("cortex.tutor.cli.print_error_panel"): + result = cmd_question("", "What?") + assert result == 1 + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.TutorAgent") + def test_successful_question(self, mock_agent_class): + """Test successful question.""" + reset_config() + + mock_agent = Mock() + mock_agent.ask.return_value = { + "validation_passed": True, + "content": { + "answer": "Docker is a container platform.", + "code_example": None, + "related_topics": [], + }, + } + mock_agent_class.return_value = mock_agent + + with patch("cortex.tutor.cli.console"): + result = cmd_question("docker", "What is Docker?") + assert result == 0 + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.TutorAgent") + def test_question_with_code_example(self, mock_agent_class): + """Test question with code example in response.""" + reset_config() + + mock_agent = Mock() + mock_agent.ask.return_value = { + "validation_passed": True, + "content": { + "answer": "Run a container like this.", + "code_example": { + "code": "docker run nginx", + "language": "bash", + }, + "related_topics": ["containers", "images"], + }, + } + mock_agent_class.return_value = mock_agent + + with patch("cortex.tutor.cli.console"): + with patch("cortex.tutor.branding.print_code_example"): + result = cmd_question("docker", "How to run?") + assert result == 0 + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.TutorAgent") + def test_question_validation_failed(self, mock_agent_class): + """Test question when validation fails.""" + reset_config() + + mock_agent = Mock() + mock_agent.ask.return_value = {"validation_passed": False} + mock_agent_class.return_value = mock_agent + + with patch("cortex.tutor.cli.print_error_panel"): + result = cmd_question("docker", "What?") + assert result == 1 + + +class TestCmdListPackages: + """Tests for cmd_list_packages function.""" + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + def test_no_packages(self, mock_config_class, mock_store_class): + """Test list with no packages.""" + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + mock_store = Mock() + mock_store.get_packages_studied.return_value = [] + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.tutor_print"): + result = cmd_list_packages() + assert result == 0 + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + def test_with_packages(self, mock_config_class, mock_store_class): + """Test list with packages.""" + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + mock_store = Mock() + mock_store.get_packages_studied.return_value = ["docker", "nginx"] + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.console"): + result = cmd_list_packages() + assert result == 0 + + @patch("cortex.tutor.cli.Config") + def test_list_with_error(self, mock_config_class): + """Test list handles errors.""" + mock_config_class.from_env.side_effect = Exception("Test error") + + with patch("cortex.tutor.cli.print_error_panel"): + result = cmd_list_packages() + assert result == 1 + + +class TestCmdProgress: + """Tests for cmd_progress function.""" + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + def test_progress_for_package(self, mock_config_class, mock_store_class): + """Test progress for specific package.""" + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + mock_store = Mock() + mock_store.get_completion_stats.return_value = { + "completed": 3, + "total": 5, + "avg_score": 0.8, + "total_time_seconds": 600, + } + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.print_progress_summary"): + with patch("cortex.tutor.cli.console"): + result = cmd_progress("docker") + assert result == 0 + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + def test_progress_no_package_found(self, mock_config_class, mock_store_class): + """Test progress when no progress found.""" + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + mock_store = Mock() + mock_store.get_completion_stats.return_value = None + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.tutor_print"): + result = cmd_progress("docker") + assert result == 0 + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + def test_progress_all(self, mock_config_class, mock_store_class): + """Test progress for all packages.""" + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + # Create mock progress objects with attributes + progress1 = Mock(package_name="docker", topic="basics", completed=True, score=0.9) + progress2 = Mock(package_name="docker", topic="advanced", completed=False, score=0.5) + + mock_store = Mock() + mock_store.get_all_progress.return_value = [progress1, progress2] + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.print_table"): + result = cmd_progress() + assert result == 0 + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + def test_progress_empty(self, mock_config_class, mock_store_class): + """Test progress when empty.""" + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + mock_store = Mock() + mock_store.get_all_progress.return_value = [] + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.tutor_print"): + result = cmd_progress() + assert result == 0 + + +class TestCmdReset: + """Tests for cmd_reset function.""" + + @patch("cortex.tutor.cli.get_user_input") + def test_reset_cancelled(self, mock_input): + """Test reset when cancelled.""" + mock_input.return_value = "n" + + with patch("cortex.tutor.cli.tutor_print"): + result = cmd_reset() + assert result == 0 + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + @patch("cortex.tutor.cli.get_user_input") + def test_reset_confirmed(self, mock_input, mock_config_class, mock_store_class): + """Test reset when confirmed.""" + mock_input.return_value = "y" + + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + mock_store = Mock() + mock_store.reset_progress.return_value = 5 + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.print_success_panel"): + result = cmd_reset() + assert result == 0 + + @patch("cortex.tutor.cli.SQLiteStore") + @patch("cortex.tutor.cli.Config") + @patch("cortex.tutor.cli.get_user_input") + def test_reset_specific_package(self, mock_input, mock_config_class, mock_store_class): + """Test reset for specific package.""" + mock_input.return_value = "y" + + mock_config = Mock() + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" + mock_config_class.from_env.return_value = mock_config + + mock_store = Mock() + mock_store.reset_progress.return_value = 3 + mock_store_class.return_value = mock_store + + with patch("cortex.tutor.cli.print_success_panel"): + result = cmd_reset("docker") + assert result == 0 + + +class TestMain: + """Tests for main entry point.""" + + def test_no_args_shows_help(self): + """Test no arguments shows help.""" + with patch("cortex.tutor.cli.create_parser") as mock_parser: + mock_p = Mock() + mock_p.parse_args.return_value = Mock( + package=None, list=False, progress=False, reset=None, question=None + ) + mock_parser.return_value = mock_p + + result = main([]) + assert result == 0 + mock_p.print_help.assert_called_once() + + @patch("cortex.tutor.cli.cmd_list_packages") + def test_list_command(self, mock_list): + """Test list command.""" + mock_list.return_value = 0 + _result = main(["--list"]) + mock_list.assert_called_once() + + @patch("cortex.tutor.cli.cmd_progress") + def test_progress_command(self, mock_progress): + """Test progress command.""" + mock_progress.return_value = 0 + _result = main(["--progress"]) + mock_progress.assert_called_once() + + @patch("cortex.tutor.cli.cmd_reset") + def test_reset_command(self, mock_reset): + """Test reset command.""" + mock_reset.return_value = 0 + _result = main(["--reset"]) + mock_reset.assert_called_once() + + @patch("cortex.tutor.cli.cmd_question") + def test_question_command(self, mock_question): + """Test question command.""" + mock_question.return_value = 0 + _result = main(["docker", "-q", "What is Docker?"]) + mock_question.assert_called_once() + + @patch("cortex.tutor.cli.cmd_teach") + @patch("cortex.tutor.cli.print_banner") + def test_teach_command(self, mock_banner, mock_teach): + """Test teach command.""" + mock_teach.return_value = 0 + _result = main(["docker"]) + mock_teach.assert_called_once() + mock_banner.assert_called_once() diff --git a/tests/tutor/test_deterministic_tools.py b/tests/tutor/test_deterministic_tools.py new file mode 100644 index 00000000..15095fc0 --- /dev/null +++ b/tests/tutor/test_deterministic_tools.py @@ -0,0 +1,179 @@ +""" +Tests for deterministic tools. + +Tests for lesson_loader and progress_tracker. +""" + +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from cortex.tutor.tools.deterministic.lesson_loader import ( + FALLBACK_LESSONS, + LessonLoaderTool, + get_fallback_lesson, +) +from cortex.tutor.tools.deterministic.progress_tracker import ( + ProgressTrackerTool, +) + + +class TestLessonLoaderTool: + """Tests for LessonLoaderTool.""" + + @pytest.fixture + def temp_db(self): + """Create a temporary database.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + def test_cache_miss(self, temp_db): + """Test cache miss returns appropriate response.""" + loader = LessonLoaderTool(temp_db) + result = loader._run("unknown_package") + + assert result["success"] + assert not result["cache_hit"] + + def test_cache_and_retrieve(self, temp_db): + """Test caching and retrieving a lesson.""" + loader = LessonLoaderTool(temp_db) + + lesson = {"package_name": "docker", "summary": "Test"} + loader.cache_lesson("docker", lesson) + + result = loader._run("docker") + assert result["cache_hit"] + assert result["lesson"]["summary"] == "Test" + + def test_force_fresh(self, temp_db): + """Test force_fresh skips cache.""" + loader = LessonLoaderTool(temp_db) + loader.cache_lesson("docker", {"summary": "cached"}) + + result = loader._run("docker", force_fresh=True) + assert not result["cache_hit"] + + +class TestFallbackLessons: + """Tests for fallback lessons.""" + + def test_docker_fallback(self): + """Test Docker fallback exists.""" + fallback = get_fallback_lesson("docker") + assert fallback is not None + assert fallback["package_name"] == "docker" + + def test_git_fallback(self): + """Test Git fallback exists.""" + fallback = get_fallback_lesson("git") + assert fallback is not None + + def test_nginx_fallback(self): + """Test Nginx fallback exists.""" + fallback = get_fallback_lesson("nginx") + assert fallback is not None + + def test_unknown_fallback(self): + """Test unknown package returns None.""" + fallback = get_fallback_lesson("unknown_xyz") + assert fallback is None + + def test_case_insensitive(self): + """Test fallback lookup is case insensitive.""" + fallback = get_fallback_lesson("DOCKER") + assert fallback is not None + + def test_all_fallbacks_have_required_fields(self): + """Test all fallbacks have basic fields.""" + for package in FALLBACK_LESSONS: + fallback = get_fallback_lesson(package) + assert "package_name" in fallback + assert "summary" in fallback + + +class TestProgressTrackerTool: + """Tests for ProgressTrackerTool.""" + + @pytest.fixture + def temp_db(self): + """Create a temporary database.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + @pytest.fixture + def tracker(self, temp_db): + """Create a progress tracker.""" + return ProgressTrackerTool(temp_db) + + def test_update_progress(self, tracker): + """Test update_progress action.""" + result = tracker._run( + "update_progress", + package_name="docker", + topic="basics", + ) + assert result["success"] + + def test_get_progress(self, tracker): + """Test get_progress action.""" + tracker._run("update_progress", package_name="docker", topic="basics") + result = tracker._run("get_progress", package_name="docker", topic="basics") + + assert result["success"] + assert result["progress"]["package_name"] == "docker" + + def test_get_all_progress(self, tracker): + """Test get_all_progress action.""" + tracker._run("update_progress", package_name="docker", topic="basics") + result = tracker._run("get_all_progress") + + assert result["success"] + assert len(result["progress"]) >= 1 + + def test_mark_completed(self, tracker): + """Test mark_completed action.""" + result = tracker._run( + "mark_completed", + package_name="docker", + topic="basics", + score=0.9, + ) + assert result["success"] + + def test_get_stats(self, tracker): + """Test get_stats action.""" + tracker._run("mark_completed", package_name="docker", topic="basics", score=0.8) + result = tracker._run("get_stats", package_name="docker") + + assert result["success"] + assert "stats" in result + + def test_get_profile(self, tracker): + """Test get_profile action.""" + result = tracker._run("get_profile") + + assert result["success"] + assert "learning_style" in result["profile"] + + def test_update_profile(self, tracker): + """Test update_profile action.""" + result = tracker._run("update_profile", learning_style="visual") + assert result["success"] + + def test_get_packages(self, tracker): + """Test get_packages action.""" + tracker._run("update_progress", package_name="docker", topic="basics") + result = tracker._run("get_packages") + + assert result["success"] + assert "docker" in result["packages"] + + def test_invalid_action(self, tracker): + """Test invalid action returns error.""" + result = tracker._run("invalid_action_xyz") + + assert not result["success"] + assert "error" in result diff --git a/tests/tutor/test_integration.py b/tests/tutor/test_integration.py new file mode 100644 index 00000000..b3e6752f --- /dev/null +++ b/tests/tutor/test_integration.py @@ -0,0 +1,233 @@ +""" +Integration tests for Intelligent Tutor. + +Tests for configuration, contracts, branding, and CLI. +""" + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cortex.tutor.branding import console, tutor_print +from cortex.tutor.config import Config, reset_config +from cortex.tutor.contracts.lesson_context import CodeExample, LessonContext +from cortex.tutor.contracts.progress_context import ( + PackageProgress, + ProgressContext, + TopicProgress, +) + + +@pytest.fixture(autouse=True) +def reset_global_config(): + """Reset global config before each test.""" + reset_config() + yield + reset_config() + + +class TestConfig: + """Tests for configuration management.""" + + def test_config_from_env(self, monkeypatch): + """Test config loads from environment.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test123") + monkeypatch.setenv("TUTOR_MODEL", "claude-sonnet-4-20250514") + monkeypatch.setenv("TUTOR_DEBUG", "true") + + config = Config.from_env() + + assert config.anthropic_api_key == "sk-ant-test123" + assert config.model == "claude-sonnet-4-20250514" + assert config.debug is True + + def test_config_missing_api_key(self, monkeypatch): + """Test config raises error without API key.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + with pytest.raises(ValueError) as exc_info: + Config.from_env() + + assert "ANTHROPIC_API_KEY" in str(exc_info.value) + + def test_config_validate_api_key(self): + """Test API key validation.""" + config = Config(anthropic_api_key="sk-ant-valid") + assert config.validate_api_key() is True + + config = Config(anthropic_api_key="invalid") + assert config.validate_api_key() is False + + def test_config_data_dir_expansion(self): + """Test data directory path expansion.""" + config = Config( + anthropic_api_key="test", + data_dir="~/test_dir", + ) + assert "~" not in str(config.data_dir) + + def test_ensure_data_dir(self): + """Test data directory creation.""" + with tempfile.TemporaryDirectory() as tmpdir: + config = Config( + anthropic_api_key="test", + data_dir=Path(tmpdir) / "subdir", + ) + config.ensure_data_dir() + assert config.data_dir.exists() + + +class TestLessonContext: + """Tests for LessonContext contract.""" + + def test_lesson_context_creation(self): + """Test creating a LessonContext.""" + lesson = LessonContext( + package_name="docker", + summary="Docker is a container platform.", + explanation="Docker allows you to package applications.", + use_cases=["Development", "Deployment"], + best_practices=["Use official images"], + installation_command="apt install docker.io", + confidence=0.9, + ) + + assert lesson.package_name == "docker" + assert lesson.confidence == pytest.approx(0.9) + assert len(lesson.use_cases) == 2 + + def test_lesson_context_with_examples(self): + """Test LessonContext with code examples.""" + example = CodeExample( + title="Run container", + code="docker run nginx", + language="bash", + description="Runs an nginx container", + ) + + lesson = LessonContext( + package_name="docker", + summary="Docker summary", + explanation="Docker explanation", + code_examples=[example], + installation_command="apt install docker.io", + confidence=0.9, + ) + + assert len(lesson.code_examples) == 1 + assert lesson.code_examples[0].title == "Run container" + + def test_lesson_context_serialization(self): + """Test JSON serialization.""" + lesson = LessonContext( + package_name="docker", + summary="Summary", + explanation="Explanation", + installation_command="apt install docker.io", + confidence=0.85, + ) + + json_str = lesson.to_json() + restored = LessonContext.from_json(json_str) + + assert restored.package_name == "docker" + assert restored.confidence == pytest.approx(0.85) + + +class TestProgressContext: + """Tests for ProgressContext contract.""" + + def test_progress_context_creation(self): + """Test creating ProgressContext.""" + progress = ProgressContext( + total_packages_started=5, + total_packages_completed=2, + ) + + assert progress.total_packages_started == 5 + assert progress.total_packages_completed == 2 + + def test_package_progress_completion(self): + """Test PackageProgress completion calculation.""" + topics = [ + TopicProgress(topic="basics", completed=True, score=0.9), + TopicProgress(topic="advanced", completed=False, score=0.5), + ] + + package = PackageProgress( + package_name="docker", + topics=topics, + ) + + assert package.completion_percentage == pytest.approx(50.0) + assert package.average_score == pytest.approx(0.7) + assert not package.is_complete() + assert package.get_next_topic() == "advanced" + + +class TestBranding: + """Tests for branding/UI utilities.""" + + def test_tutor_print_success(self, capsys): + """Test tutor_print with success status.""" + tutor_print("Test message", "success") + + def test_tutor_print_error(self, capsys): + """Test tutor_print with error status.""" + tutor_print("Error message", "error") + + def test_console_exists(self): + """Test console is properly initialized.""" + assert console is not None + + +class TestCLI: + """Tests for CLI commands.""" + + def test_create_parser(self): + """Test argument parser creation.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["--help"]) + + def test_version_flag(self): + """Test version flag.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + + with pytest.raises(SystemExit): + parser.parse_args(["--version"]) + + def test_parse_package_argument(self): + """Test parsing package argument.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + args = parser.parse_args(["docker"]) + + assert args.package == "docker" + + def test_parse_question_flag(self): + """Test parsing question flag.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + args = parser.parse_args(["docker", "-q", "What is Docker?"]) + + assert args.package == "docker" + assert args.question == "What is Docker?" + + def test_parse_list_flag(self): + """Test parsing list flag.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + args = parser.parse_args(["--list"]) + + assert args.list is True diff --git a/tests/tutor/test_interactive_tutor.py b/tests/tutor/test_interactive_tutor.py new file mode 100644 index 00000000..20e45451 --- /dev/null +++ b/tests/tutor/test_interactive_tutor.py @@ -0,0 +1,262 @@ +""" +Tests for InteractiveTutor class. + +Tests the interactive menu-driven tutoring interface. +""" + +from unittest.mock import MagicMock, Mock, call, patch + +import pytest + + +class TestInteractiveTutorInit: + """Tests for InteractiveTutor initialization.""" + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.TutorAgent") + def test_init(self, mock_agent_class): + """Test InteractiveTutor initialization.""" + from cortex.tutor.agents.tutor_agent import InteractiveTutor + + mock_agent = Mock() + mock_agent_class.return_value = mock_agent + + tutor = InteractiveTutor("docker") + + assert tutor.package_name == "docker" + assert tutor.lesson is None + + +class TestInteractiveTutorStart: + """Tests for InteractiveTutor.start method.""" + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.TutorAgent") + @patch("cortex.tutor.branding.get_user_input") + @patch("cortex.tutor.branding.print_menu") + @patch("cortex.tutor.branding.print_lesson_header") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.console") + def test_start_loads_lesson( + self, mock_console, mock_tutor_print, mock_header, mock_menu, mock_input, mock_agent_class + ): + """Test start loads lesson and shows menu.""" + from cortex.tutor.agents.tutor_agent import InteractiveTutor + + mock_agent = Mock() + mock_agent.teach.return_value = { + "validation_passed": True, + "content": { + "summary": "Docker is a container platform.", + "explanation": "Details...", + }, + } + mock_agent_class.return_value = mock_agent + + # User selects Exit (7) + mock_input.return_value = "7" + + tutor = InteractiveTutor("docker") + tutor.start() + + mock_agent.teach.assert_called_once() + mock_header.assert_called_once_with("docker") + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.TutorAgent") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print") + def test_start_handles_failed_lesson(self, mock_tutor_print, mock_agent_class): + """Test start handles failed lesson load.""" + from cortex.tutor.agents.tutor_agent import InteractiveTutor + + mock_agent = Mock() + mock_agent.teach.return_value = {"validation_passed": False} + mock_agent_class.return_value = mock_agent + + tutor = InteractiveTutor("docker") + tutor.start() + + # Should print error + mock_tutor_print.assert_any_call("Failed to load lesson. Please try again.", "error") + + +class TestInteractiveTutorMenuOptions: + """Tests for InteractiveTutor menu options.""" + + @pytest.fixture + def mock_tutor(self): + """Create a mock InteractiveTutor.""" + with patch("cortex.tutor.agents.tutor_agent.tutor_agent.TutorAgent"): + from cortex.tutor.agents.tutor_agent import InteractiveTutor + + tutor = InteractiveTutor("docker") + tutor.lesson = { + "summary": "Docker summary", + "explanation": "Docker explanation", + "code_examples": [ + { + "title": "Run container", + "code": "docker run nginx", + "language": "bash", + "description": "Runs nginx", + } + ], + "tutorial_steps": [ + { + "step_number": 1, + "title": "Install", + "content": "Install Docker first", + "code": "apt install docker", + "expected_output": "Done", + } + ], + "best_practices": ["Use official images", "Keep images small"], + } + tutor.agent = Mock() + tutor.agent.mark_completed.return_value = True + tutor.agent.ask.return_value = { + "validation_passed": True, + "content": {"answer": "Test answer"}, + } + tutor.agent.get_progress.return_value = { + "success": True, + "stats": {"completed": 2, "total": 5}, + } + return tutor + + @patch("cortex.tutor.branding.print_markdown") + def test_show_concepts(self, mock_markdown, mock_tutor): + """Test showing concepts.""" + mock_tutor._show_concepts() + + mock_markdown.assert_called_once() + mock_tutor.agent.mark_completed.assert_called_with("docker", "concepts", 0.5) + + @patch("cortex.tutor.branding.print_code_example") + @patch("cortex.tutor.branding.console") + def test_show_examples(self, mock_console, mock_code_example, mock_tutor): + """Test showing code examples.""" + mock_tutor._show_examples() + + mock_code_example.assert_called() + mock_tutor.agent.mark_completed.assert_called_with("docker", "examples", 0.7) + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print") + def test_show_examples_empty(self, mock_print, mock_tutor): + """Test showing examples when none available.""" + mock_tutor.lesson["code_examples"] = [] + mock_tutor._show_examples() + + mock_print.assert_called_with("No examples available", "info") + + @patch("cortex.tutor.branding.print_tutorial_step") + @patch("cortex.tutor.branding.get_user_input") + @patch("cortex.tutor.branding.console") + def test_run_tutorial(self, mock_console, mock_input, mock_step, mock_tutor): + """Test running tutorial.""" + mock_input.return_value = "" # Press Enter to continue + + mock_tutor._run_tutorial() + + mock_step.assert_called() + mock_tutor.agent.mark_completed.assert_called_with("docker", "tutorial", 0.9) + + @patch("cortex.tutor.branding.print_tutorial_step") + @patch("cortex.tutor.branding.get_user_input") + def test_run_tutorial_quit(self, mock_input, mock_step, mock_tutor): + """Test quitting tutorial early.""" + mock_input.return_value = "q" + + mock_tutor._run_tutorial() + + # Should still mark as completed + mock_tutor.agent.mark_completed.assert_called() + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print") + def test_run_tutorial_empty(self, mock_print, mock_tutor): + """Test tutorial with no steps.""" + mock_tutor.lesson["tutorial_steps"] = [] + mock_tutor._run_tutorial() + + mock_print.assert_called_with("No tutorial available", "info") + + @patch("cortex.tutor.branding.print_best_practice") + @patch("cortex.tutor.branding.console") + def test_show_best_practices(self, mock_console, mock_practice, mock_tutor): + """Test showing best practices.""" + mock_tutor._show_best_practices() + + assert mock_practice.call_count == 2 + mock_tutor.agent.mark_completed.assert_called_with("docker", "best_practices", 0.6) + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print") + def test_show_best_practices_empty(self, mock_print, mock_tutor): + """Test best practices when none available.""" + mock_tutor.lesson["best_practices"] = [] + mock_tutor._show_best_practices() + + mock_print.assert_called_with("No best practices available", "info") + + @patch("cortex.tutor.branding.get_user_input") + @patch("cortex.tutor.branding.print_markdown") + @patch("cortex.tutor.branding.tutor_print") + def test_ask_question(self, mock_print, mock_markdown, mock_input, mock_tutor): + """Test asking a question.""" + mock_input.return_value = "What is Docker?" + + mock_tutor._ask_question() + + mock_tutor.agent.ask.assert_called_with("docker", "What is Docker?") + mock_markdown.assert_called() + + @patch("cortex.tutor.branding.get_user_input") + def test_ask_question_empty(self, mock_input, mock_tutor): + """Test asking with empty question.""" + mock_input.return_value = "" + + mock_tutor._ask_question() + + mock_tutor.agent.ask.assert_not_called() + + @patch("cortex.tutor.branding.get_user_input") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print") + def test_ask_question_failed(self, mock_print, mock_input, mock_tutor): + """Test asking question with failed response.""" + mock_input.return_value = "What?" + mock_tutor.agent.ask.return_value = {"validation_passed": False} + + mock_tutor._ask_question() + + mock_print.assert_any_call("Sorry, I couldn't answer that question.", "error") + + @patch("cortex.tutor.branding.print_progress_summary") + def test_show_progress(self, mock_progress, mock_tutor): + """Test showing progress.""" + mock_tutor._show_progress() + + mock_progress.assert_called_with(2, 5, "docker") + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print") + def test_show_progress_failed(self, mock_print, mock_tutor): + """Test showing progress when failed.""" + mock_tutor.agent.get_progress.return_value = {"success": False} + + mock_tutor._show_progress() + + mock_print.assert_called_with("Could not load progress", "warning") + + +class TestInteractiveTutorNoLesson: + """Tests for InteractiveTutor when lesson is None.""" + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.TutorAgent") + def test_methods_with_no_lesson(self, mock_agent_class): + """Test methods handle None lesson gracefully.""" + from cortex.tutor.agents.tutor_agent import InteractiveTutor + + tutor = InteractiveTutor("docker") + tutor.lesson = None + tutor.agent = Mock() + + # These should not raise errors + tutor._show_concepts() + tutor._show_examples() + tutor._run_tutorial() + tutor._show_best_practices() diff --git a/tests/tutor/test_llm.py b/tests/tutor/test_llm.py new file mode 100644 index 00000000..56ae4164 --- /dev/null +++ b/tests/tutor/test_llm.py @@ -0,0 +1,253 @@ +"""Tests for cortex.tutor.llm module.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +class TestParseJsonResponse: + """Tests for _parse_json_response function.""" + + def test_parse_plain_json(self): + """Test parsing plain JSON.""" + from cortex.tutor.llm import _parse_json_response + + content = '{"key": "value"}' + result = _parse_json_response(content) + assert result == {"key": "value"} + + def test_parse_json_with_markdown_fence(self): + """Test parsing JSON wrapped in markdown fences.""" + from cortex.tutor.llm import _parse_json_response + + content = '```json\n{"key": "value"}\n```' + result = _parse_json_response(content) + assert result == {"key": "value"} + + def test_parse_json_with_plain_fence(self): + """Test parsing JSON wrapped in plain markdown fences.""" + from cortex.tutor.llm import _parse_json_response + + content = '```\n{"key": "value"}\n```' + result = _parse_json_response(content) + assert result == {"key": "value"} + + def test_parse_json_with_whitespace(self): + """Test parsing JSON with extra whitespace.""" + from cortex.tutor.llm import _parse_json_response + + content = ' \n {"key": "value"} \n ' + result = _parse_json_response(content) + assert result == {"key": "value"} + + def test_parse_invalid_json_raises(self): + """Test that invalid JSON raises JSONDecodeError.""" + from cortex.tutor.llm import _parse_json_response + + with pytest.raises(json.JSONDecodeError): + _parse_json_response("not valid json") + + +class TestGetRouter: + """Tests for get_router function.""" + + def test_get_router_creates_singleton(self): + """Test that get_router creates a singleton instance.""" + from cortex.tutor import llm + + # Reset the global router + llm._router = None + + with patch.object(llm, "LLMRouter") as mock_router_class: + mock_router = MagicMock() + mock_router_class.return_value = mock_router + + router1 = llm.get_router() + router2 = llm.get_router() + + # Should only create one instance + assert mock_router_class.call_count == 1 + assert router1 is router2 + + # Clean up + llm._router = None + + +class TestGenerateLesson: + """Tests for generate_lesson function.""" + + def test_generate_lesson_success(self): + """Test successful lesson generation.""" + from cortex.tutor import llm + + mock_response = MagicMock() + mock_response.content = json.dumps( + { + "summary": "Test summary", + "explanation": "Test explanation", + "use_cases": ["use case 1"], + "best_practices": ["practice 1"], + "code_examples": [], + "tutorial_steps": [], + "installation_command": "apt install test", + "related_packages": [], + } + ) + mock_response.cost_usd = 0.01 + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.return_value = mock_response + mock_get_router.return_value = mock_router + + result = llm.generate_lesson("docker") + + assert result["success"] is True + assert result["lesson"]["summary"] == "Test summary" + assert result["cost_usd"] == pytest.approx(0.01) + + def test_generate_lesson_with_options(self): + """Test lesson generation with custom options.""" + from cortex.tutor import llm + + mock_response = MagicMock() + mock_response.content = '{"summary": "Advanced lesson"}' + mock_response.cost_usd = 0.02 + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.return_value = mock_response + mock_get_router.return_value = mock_router + + result = llm.generate_lesson( + "nginx", + student_level="advanced", + learning_style="hands-on", + skip_areas=["basics", "intro"], + ) + + assert result["success"] is True + # Verify the prompt includes skip areas + call_args = mock_router.complete.call_args + user_msg = call_args[1]["messages"][1]["content"] + assert "basics" in user_msg + assert "intro" in user_msg + + def test_generate_lesson_json_error(self): + """Test lesson generation with JSON parse error.""" + from cortex.tutor import llm + + mock_response = MagicMock() + mock_response.content = "not valid json" + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.return_value = mock_response + mock_get_router.return_value = mock_router + + result = llm.generate_lesson("docker") + + assert result["success"] is False + assert "error" in result + assert result["lesson"] is None + + def test_generate_lesson_api_error(self): + """Test lesson generation with API error.""" + from cortex.tutor import llm + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.side_effect = Exception("API error") + mock_get_router.return_value = mock_router + + result = llm.generate_lesson("docker") + + assert result["success"] is False + assert "API error" in result["error"] + + +class TestAnswerQuestion: + """Tests for answer_question function.""" + + def test_answer_question_success(self): + """Test successful question answering.""" + from cortex.tutor import llm + + mock_response = MagicMock() + mock_response.content = json.dumps( + { + "answer": "Docker is a containerization platform", + "code_example": {"code": "docker ps", "language": "bash"}, + "related_topics": ["containers", "images"], + "confidence": 0.95, + } + ) + mock_response.cost_usd = 0.005 + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.return_value = mock_response + mock_get_router.return_value = mock_router + + result = llm.answer_question("docker", "What is Docker?") + + assert result["success"] is True + assert "containerization" in result["answer"]["answer"] + assert result["cost_usd"] == pytest.approx(0.005) + + def test_answer_question_with_context(self): + """Test question answering with context.""" + from cortex.tutor import llm + + mock_response = MagicMock() + mock_response.content = '{"answer": "test", "confidence": 0.9}' + mock_response.cost_usd = 0.01 + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.return_value = mock_response + mock_get_router.return_value = mock_router + + result = llm.answer_question( + "docker", + "How do I build?", + context="Learning about Dockerfiles", + ) + + assert result["success"] is True + # Verify context is in the prompt + call_args = mock_router.complete.call_args + user_msg = call_args[1]["messages"][1]["content"] + assert "Learning about Dockerfiles" in user_msg + + def test_answer_question_json_error(self): + """Test question answering with JSON parse error.""" + from cortex.tutor import llm + + mock_response = MagicMock() + mock_response.content = "invalid json response" + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.return_value = mock_response + mock_get_router.return_value = mock_router + + result = llm.answer_question("docker", "What is Docker?") + + assert result["success"] is False + assert result["answer"] is None + + def test_answer_question_api_error(self): + """Test question answering with API error.""" + from cortex.tutor import llm + + with patch.object(llm, "get_router") as mock_get_router: + mock_router = MagicMock() + mock_router.complete.side_effect = RuntimeError("Connection failed") + mock_get_router.return_value = mock_router + + result = llm.answer_question("docker", "What is Docker?") + + assert result["success"] is False + assert "Connection failed" in result["error"] diff --git a/tests/tutor/test_progress_tracker.py b/tests/tutor/test_progress_tracker.py new file mode 100644 index 00000000..52273da0 --- /dev/null +++ b/tests/tutor/test_progress_tracker.py @@ -0,0 +1,349 @@ +""" +Tests for progress tracker and SQLite store. + +Tests learning progress persistence and retrieval. +""" + +import tempfile +from pathlib import Path + +import pytest + +from cortex.tutor.memory.sqlite_store import ( + LearningProgress, + QuizResult, + SQLiteStore, + StudentProfile, +) +from cortex.tutor.tools.deterministic.progress_tracker import ( + ProgressTrackerTool, + get_learning_progress, + get_package_stats, + mark_topic_completed, +) + + +@pytest.fixture +def temp_db(): + """Create a temporary database for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "test_progress.db" + yield db_path + + +@pytest.fixture +def store(temp_db): + """Create a SQLite store with temp database.""" + return SQLiteStore(temp_db) + + +@pytest.fixture +def tracker(temp_db): + """Create a progress tracker with temp database.""" + return ProgressTrackerTool(temp_db) + + +class TestSQLiteStore: + """Tests for SQLiteStore class.""" + + def test_init_creates_database(self, temp_db): + """Test database is created on init.""" + _store = SQLiteStore(temp_db) + assert temp_db.exists() + + def test_upsert_and_get_progress(self, store): + """Test inserting and retrieving progress.""" + progress = LearningProgress( + package_name="docker", + topic="basics", + completed=True, + score=0.9, + ) + store.upsert_progress(progress) + + result = store.get_progress("docker", "basics") + assert result is not None + assert result.package_name == "docker" + assert result.completed is True + assert result.score == pytest.approx(0.9) + + def test_upsert_updates_existing(self, store): + """Test upsert updates existing record.""" + # Insert first + progress1 = LearningProgress( + package_name="docker", + topic="basics", + completed=False, + score=0.5, + ) + store.upsert_progress(progress1) + + # Update + progress2 = LearningProgress( + package_name="docker", + topic="basics", + completed=True, + score=0.9, + ) + store.upsert_progress(progress2) + + result = store.get_progress("docker", "basics") + assert result.completed is True + assert result.score == pytest.approx(0.9) + + def test_get_all_progress(self, store): + """Test getting all progress records.""" + store.upsert_progress( + LearningProgress(package_name="docker", topic="basics", completed=True) + ) + store.upsert_progress( + LearningProgress(package_name="docker", topic="advanced", completed=False) + ) + store.upsert_progress(LearningProgress(package_name="git", topic="basics", completed=True)) + + all_progress = store.get_all_progress() + assert len(all_progress) == 3 + + docker_progress = store.get_all_progress("docker") + assert len(docker_progress) == 2 + + def test_mark_topic_completed(self, store): + """Test marking topic as completed.""" + store.mark_topic_completed("docker", "tutorial", 0.85) + + result = store.get_progress("docker", "tutorial") + assert result.completed is True + assert result.score == pytest.approx(0.85) + + def test_get_completion_stats(self, store): + """Test getting completion statistics.""" + store.upsert_progress( + LearningProgress(package_name="docker", topic="basics", completed=True, score=0.9) + ) + store.upsert_progress( + LearningProgress(package_name="docker", topic="advanced", completed=False, score=0.5) + ) + + stats = store.get_completion_stats("docker") + assert stats["total"] == 2 + assert stats["completed"] == 1 + assert stats["avg_score"] == pytest.approx(0.7) + + def test_quiz_results(self, store): + """Test adding and retrieving quiz results.""" + result = QuizResult( + package_name="docker", + question="What is Docker?", + user_answer="A container platform", + correct=True, + ) + store.add_quiz_result(result) + + results = store.get_quiz_results("docker") + assert len(results) == 1 + assert results[0].question == "What is Docker?" + assert results[0].correct is True + + def test_student_profile(self, store): + """Test student profile operations.""" + profile = store.get_student_profile() + assert profile.learning_style == "reading" + + profile.learning_style = "hands-on" + profile.mastered_concepts = ["docker basics"] + store.update_student_profile(profile) + + updated = store.get_student_profile() + assert updated.learning_style == "hands-on" + assert "docker basics" in updated.mastered_concepts + + def test_add_mastered_concept(self, store): + """Test adding mastered concept.""" + store.add_mastered_concept("containerization") + + profile = store.get_student_profile() + assert "containerization" in profile.mastered_concepts + + def test_add_weak_concept(self, store): + """Test adding weak concept.""" + store.add_weak_concept("networking") + + profile = store.get_student_profile() + assert "networking" in profile.weak_concepts + + def test_lesson_cache(self, store): + """Test lesson caching.""" + lesson = {"summary": "Docker is...", "explanation": "..."} + store.cache_lesson("docker", lesson, ttl_hours=24) + + cached = store.get_cached_lesson("docker") + assert cached is not None + assert cached["summary"] == "Docker is..." + + def test_expired_cache_not_returned(self, store): + """Test expired cache is not returned.""" + lesson = {"summary": "test"} + store.cache_lesson("test", lesson, ttl_hours=0) + + # Should not return expired cache + cached = store.get_cached_lesson("test") + assert cached is None + + def test_reset_progress(self, store): + """Test resetting progress.""" + store.upsert_progress(LearningProgress(package_name="docker", topic="basics")) + store.upsert_progress(LearningProgress(package_name="git", topic="basics")) + + count = store.reset_progress("docker") + assert count == 1 + + remaining = store.get_all_progress() + assert len(remaining) == 1 + assert remaining[0].package_name == "git" + + def test_get_packages_studied(self, store): + """Test getting list of studied packages.""" + store.upsert_progress(LearningProgress(package_name="docker", topic="basics")) + store.upsert_progress(LearningProgress(package_name="git", topic="basics")) + + packages = store.get_packages_studied() + assert set(packages) == {"docker", "git"} + + +class TestProgressTrackerTool: + """Tests for ProgressTrackerTool class.""" + + def test_get_progress_action(self, tracker): + """Test get_progress action.""" + # First add some progress + tracker._run("mark_completed", package_name="docker", topic="basics", score=0.9) + + result = tracker._run("get_progress", package_name="docker", topic="basics") + assert result["success"] + assert result["progress"]["completed"] is True + + def test_get_progress_not_found(self, tracker): + """Test get_progress for non-existent progress.""" + result = tracker._run("get_progress", package_name="unknown", topic="topic") + assert result["success"] + assert result["progress"] is None + + def test_mark_completed_action(self, tracker): + """Test mark_completed action.""" + result = tracker._run( + "mark_completed", + package_name="docker", + topic="tutorial", + score=0.85, + ) + assert result["success"] + assert result["score"] == pytest.approx(0.85) + + def test_get_stats_action(self, tracker): + """Test get_stats action.""" + tracker._run("mark_completed", package_name="docker", topic="basics") + tracker._run("update_progress", package_name="docker", topic="advanced") + + result = tracker._run("get_stats", package_name="docker") + assert result["success"] + assert result["stats"]["total"] == 2 + assert result["stats"]["completed"] == 1 + + def test_get_profile_action(self, tracker): + """Test get_profile action.""" + result = tracker._run("get_profile") + assert result["success"] + assert "learning_style" in result["profile"] + + def test_update_profile_action(self, tracker): + """Test update_profile action.""" + result = tracker._run("update_profile", learning_style="visual") + assert result["success"] + + profile = tracker._run("get_profile") + assert profile["profile"]["learning_style"] == "visual" + + def test_add_mastered_concept_action(self, tracker): + """Test add_mastered action.""" + result = tracker._run("add_mastered", concept="docker basics") + assert result["success"] + + def test_reset_action(self, tracker): + """Test reset action.""" + tracker._run("mark_completed", package_name="docker", topic="basics") + result = tracker._run("reset", package_name="docker") + assert result["success"] + + def test_unknown_action(self, tracker): + """Test unknown action returns error.""" + result = tracker._run("unknown_action") + assert not result["success"] + assert "Unknown action" in result["error"] + + def test_missing_required_params(self, tracker): + """Test missing required params returns error.""" + result = tracker._run("get_progress") # Missing package_name and topic + assert not result["success"] + + +class TestConvenienceFunctions: + """Tests for convenience functions.""" + + def test_get_learning_progress(self, temp_db): + """Test get_learning_progress function.""" + from unittest.mock import Mock, patch + + # Mock the global config to use temp_db + mock_config = Mock() + mock_config.get_db_path.return_value = temp_db + + with patch( + "cortex.tutor.tools.deterministic.progress_tracker.get_config", return_value=mock_config + ): + # First mark a topic completed + result = mark_topic_completed("docker", "basics", 0.85) + assert result is True + + # Now get the progress + progress = get_learning_progress("docker", "basics") + assert progress is not None + assert progress["completed"] is True + assert progress["score"] == pytest.approx(0.85) + + def test_mark_topic_completed(self, temp_db): + """Test mark_topic_completed function.""" + from unittest.mock import Mock, patch + + mock_config = Mock() + mock_config.get_db_path.return_value = temp_db + + with patch( + "cortex.tutor.tools.deterministic.progress_tracker.get_config", return_value=mock_config + ): + result = mark_topic_completed("git", "branching", 0.9) + assert result is True + + # Verify it was actually saved + progress = get_learning_progress("git", "branching") + assert progress is not None + assert progress["completed"] is True + + def test_get_package_stats(self, temp_db): + """Test get_package_stats function.""" + from unittest.mock import Mock, patch + + mock_config = Mock() + mock_config.get_db_path.return_value = temp_db + + with patch( + "cortex.tutor.tools.deterministic.progress_tracker.get_config", return_value=mock_config + ): + # Mark some topics + mark_topic_completed("nginx", "basics", 0.9) + mark_topic_completed("nginx", "config", 0.7) + + # Get stats + stats = get_package_stats("nginx") + assert stats["total"] == 2 + assert stats["completed"] == 2 + assert stats["avg_score"] == pytest.approx(0.8) # (0.9 + 0.7) / 2 diff --git a/tests/tutor/test_tools.py b/tests/tutor/test_tools.py new file mode 100644 index 00000000..90d3bdad --- /dev/null +++ b/tests/tutor/test_tools.py @@ -0,0 +1,113 @@ +""" +Tests for deterministic tools. + +Tests lesson loader and fallback functionality. +""" + +import tempfile +from pathlib import Path + +import pytest + +from cortex.tutor.tools.deterministic.lesson_loader import ( + LessonLoaderTool, + get_fallback_lesson, + load_lesson_with_fallback, +) + + +@pytest.fixture +def temp_db(): + """Create a temporary database.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + +class TestLessonLoaderTool: + """Tests for LessonLoaderTool.""" + + def test_cache_miss(self, temp_db): + """Test cache miss returns appropriate response.""" + loader = LessonLoaderTool(temp_db) + result = loader._run("unknown_package") + + assert result["success"] + assert not result["cache_hit"] + assert result["lesson"] is None + + def test_force_fresh(self, temp_db): + """Test force_fresh skips cache.""" + loader = LessonLoaderTool(temp_db) + + loader.cache_lesson("docker", {"summary": "cached"}) + + result = loader._run("docker", force_fresh=True) + assert not result["cache_hit"] + + def test_cache_lesson_and_retrieve(self, temp_db): + """Test caching and retrieving a lesson.""" + loader = LessonLoaderTool(temp_db) + + lesson = {"summary": "Docker is...", "explanation": "A container platform"} + loader.cache_lesson("docker", lesson, ttl_hours=24) + + result = loader._run("docker") + assert result["success"] + assert result["cache_hit"] + assert result["lesson"]["summary"] == "Docker is..." + + +class TestFallbackLessons: + """Tests for fallback lesson templates.""" + + def test_docker_fallback(self): + """Test Docker fallback exists.""" + fallback = get_fallback_lesson("docker") + assert fallback is not None + assert fallback["package_name"] == "docker" + assert "summary" in fallback + + def test_git_fallback(self): + """Test Git fallback exists.""" + fallback = get_fallback_lesson("git") + assert fallback is not None + + def test_nginx_fallback(self): + """Test Nginx fallback exists.""" + fallback = get_fallback_lesson("nginx") + assert fallback is not None + + def test_unknown_fallback(self): + """Test unknown package returns None.""" + fallback = get_fallback_lesson("unknown_package") + assert fallback is None + + def test_case_insensitive(self): + """Test fallback lookup is case insensitive.""" + fallback = get_fallback_lesson("DOCKER") + assert fallback is not None + + +class TestLoadLessonWithFallback: + """Tests for load_lesson_with_fallback function.""" + + def test_returns_cache_if_available(self, temp_db): + """Test returns cached lesson if available.""" + from cortex.tutor.memory.sqlite_store import SQLiteStore + + store = SQLiteStore(temp_db) + store.cache_lesson("docker", {"summary": "cached"}, ttl_hours=24) + + result = load_lesson_with_fallback("docker", temp_db) + assert result["source"] == "cache" + + def test_returns_fallback_if_no_cache(self, temp_db): + """Test returns fallback if no cache.""" + result = load_lesson_with_fallback("docker", temp_db) + assert result["source"] == "fallback_template" + + def test_returns_none_for_unknown(self, temp_db): + """Test returns none for unknown package.""" + result = load_lesson_with_fallback("totally_unknown", temp_db) + assert result["source"] == "none" + assert result["needs_generation"] diff --git a/tests/tutor/test_validators.py b/tests/tutor/test_validators.py new file mode 100644 index 00000000..65404035 --- /dev/null +++ b/tests/tutor/test_validators.py @@ -0,0 +1,302 @@ +""" +Tests for validators module. + +Tests input validation functions for security and format checking. +""" + +import pytest + +from cortex.tutor.tools.deterministic.validators import ( + MAX_INPUT_LENGTH, + MAX_PACKAGE_NAME_LENGTH, + ValidationResult, + extract_package_name, + get_validation_errors, + sanitize_input, + validate_all, + validate_input, + validate_learning_style, + validate_package_name, + validate_question, + validate_score, + validate_topic, +) + + +class TestValidatePackageName: + """Tests for validate_package_name function.""" + + def test_valid_package_names(self): + """Test valid package names pass validation.""" + valid_names = [ + "docker", + "git", + "nginx", + "python3", + "node-js", + "my_package", + "package.name", + "a", + "a1", + ] + for name in valid_names: + is_valid, error = validate_package_name(name) + assert is_valid, f"Expected {name} to be valid, got error: {error}" + assert error is None + + def test_empty_package_name(self): + """Test empty package name fails.""" + is_valid, error = validate_package_name("") + assert not is_valid + assert "empty" in error.lower() + + def test_whitespace_only(self): + """Test whitespace-only input fails.""" + is_valid, _ = validate_package_name(" ") + assert not is_valid + + def test_too_long_package_name(self): + """Test package name exceeding max length fails.""" + long_name = "a" * (MAX_PACKAGE_NAME_LENGTH + 1) + is_valid, error = validate_package_name(long_name) + assert not is_valid + assert "too long" in error.lower() + + def test_blocked_patterns(self): + """Test blocked patterns are rejected.""" + dangerous_inputs = [ + "rm -rf /", + "curl foo | sh", + "wget bar | sh", + "dd if=/dev/zero", + ] + for inp in dangerous_inputs: + is_valid, error = validate_package_name(inp) + assert not is_valid, f"Expected {inp} to be blocked" + assert "blocked pattern" in error.lower() + + def test_invalid_characters(self): + """Test invalid characters fail.""" + invalid_names = [ + "-starts-with-dash", + ".starts-with-dot", + "has spaces", + "has@symbol", + "has#hash", + ] + for name in invalid_names: + is_valid, _ = validate_package_name(name) + assert not is_valid, f"Expected {name} to be invalid" + + +class TestValidateInput: + """Tests for validate_input function.""" + + def test_valid_input(self): + """Test valid input passes.""" + is_valid, error = validate_input("What is Docker?") + assert is_valid + assert error is None + + def test_empty_input_not_allowed(self): + """Test empty input fails by default.""" + is_valid, _ = validate_input("") + assert not is_valid + + def test_empty_input_allowed(self): + """Test empty input passes when allowed.""" + is_valid, _ = validate_input("", allow_empty=True) + assert is_valid + + def test_max_length(self): + """Test input exceeding max length fails.""" + long_input = "a" * (MAX_INPUT_LENGTH + 1) + is_valid, error = validate_input(long_input) + assert not is_valid + assert "too long" in error.lower() + + def test_custom_max_length(self): + """Test custom max length works.""" + is_valid, _ = validate_input("hello", max_length=3) + assert not is_valid + + def test_blocked_patterns_in_input(self): + """Test blocked patterns are caught.""" + is_valid, error = validate_input("Let's run rm -rf / to clean up") + assert not is_valid + assert "blocked pattern" in error.lower() + + +class TestValidateQuestion: + """Tests for validate_question function.""" + + def test_valid_question(self): + """Test valid questions pass.""" + is_valid, _ = validate_question("What is the difference between Docker and VMs?") + assert is_valid + + def test_empty_question(self): + """Test empty question fails.""" + is_valid, _ = validate_question("") + assert not is_valid + + +class TestValidateTopic: + """Tests for validate_topic function.""" + + def test_valid_topic(self): + """Test valid topics pass.""" + valid_topics = [ + "basic concepts", + "installation", + "advanced usage", + "best-practices", + ] + for topic in valid_topics: + is_valid, _ = validate_topic(topic) + assert is_valid, f"Expected {topic} to be valid" + + def test_empty_topic(self): + """Test empty topic fails.""" + is_valid, _ = validate_topic("") + assert not is_valid + + +class TestValidateScore: + """Tests for validate_score function.""" + + def test_valid_scores(self): + """Test valid scores pass.""" + valid_scores = [0.0, 0.5, 1.0, 0.75] + for score in valid_scores: + is_valid, _ = validate_score(score) + assert is_valid + + def test_out_of_range_scores(self): + """Test out-of-range scores fail.""" + invalid_scores = [-0.1, 1.1, -1, 2] + for score in invalid_scores: + is_valid, _ = validate_score(score) + assert not is_valid + + +class TestValidateLearningStyle: + """Tests for validate_learning_style function.""" + + def test_valid_styles(self): + """Test valid learning styles pass.""" + valid_styles = ["visual", "reading", "hands-on"] + for style in valid_styles: + is_valid, _ = validate_learning_style(style) + assert is_valid + + def test_invalid_style(self): + """Test invalid styles fail.""" + is_valid, _ = validate_learning_style("invalid") + assert not is_valid + + +class TestSanitizeInput: + """Tests for sanitize_input function.""" + + def test_strips_whitespace(self): + """Test whitespace is stripped.""" + assert sanitize_input(" hello ") == "hello" + + def test_removes_null_bytes(self): + """Test null bytes are removed.""" + assert sanitize_input("hello\x00world") == "helloworld" + + def test_truncates_long_input(self): + """Test long input is truncated.""" + long_input = "a" * 2000 + result = sanitize_input(long_input) + assert len(result) == MAX_INPUT_LENGTH + + def test_handles_empty(self): + """Test empty input returns empty string.""" + assert sanitize_input("") == "" + assert sanitize_input(None) == "" + + +class TestExtractPackageName: + """Tests for extract_package_name function.""" + + def test_extract_from_phrases(self): + """Test package extraction from various phrases.""" + test_cases = [ + ("Tell me about docker", "docker"), + ("I want to learn git", "git"), + ("teach me nginx", "nginx"), + ("explain python", "python"), + ("how to use curl", "curl"), + ("what is redis", "redis"), + ("docker", "docker"), + ] + for phrase, expected in test_cases: + result = extract_package_name(phrase) + assert result == expected, f"Expected {expected} from '{phrase}', got {result}" + + def test_returns_none_for_invalid(self): + """Test None is returned for invalid inputs.""" + assert extract_package_name("") is None + assert extract_package_name(None) is None + + +class TestValidationResult: + """Tests for ValidationResult class.""" + + def test_bool_conversion(self): + """Test boolean conversion.""" + valid = ValidationResult(True) + invalid = ValidationResult(False, ["error"]) + + assert bool(valid) is True + assert bool(invalid) is False + + def test_str_representation(self): + """Test string representation.""" + valid = ValidationResult(True) + invalid = ValidationResult(False, ["error1", "error2"]) + + assert "passed" in str(valid).lower() + assert "error1" in str(invalid) + + +class TestGetValidationErrors: + """Tests for get_validation_errors function.""" + + def test_no_errors_when_all_valid(self): + """Test empty list when all inputs are valid.""" + errors = get_validation_errors( + package_name="docker", + topic="basics", + question="What is it?", + score=0.8, + ) + assert errors == [] + + def test_collects_all_errors(self): + """Test all errors are collected.""" + errors = get_validation_errors( + package_name="", + topic="", + score=1.5, + ) + assert len(errors) == 3 + + +class TestValidateAll: + """Tests for validate_all function.""" + + def test_valid_inputs(self): + """Test valid inputs return success.""" + result = validate_all(package_name="docker", score=0.8) + assert result.is_valid + assert len(result.errors) == 0 + + def test_invalid_inputs(self): + """Test invalid inputs return failure.""" + result = validate_all(package_name="", score=2.0) + assert not result.is_valid + assert len(result.errors) == 2