From 1fbed6a5d51aca0b1d97a29132249697bca1b8dd Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 00:29:39 +0530 Subject: [PATCH 01/32] [tutor] Add AI-Powered Installation Tutor (Issue #131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive AI tutor for package education with: - Interactive tutoring with Plan→Act→Reflect LangGraph workflow - LLM-powered lessons, code examples, and Q&A (Claude API via LangChain) - SQLite-based progress tracking with topic completion stats - Best practices and step-by-step tutorials - Rich terminal UI with branded output Features: - cortex tutor - Start interactive session - cortex tutor --list - Show studied packages - cortex tutor --progress - View learning progress - cortex tutor --reset - Reset progress Technical: - 266 tests with 85.61% coverage - Lazy imports for non-LLM operations (no API key needed for --list/--progress) - Pydantic models for type safety - 7-layer prompt architecture Closes #131 --- cortex/cli.py | 75 ++ cortex/tutor/__init__.py | 14 + cortex/tutor/agents/__init__.py | 9 + cortex/tutor/agents/tutor_agent/__init__.py | 10 + cortex/tutor/agents/tutor_agent/graph.py | 390 ++++++++ cortex/tutor/agents/tutor_agent/state.py | 244 +++++ .../tutor/agents/tutor_agent/tutor_agent.py | 447 +++++++++ cortex/tutor/branding.py | 267 ++++++ cortex/tutor/cli.py | 390 ++++++++ cortex/tutor/config.py | 147 +++ cortex/tutor/contracts/__init__.py | 10 + cortex/tutor/contracts/lesson_context.py | 179 ++++ cortex/tutor/contracts/progress_context.py | 176 ++++ cortex/tutor/memory/__init__.py | 9 + cortex/tutor/memory/sqlite_store.py | 531 +++++++++++ cortex/tutor/prompts/agents/tutor/system.md | 302 ++++++ .../tutor/prompts/tools/examples_provider.md | 156 ++++ .../tutor/prompts/tools/lesson_generator.md | 224 +++++ cortex/tutor/prompts/tools/qa_handler.md | 231 +++++ cortex/tutor/tests/__init__.py | 5 + cortex/tutor/tests/test_agent_methods.py | 439 +++++++++ cortex/tutor/tests/test_agentic_tools.py | 196 ++++ cortex/tutor/tests/test_branding.py | 248 +++++ cortex/tutor/tests/test_cli.py | 442 +++++++++ .../tutor/tests/test_deterministic_tools.py | 178 ++++ cortex/tutor/tests/test_integration.py | 363 ++++++++ cortex/tutor/tests/test_interactive_tutor.py | 262 ++++++ cortex/tutor/tests/test_progress_tracker.py | 304 +++++++ cortex/tutor/tests/test_tools.py | 308 +++++++ cortex/tutor/tests/test_tutor_agent.py | 317 +++++++ cortex/tutor/tests/test_validators.py | 302 ++++++ cortex/tutor/tools/__init__.py | 20 + cortex/tutor/tools/agentic/__init__.py | 16 + .../tutor/tools/agentic/examples_provider.py | 260 ++++++ .../tutor/tools/agentic/lesson_generator.py | 327 +++++++ cortex/tutor/tools/agentic/qa_handler.py | 344 +++++++ cortex/tutor/tools/deterministic/__init__.py | 17 + .../tools/deterministic/lesson_loader.py | 276 ++++++ .../tools/deterministic/progress_tracker.py | 346 +++++++ .../tutor/tools/deterministic/validators.py | 368 ++++++++ docs/AI_TUTOR.md | 857 ++++++++++++++++++ requirements.txt | 6 + 42 files changed, 10012 insertions(+) create mode 100644 cortex/tutor/__init__.py create mode 100644 cortex/tutor/agents/__init__.py create mode 100644 cortex/tutor/agents/tutor_agent/__init__.py create mode 100644 cortex/tutor/agents/tutor_agent/graph.py create mode 100644 cortex/tutor/agents/tutor_agent/state.py create mode 100644 cortex/tutor/agents/tutor_agent/tutor_agent.py create mode 100644 cortex/tutor/branding.py create mode 100644 cortex/tutor/cli.py create mode 100644 cortex/tutor/config.py create mode 100644 cortex/tutor/contracts/__init__.py create mode 100644 cortex/tutor/contracts/lesson_context.py create mode 100644 cortex/tutor/contracts/progress_context.py create mode 100644 cortex/tutor/memory/__init__.py create mode 100644 cortex/tutor/memory/sqlite_store.py create mode 100644 cortex/tutor/prompts/agents/tutor/system.md create mode 100644 cortex/tutor/prompts/tools/examples_provider.md create mode 100644 cortex/tutor/prompts/tools/lesson_generator.md create mode 100644 cortex/tutor/prompts/tools/qa_handler.md create mode 100644 cortex/tutor/tests/__init__.py create mode 100644 cortex/tutor/tests/test_agent_methods.py create mode 100644 cortex/tutor/tests/test_agentic_tools.py create mode 100644 cortex/tutor/tests/test_branding.py create mode 100644 cortex/tutor/tests/test_cli.py create mode 100644 cortex/tutor/tests/test_deterministic_tools.py create mode 100644 cortex/tutor/tests/test_integration.py create mode 100644 cortex/tutor/tests/test_interactive_tutor.py create mode 100644 cortex/tutor/tests/test_progress_tracker.py create mode 100644 cortex/tutor/tests/test_tools.py create mode 100644 cortex/tutor/tests/test_tutor_agent.py create mode 100644 cortex/tutor/tests/test_validators.py create mode 100644 cortex/tutor/tools/__init__.py create mode 100644 cortex/tutor/tools/agentic/__init__.py create mode 100644 cortex/tutor/tools/agentic/examples_provider.py create mode 100644 cortex/tutor/tools/agentic/lesson_generator.py create mode 100644 cortex/tutor/tools/agentic/qa_handler.py create mode 100644 cortex/tutor/tools/deterministic/__init__.py create mode 100644 cortex/tutor/tools/deterministic/lesson_loader.py create mode 100644 cortex/tutor/tools/deterministic/progress_tracker.py create mode 100644 cortex/tutor/tools/deterministic/validators.py create mode 100644 docs/AI_TUTOR.md diff --git a/cortex/cli.py b/cortex/cli.py index 9261a816..7c3d0f71 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -1013,6 +1013,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.cli import ( + cmd_teach, + cmd_question, + cmd_list_packages, + cmd_progress, + cmd_reset, + ) + from cortex.tutor.branding import print_banner + + # 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, force_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 wizard(self): """Interactive setup wizard for API key configuration""" show_banner() @@ -2130,6 +2190,19 @@ 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") + # Ask command ask_parser = subparsers.add_parser("ask", help="Ask a question about your system") ask_parser.add_argument("question", type=str, help="Natural language question") @@ -2502,6 +2575,8 @@ def main(): return cli.wizard() elif args.command == "status": return cli.status() + elif args.command == "tutor": + return cli.tutor(args) elif args.command == "ask": return cli.ask(args.question) elif args.command == "install": diff --git a/cortex/tutor/__init__.py b/cortex/tutor/__init__.py new file mode 100644 index 00000000..6c6ed85f --- /dev/null +++ b/cortex/tutor/__init__.py @@ -0,0 +1,14 @@ +""" +Intelligent Tutor - AI-Powered Installation Tutor for Cortex Linux. + +An interactive AI tutor that teaches users about packages and best practices +using LangChain, LangGraph, and Claude API. +""" + +__version__ = "0.1.0" +__author__ = "Sri Krishna Vamsi" + +from cortex.tutor.config import Config +from cortex.tutor.branding import console, tutor_print + +__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..9ec39200 --- /dev/null +++ b/cortex/tutor/agents/tutor_agent/__init__.py @@ -0,0 +1,10 @@ +""" +Tutor Agent - Main LangGraph workflow for interactive tutoring. + +Implements Plan→Act→Reflect pattern for package education. +""" + +from cortex.tutor.agents.tutor_agent.state import TutorAgentState +from cortex.tutor.agents.tutor_agent.tutor_agent import TutorAgent, InteractiveTutor + +__all__ = ["TutorAgent", "TutorAgentState", "InteractiveTutor"] diff --git a/cortex/tutor/agents/tutor_agent/graph.py b/cortex/tutor/agents/tutor_agent/graph.py new file mode 100644 index 00000000..30573605 --- /dev/null +++ b/cortex/tutor/agents/tutor_agent/graph.py @@ -0,0 +1,390 @@ +""" +Tutor Agent Graph - LangGraph workflow definition. + +Implements the Plan→Act→Reflect pattern for interactive tutoring. +""" + +from typing import Literal + +from langgraph.graph import StateGraph, END + +from cortex.tutor.agents.tutor_agent.state import ( + TutorAgentState, + add_error, + add_checkpoint, + add_cost, + has_critical_error, + get_session_type, + get_package_name, +) +from cortex.tutor.tools.deterministic.lesson_loader import LessonLoaderTool +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool +from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool +from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool + + +# ==================== Node Functions ==================== + + +def plan_node(state: TutorAgentState) -> TutorAgentState: + """ + PLAN Phase: Decide on strategy for handling the request. + + Implements hybrid approach: + 1. Check cache first (deterministic, free) + 2. Use rules for simple requests + 3. Use LLM planner for complex decisions + """ + package_name = get_package_name(state) + session_type = get_session_type(state) + force_fresh = state.get("force_fresh", False) + + add_checkpoint(state, "plan_start", "ok", f"Planning for {package_name}") + + # Load student profile (deterministic) + progress_tool = ProgressTrackerTool() + profile_result = progress_tool._run("get_profile") + if profile_result.get("success"): + state["student_profile"] = profile_result["profile"] + + # Q&A mode - skip cache, go directly to Q&A + if session_type == "qa": + state["plan"] = { + "strategy": "qa_mode", + "cached_data": None, + "estimated_cost": 0.02, + "reasoning": "Q&A session requested, using qa_handler", + } + add_checkpoint(state, "plan_complete", "ok", "Strategy: qa_mode") + return state + + # Check cache (deterministic, free) + if not force_fresh: + loader = LessonLoaderTool() + cache_result = loader._run(package_name) + + if cache_result.get("cache_hit"): + state["plan"] = { + "strategy": "use_cache", + "cached_data": cache_result["lesson"], + "estimated_cost": 0.0, + "reasoning": "Valid cache found, reusing existing lesson", + } + state["cache_hit"] = True + state["cost_saved_gbp"] = 0.02 + add_checkpoint(state, "plan_complete", "ok", "Strategy: use_cache") + return state + + # No cache - need to generate + state["plan"] = { + "strategy": "generate_full", + "cached_data": None, + "estimated_cost": 0.02, + "reasoning": "No valid cache, generating fresh lesson", + } + add_checkpoint(state, "plan_complete", "ok", "Strategy: generate_full") + return state + + +def load_cache_node(state: TutorAgentState) -> TutorAgentState: + """ + ACT Phase - Cache Path: Load lesson from cache. + + This node is reached when plan.strategy == "use_cache". + """ + cached_data = state.get("plan", {}).get("cached_data", {}) + + if cached_data: + state["lesson_content"] = cached_data + state["results"] = { + "type": "lesson", + "content": cached_data, + "source": "cache", + } + add_checkpoint(state, "cache_load", "ok", "Loaded lesson from cache") + else: + add_error(state, "load_cache", "Cache data missing", recoverable=True) + + return state + + +def generate_lesson_node(state: TutorAgentState) -> TutorAgentState: + """ + ACT Phase - Generation Path: Generate new lesson content. + + Uses LessonGeneratorTool to create comprehensive lesson. + """ + package_name = get_package_name(state) + profile = state.get("student_profile", {}) + + add_checkpoint(state, "generate_start", "ok", f"Generating lesson for {package_name}") + + try: + generator = LessonGeneratorTool() + result = generator._run( + package_name=package_name, + student_level="beginner", # Could be dynamic based on profile + learning_style=profile.get("learning_style", "reading"), + skip_areas=profile.get("mastered_concepts", []), + ) + + if result.get("success"): + state["lesson_content"] = result["lesson"] + state["results"] = { + "type": "lesson", + "content": result["lesson"], + "source": "generated", + } + add_cost(state, result.get("cost_gbp", 0.02)) + + # Cache the generated lesson + loader = LessonLoaderTool() + loader.cache_lesson(package_name, result["lesson"]) + + add_checkpoint(state, "generate_complete", "ok", "Lesson generated and cached") + else: + add_error(state, "generate_lesson", result.get("error", "Unknown error")) + add_checkpoint(state, "generate_complete", "error", "Generation failed") + + except Exception as e: + add_error(state, "generate_lesson", str(e)) + add_checkpoint(state, "generate_complete", "error", str(e)) + + return state + + +def qa_node(state: TutorAgentState) -> TutorAgentState: + """ + ACT Phase - Q&A Path: Handle user questions. + + Uses QAHandlerTool for free-form questions. + """ + input_data = state.get("input", {}) + question = input_data.get("question", "") + package_name = get_package_name(state) + profile = state.get("student_profile", {}) + + if not question: + add_error(state, "qa", "No question provided", recoverable=False) + return state + + add_checkpoint(state, "qa_start", "ok", f"Answering question about {package_name}") + + try: + qa_handler = QAHandlerTool() + result = qa_handler._run( + package_name=package_name, + question=question, + learning_style=profile.get("learning_style", "reading"), + mastered_concepts=profile.get("mastered_concepts", []), + weak_concepts=profile.get("weak_concepts", []), + ) + + if result.get("success"): + state["qa_result"] = result["answer"] + state["results"] = { + "type": "qa", + "content": result["answer"], + "source": "generated", + } + add_cost(state, result.get("cost_gbp", 0.02)) + add_checkpoint(state, "qa_complete", "ok", "Question answered") + else: + add_error(state, "qa", result.get("error", "Unknown error")) + + except Exception as e: + add_error(state, "qa", str(e)) + + return state + + +def reflect_node(state: TutorAgentState) -> TutorAgentState: + """ + REFLECT Phase: Validate results and prepare output. + + 1. Deterministic validation (free) + 2. Prepare final output + """ + add_checkpoint(state, "reflect_start", "ok", "Validating results") + + results = state.get("results", {}) + errors = state.get("errors", []) + + # Deterministic validation + validation_errors = [] + + # Check for content + if not results.get("content"): + validation_errors.append("No content generated") + + # Check for critical errors + if has_critical_error(state): + validation_errors.append("Critical errors occurred during processing") + + # Calculate confidence + confidence = 1.0 + if errors: + confidence -= 0.1 * len(errors) + if state.get("cache_hit"): + confidence = min(confidence, 0.95) # Cached content might be stale + + # Prepare output + content = results.get("content", {}) + output = { + "type": results.get("type", "unknown"), + "package_name": get_package_name(state), + "content": content, + "source": results.get("source", "unknown"), + "confidence": max(confidence, 0.0), + "cost_gbp": state.get("cost_gbp", 0.0), + "cost_saved_gbp": state.get("cost_saved_gbp", 0.0), + "cache_hit": state.get("cache_hit", False), + "validation_passed": len(validation_errors) == 0, + "validation_errors": validation_errors, + "checkpoints": state.get("checkpoints", []), + } + + state["output"] = output + add_checkpoint(state, "reflect_complete", "ok", f"Validation: {len(validation_errors)} errors") + + return state + + +def fail_node(state: TutorAgentState) -> TutorAgentState: + """ + Failure node: Handle unrecoverable errors. + """ + errors = state.get("errors", []) + error_messages = [e.get("error", "Unknown") for e in errors] + + state["output"] = { + "type": "error", + "package_name": get_package_name(state), + "content": None, + "source": "failed", + "confidence": 0.0, + "cost_gbp": state.get("cost_gbp", 0.0), + "cost_saved_gbp": 0.0, + "cache_hit": False, + "validation_passed": False, + "validation_errors": error_messages, + "checkpoints": state.get("checkpoints", []), + } + + return state + + +# ==================== Routing Functions ==================== + + +def route_after_plan( + state: TutorAgentState, +) -> Literal["load_cache", "generate_lesson", "qa", "fail"]: + """ + Route after PLAN phase based on strategy. + """ + if has_critical_error(state): + return "fail" + + strategy = state.get("plan", {}).get("strategy", "generate_full") + + if strategy == "use_cache": + return "load_cache" + elif strategy == "qa_mode": + return "qa" + else: + return "generate_lesson" + + +def route_after_act(state: TutorAgentState) -> Literal["reflect", "fail"]: + """ + Route after ACT phase. + """ + if has_critical_error(state): + return "fail" + + # Check if we have results + if not state.get("results"): + return "fail" + + return "reflect" + + +# ==================== Graph Builder ==================== + + +def create_tutor_graph() -> StateGraph: + """ + Create the LangGraph workflow for the Tutor Agent. + + Returns: + Compiled StateGraph ready for execution. + """ + # Create graph with state schema + graph = StateGraph(TutorAgentState) + + # Add nodes + graph.add_node("plan", plan_node) + graph.add_node("load_cache", load_cache_node) + graph.add_node("generate_lesson", generate_lesson_node) + graph.add_node("qa", qa_node) + graph.add_node("reflect", reflect_node) + graph.add_node("fail", fail_node) + + # Set entry point + graph.set_entry_point("plan") + + # Add conditional edges after PLAN + graph.add_conditional_edges( + "plan", + route_after_plan, + { + "load_cache": "load_cache", + "generate_lesson": "generate_lesson", + "qa": "qa", + "fail": "fail", + }, + ) + + # Add edges from ACT nodes to REFLECT + graph.add_conditional_edges( + "load_cache", + route_after_act, + {"reflect": "reflect", "fail": "fail"}, + ) + + graph.add_conditional_edges( + "generate_lesson", + route_after_act, + {"reflect": "reflect", "fail": "fail"}, + ) + + graph.add_conditional_edges( + "qa", + route_after_act, + {"reflect": "reflect", "fail": "fail"}, + ) + + # End edges + graph.add_edge("reflect", END) + graph.add_edge("fail", END) + + return graph.compile() + + +# Create singleton graph instance +_graph = None + + +def get_tutor_graph() -> StateGraph: + """ + Get the singleton Tutor Agent graph. + + Returns: + Compiled StateGraph. + """ + global _graph + if _graph is None: + _graph = create_tutor_graph() + return _graph diff --git a/cortex/tutor/agents/tutor_agent/state.py b/cortex/tutor/agents/tutor_agent/state.py new file mode 100644 index 00000000..51a2eb95 --- /dev/null +++ b/cortex/tutor/agents/tutor_agent/state.py @@ -0,0 +1,244 @@ +""" +Tutor Agent State - TypedDict for LangGraph workflow state. + +Defines the state schema that flows through the Plan→Act→Reflect workflow. +""" + +from typing import Any, Dict, List, Optional, TypedDict + + +class StudentProfileState(TypedDict, total=False): + """Student profile state within the agent.""" + + learning_style: str + mastered_concepts: List[str] + weak_concepts: List[str] + last_session: Optional[str] + + +class LessonContentState(TypedDict, total=False): + """Lesson content state.""" + + package_name: str + summary: str + explanation: str + use_cases: List[str] + best_practices: List[str] + code_examples: List[Dict[str, Any]] + tutorial_steps: List[Dict[str, Any]] + installation_command: str + confidence: float + + +class PlanState(TypedDict, total=False): + """Plan phase output state.""" + + strategy: str # "use_cache", "generate_full", "generate_quick", "qa_mode" + cached_data: Optional[Dict[str, Any]] + estimated_cost: float + reasoning: str + + +class ErrorState(TypedDict): + """Error entry in state.""" + + node: str + error: str + recoverable: bool + + +class TutorAgentState(TypedDict, total=False): + """ + Complete state for the Tutor Agent workflow. + + This state flows through all nodes in the LangGraph: + Plan → Act → Reflect → Output + + Attributes: + input: User input and request parameters + force_fresh: Skip cache and generate fresh content + plan: Output from the PLAN phase + student_profile: Student's learning profile + lesson_content: Generated or cached lesson content + qa_result: Result from Q&A if in qa_mode + results: Combined results from ACT phase + errors: List of errors encountered + checkpoints: Monitoring checkpoints + cost_gbp: Total cost accumulated + cache_hit: Whether cache was used + replan_count: Number of replanning attempts + output: Final output to return + """ + + # Input + input: Dict[str, Any] + force_fresh: bool + + # PLAN phase + plan: PlanState + + # Context + student_profile: StudentProfileState + + # ACT phase outputs + lesson_content: LessonContentState + qa_result: Optional[Dict[str, Any]] + examples_result: Optional[Dict[str, Any]] + + # Combined results + results: Dict[str, Any] + + # Errors and monitoring + errors: List[ErrorState] + checkpoints: List[Dict[str, Any]] + + # Costs + cost_gbp: float + cost_saved_gbp: float + + # Flags + cache_hit: bool + replan_count: int + + # Final output + output: Optional[Dict[str, Any]] + + +def create_initial_state( + package_name: str, + session_type: str = "lesson", + question: Optional[str] = None, + force_fresh: bool = False, +) -> TutorAgentState: + """ + Create initial state for a tutor session. + + Args: + package_name: Package to teach. + session_type: Type of session (lesson, qa, tutorial, quiz). + question: User question for Q&A mode. + force_fresh: Skip cache. + + Returns: + Initial TutorAgentState. + """ + return TutorAgentState( + input={ + "package_name": package_name, + "session_type": session_type, + "question": question, + }, + force_fresh=force_fresh, + plan={}, + student_profile={ + "learning_style": "reading", + "mastered_concepts": [], + "weak_concepts": [], + "last_session": None, + }, + lesson_content={}, + qa_result=None, + examples_result=None, + results={}, + errors=[], + checkpoints=[], + cost_gbp=0.0, + cost_saved_gbp=0.0, + cache_hit=False, + replan_count=0, + output=None, + ) + + +def add_error(state: TutorAgentState, node: str, error: str, recoverable: bool = True) -> None: + """ + Add an error to the state. + + Args: + state: Current state. + node: Node where error occurred. + error: Error message. + recoverable: Whether error is recoverable. + """ + if "errors" not in state: + state["errors"] = [] + state["errors"].append( + { + "node": node, + "error": error, + "recoverable": recoverable, + } + ) + + +def add_checkpoint(state: TutorAgentState, name: str, status: str, details: str = "") -> None: + """ + Add a monitoring checkpoint to the state. + + Args: + state: Current state. + name: Checkpoint name. + status: Status (ok, warning, error). + details: Additional details. + """ + if "checkpoints" not in state: + state["checkpoints"] = [] + state["checkpoints"].append( + { + "name": name, + "status": status, + "details": details, + } + ) + + +def add_cost(state: TutorAgentState, cost: float) -> None: + """ + Add cost to the state. + + Args: + state: Current state. + cost: Cost in GBP to add. + """ + current = state.get("cost_gbp", 0.0) + state["cost_gbp"] = current + cost + + +def has_critical_error(state: TutorAgentState) -> bool: + """ + Check if state has any non-recoverable errors. + + Args: + state: Current state. + + Returns: + True if there are critical errors. + """ + errors = state.get("errors", []) + return any(not e.get("recoverable", True) for e in errors) + + +def get_session_type(state: TutorAgentState) -> str: + """ + Get the session type from state. + + Args: + state: Current state. + + Returns: + Session type string. + """ + return state.get("input", {}).get("session_type", "lesson") + + +def get_package_name(state: TutorAgentState) -> str: + """ + Get the package name from state. + + Args: + state: Current state. + + Returns: + Package name string. + """ + return state.get("input", {}).get("package_name", "") 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..a6a38d45 --- /dev/null +++ b/cortex/tutor/agents/tutor_agent/tutor_agent.py @@ -0,0 +1,447 @@ +""" +Tutor Agent - Main orchestrator for interactive tutoring. + +Provides high-level interface for the Plan→Act→Reflect workflow. +""" + +from typing import Any, Dict, List, Optional + +from cortex.tutor.agents.tutor_agent.state import TutorAgentState, create_initial_state +from cortex.tutor.agents.tutor_agent.graph import get_tutor_graph +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.deterministic.validators import ( + validate_package_name, + validate_question, +) +from cortex.tutor.contracts.lesson_context import LessonContext +from cortex.tutor.branding import tutor_print, console + + +class TutorAgent: + """ + Main Tutor Agent class for interactive package education. + + Implements the Plan→Act→Reflect pattern using LangGraph for + comprehensive, adaptive tutoring sessions. + + Example: + >>> agent = TutorAgent() + >>> result = agent.teach("docker") + >>> print(result.summary) + + >>> answer = agent.ask("docker", "What's the difference between images and containers?") + >>> print(answer["answer"]) + """ + + def __init__(self, verbose: bool = False) -> None: + """ + Initialize the Tutor Agent. + + Args: + verbose: Enable verbose output for debugging. + """ + self.verbose = verbose + self.graph = get_tutor_graph() + self.progress_tool = ProgressTrackerTool() + + 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. + + Raises: + ValueError: If package name is invalid. + """ + # Validate input + 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") + + # Create initial state + state = create_initial_state( + package_name=package_name, + session_type="lesson", + force_fresh=force_fresh, + ) + + # Execute workflow + result = self.graph.invoke(state) + + # Update progress + if result.get("output", {}).get("validation_passed"): + self.progress_tool._run( + "update_progress", + package_name=package_name, + topic="overview", + ) + + if self.verbose: + self._print_execution_summary(result) + + return result.get("output", {}) + + 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. + + Raises: + ValueError: If inputs are invalid. + """ + # Validate inputs + 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") + + # Create initial state for Q&A + state = create_initial_state( + package_name=package_name, + session_type="qa", + question=question, + ) + + # Execute workflow + result = self.graph.invoke(state) + + if self.verbose: + self._print_execution_summary(result) + + return result.get("output", {}) + + def get_progress(self, package_name: Optional[str] = None) -> Dict[str, Any]: + """ + Get learning progress. + + Args: + package_name: Optional package to filter by. + + Returns: + Dict containing progress data. + """ + 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. + + Returns: + Dict containing student profile data. + """ + return self.progress_tool._run("get_profile") + + def update_learning_style(self, style: str) -> bool: + """ + Update preferred learning style. + + Args: + style: Learning style (visual, reading, hands-on). + + Returns: + True if successful. + """ + 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. + + Args: + package_name: Package name. + topic: Topic that was completed. + score: Score achieved (0.0 to 1.0). + + Returns: + True if successful. + """ + 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: Optional[str] = None) -> int: + """ + Reset learning progress. + + Args: + package_name: Optional package to reset. If None, resets all. + + Returns: + Number of records reset. + """ + 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. + + Returns: + List of package names. + """ + result = self.progress_tool._run("get_packages") + return result.get("packages", []) if result.get("success") else [] + + def _print_execution_summary(self, result: Dict[str, Any]) -> None: + """Print execution summary for verbose mode.""" + output = result.get("output", {}) + + console.print("\n[dim]--- Execution Summary ---[/dim]") + console.print(f"[dim]Type: {output.get('type', 'unknown')}[/dim]") + console.print(f"[dim]Source: {output.get('source', 'unknown')}[/dim]") + console.print(f"[dim]Cache hit: {output.get('cache_hit', False)}[/dim]") + console.print(f"[dim]Cost: \u00a3{output.get('cost_gbp', 0):.4f}[/dim]") + console.print(f"[dim]Saved: \u00a3{output.get('cost_saved_gbp', 0):.4f}[/dim]") + console.print(f"[dim]Confidence: {output.get('confidence', 0):.0%}[/dim]") + console.print( + f"[dim]Validation: {'passed' if output.get('validation_passed') else 'failed'}[/dim]" + ) + + if output.get("validation_errors"): + console.print("[dim]Errors:[/dim]") + for err in output["validation_errors"]: + console.print(f"[dim] - {err}[/dim]") + + +class InteractiveTutor: + """ + Interactive tutoring session manager. + + Provides a menu-driven interface for learning packages. + """ + + def __init__(self, package_name: str) -> None: + """ + Initialize interactive tutor for a package. + + Args: + package_name: Package to learn. + """ + self.package_name = package_name + self.agent = TutorAgent(verbose=False) + self.lesson: Optional[Dict[str, Any]] = None + self.current_step = 0 + + def start(self) -> None: + """Start the interactive tutoring session.""" + from cortex.tutor.branding import ( + print_lesson_header, + print_menu, + get_user_input, + print_markdown, + print_code_example, + print_best_practice, + print_tutorial_step, + ) + + # Load lesson + tutor_print(f"Loading lesson for {self.package_name}...", "tutor") + result = self.agent.teach(self.package_name) + + 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) + + # Print summary + console.print(f"\n{self.lesson.get('summary', '')}\n") + + # Main menu loop + 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/explanation.""" + 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}") + + # Mark as viewed + 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 print_tutorial_step, get_user_input + + 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']}") + + if step.get("expected_output"): + console.print(f"[dim]Expected: {step['expected_output']}[/dim]") + + 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") + result = self.agent.ask(self.package_name, question) + + 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 5, # Default 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..da10143e --- /dev/null +++ b/cortex/tutor/branding.py @@ -0,0 +1,267 @@ +""" +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.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn +from rich.table import Table +from rich.text import Text +from rich.markdown import Markdown +from rich.syntax import Syntax + + +# 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: Optional[str] = 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: Optional[str] = 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. + + 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..eb5c4730 --- /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 typing import List, Optional + +from cortex.tutor import __version__ +from cortex.tutor.branding import ( + console, + print_banner, + tutor_print, + print_table, + print_progress_summary, + print_error_panel, + print_success_panel, + get_user_input, +) +from cortex.tutor.tools.deterministic.validators import validate_package_name +from cortex.tutor.config import Config +from cortex.tutor.memory.sqlite_store import SQLiteStore + + +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) + 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(f"\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. + + 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 cmd_progress(package: Optional[str] = None, verbose: bool = False) -> int: + """ + Show learning progress. + + Args: + package: Optional package filter. + + 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()) + + if package: + # Show progress for specific package + stats = store.get_completion_stats(package) + if stats: + print_progress_summary( + stats.get("completed", 0), + stats.get("total", 0) or 5, + 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") + else: + # Show all progress + progress_list = store.get_all_progress() + + if not progress_list: + tutor_print("No learning progress yet.", "info") + return 0 + + # Group by package (progress_list contains Pydantic models) + by_package = {} + for p in progress_list: + pkg = p.package_name + if pkg not in by_package: + by_package[pkg] = [] + by_package[pkg].append(p) + + # Display table + 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 0 + + except Exception as e: + print_error_panel(f"Error: {e}") + return 1 + + +def cmd_reset(package: Optional[str] = None, verbose: bool = False) -> int: + """ + Reset learning progress. + + Args: + package: Optional package to reset. If None, resets all. + + 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: Optional[List[str]] = 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..097a74dc --- /dev/null +++ b/cortex/tutor/config.py @@ -0,0 +1,147 @@ +""" +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() + + +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: Optional[str] = Field( + default=None, description="Anthropic API key for Claude access" + ) + openai_api_key: Optional[str] = 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 = 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: Optional[Config] = 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..31ffcf02 --- /dev/null +++ b/cortex/tutor/contracts/lesson_context.py @@ -0,0 +1,179 @@ +""" +Lesson Context - Pydantic contract for lesson generation output. + +Defines the structured output schema for lesson content. +""" + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional + +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: Optional[str] = Field(default=None, description="Optional code for this step") + expected_output: Optional[str] = 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: Optional[str] = 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=datetime.utcnow, 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: Optional[Dict[str, Any]] = 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..f68355f7 --- /dev/null +++ b/cortex/tutor/contracts/progress_context.py @@ -0,0 +1,176 @@ +""" +Progress Context - Pydantic contract for learning progress output. + +Defines the structured output schema for progress tracking. +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +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: Optional[datetime] = 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: Optional[datetime] = Field(default=None, description="When learning started") + last_session: Optional[datetime] = 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) -> Optional[str]: + """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=datetime.utcnow, description="Last update timestamp" + ) + + def get_package_progress(self, package_name: str) -> Optional[PackageProgress]: + """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=datetime.utcnow, description="Quiz completion time") + + @classmethod + def from_results( + cls, + package_name: str, + correct: int, + total: int, + feedback: str = "", + ) -> "QuizContext": + """Create QuizContext from raw results.""" + score = (correct / total * 100) if total > 0 else 0 + 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/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..0b855598 --- /dev/null +++ b/cortex/tutor/memory/sqlite_store.py @@ -0,0 +1,531 @@ +""" +SQLite storage for Intelligent Tutor learning progress. + +Provides persistence for learning progress, quiz results, and student profiles. +""" + +import json +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from pathlib import Path +from threading import RLock +from typing import Any, Dict, Generator, List, Optional + +from pydantic import BaseModel + + +class LearningProgress(BaseModel): + """Model for learning progress records.""" + + id: Optional[int] = None + package_name: str + topic: str + completed: bool = False + score: float = 0.0 + last_accessed: Optional[str] = None + total_time_seconds: int = 0 + + +class QuizResult(BaseModel): + """Model for quiz result records.""" + + id: Optional[int] = None + package_name: str + question: str + user_answer: Optional[str] = None + correct: bool = False + timestamp: Optional[str] = None + + +class StudentProfile(BaseModel): + """Model for student profile.""" + + id: Optional[int] = None + mastered_concepts: List[str] = [] + weak_concepts: List[str] = [] + learning_style: str = "reading" # visual, reading, hands-on + last_session: Optional[str] = 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); + """ + + 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) -> Optional[LearningProgress]: + """ + 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: Optional[str] = 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() + 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("SELECT * FROM student_profile LIMIT 1") + 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.""" + profile = StudentProfile() + with self._get_connection() as conn: + conn.execute( + """ + INSERT 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() + 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. + + Args: + concept: Concept that was mastered. + """ + profile = self.get_student_profile() + if concept not in profile.mastered_concepts: + profile.mastered_concepts.append(concept) + # Remove from weak concepts if present + if concept in profile.weak_concepts: + profile.weak_concepts.remove(concept) + self.update_student_profile(profile) + + def add_weak_concept(self, concept: str) -> None: + """ + Add a weak concept to the student profile. + + Args: + concept: Concept the student struggles with. + """ + profile = self.get_student_profile() + if concept not in profile.weak_concepts and concept not in profile.mastered_concepts: + profile.weak_concepts.append(concept) + self.update_student_profile(profile) + + # ==================== 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) -> Optional[Dict[str, Any]]: + """ + 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: Optional[str] = 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/tests/__init__.py b/cortex/tutor/tests/__init__.py new file mode 100644 index 00000000..3d1f683a --- /dev/null +++ b/cortex/tutor/tests/__init__.py @@ -0,0 +1,5 @@ +""" +Tests for Intelligent Tutor. + +Provides unit and integration tests with >80% coverage target. +""" diff --git a/cortex/tutor/tests/test_agent_methods.py b/cortex/tutor/tests/test_agent_methods.py new file mode 100644 index 00000000..31d97e05 --- /dev/null +++ b/cortex/tutor/tests/test_agent_methods.py @@ -0,0 +1,439 @@ +""" +Tests for TutorAgent methods and graph nodes. + +Comprehensive tests for agent functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import tempfile +from pathlib import Path + +from cortex.tutor.agents.tutor_agent.state import ( + TutorAgentState, + create_initial_state, + add_error, + add_checkpoint, + add_cost, + has_critical_error, + get_session_type, + get_package_name, +) +from cortex.tutor.agents.tutor_agent.graph import ( + plan_node, + load_cache_node, + generate_lesson_node, + qa_node, + reflect_node, + fail_node, + route_after_plan, + route_after_act, + create_tutor_graph, + get_tutor_graph, +) + + +class TestTutorAgentMethods: + """Tests for TutorAgent class methods.""" + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_teach_success(self, mock_tracker_class, mock_graph): + """Test successful teach method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + mock_g = Mock() + mock_g.invoke.return_value = { + "output": { + "validation_passed": True, + "type": "lesson", + "content": {"summary": "Docker is..."}, + } + } + mock_graph.return_value = mock_g + + agent = TutorAgent(verbose=False) + result = agent.teach("docker") + + assert result["validation_passed"] is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_teach_verbose(self, mock_tracker_class, mock_graph): + """Test teach with verbose mode.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + mock_g = Mock() + mock_g.invoke.return_value = { + "output": { + "validation_passed": True, + "type": "lesson", + "source": "cache", + "cache_hit": True, + "cost_gbp": 0.0, + "cost_saved_gbp": 0.02, + "confidence": 0.9, + } + } + mock_graph.return_value = mock_g + + with patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print"): + with patch("cortex.tutor.agents.tutor_agent.tutor_agent.console"): + agent = TutorAgent(verbose=True) + result = agent.teach("docker") + + assert result["validation_passed"] is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_ask_success(self, mock_tracker_class, mock_graph): + """Test successful ask method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker_class.return_value = mock_tracker + + mock_g = Mock() + mock_g.invoke.return_value = { + "output": { + "validation_passed": True, + "type": "qa", + "content": {"answer": "Docker is a container platform."}, + } + } + mock_graph.return_value = mock_g + + agent = TutorAgent() + result = agent.ask("docker", "What is Docker?") + + assert result["validation_passed"] is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_get_profile(self, mock_tracker_class, mock_graph): + """Test get_profile method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = { + "success": True, + "profile": {"learning_style": "visual"}, + } + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.get_profile() + + assert result["success"] is True + assert "profile" in result + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_update_learning_style(self, mock_tracker_class, mock_graph): + """Test update_learning_style method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.update_learning_style("visual") + + assert result is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_mark_completed(self, mock_tracker_class, mock_graph): + """Test mark_completed method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.mark_completed("docker", "basics", 0.9) + + assert result is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_reset_progress(self, mock_tracker_class, mock_graph): + """Test reset_progress method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "count": 5} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.reset_progress() + + assert result == 5 + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_get_packages_studied(self, mock_tracker_class, mock_graph): + """Test get_packages_studied method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "packages": ["docker", "nginx"]} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.get_packages_studied() + + assert result == ["docker", "nginx"] + + +class TestGenerateLessonNode: + """Tests for generate_lesson_node.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + def test_generate_lesson_success(self, mock_generator_class, mock_loader_class): + """Test successful lesson generation.""" + mock_generator = Mock() + mock_generator._run.return_value = { + "success": True, + "lesson": { + "package_name": "docker", + "summary": "Docker is a container platform.", + "explanation": "Docker allows...", + }, + "cost_gbp": 0.02, + } + mock_generator_class.return_value = mock_generator + + mock_loader = Mock() + mock_loader.cache_lesson.return_value = True + mock_loader_class.return_value = mock_loader + + state = create_initial_state("docker") + state["student_profile"] = {"learning_style": "reading"} + + result = generate_lesson_node(state) + + assert result["results"]["type"] == "lesson" + assert result["results"]["source"] == "generated" + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + def test_generate_lesson_failure(self, mock_generator_class): + """Test lesson generation failure.""" + mock_generator = Mock() + mock_generator._run.return_value = { + "success": False, + "error": "API error", + } + mock_generator_class.return_value = mock_generator + + state = create_initial_state("docker") + state["student_profile"] = {} + + result = generate_lesson_node(state) + + assert len(result["errors"]) > 0 + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + def test_generate_lesson_exception(self, mock_generator_class): + """Test lesson generation with exception.""" + mock_generator_class.side_effect = Exception("Test exception") + + state = create_initial_state("docker") + state["student_profile"] = {} + + result = generate_lesson_node(state) + + assert len(result["errors"]) > 0 + + +class TestQANode: + """Tests for qa_node.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_success(self, mock_qa_class): + """Test successful Q&A.""" + mock_qa = Mock() + mock_qa._run.return_value = { + "success": True, + "answer": { + "answer": "Docker is a containerization platform.", + "explanation": "It allows...", + }, + "cost_gbp": 0.02, + } + mock_qa_class.return_value = mock_qa + + state = create_initial_state("docker", session_type="qa", question="What is Docker?") + state["student_profile"] = {} + + result = qa_node(state) + + assert result["results"]["type"] == "qa" + assert result["qa_result"] is not None + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_no_question(self, mock_qa_class): + """Test Q&A without question.""" + state = create_initial_state("docker", session_type="qa") + # No question provided + + result = qa_node(state) + + assert len(result["errors"]) > 0 + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_failure(self, mock_qa_class): + """Test Q&A failure.""" + mock_qa = Mock() + mock_qa._run.return_value = { + "success": False, + "error": "Could not answer", + } + mock_qa_class.return_value = mock_qa + + state = create_initial_state("docker", session_type="qa", question="What?") + state["student_profile"] = {} + + result = qa_node(state) + + assert len(result["errors"]) > 0 + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_exception(self, mock_qa_class): + """Test Q&A with exception.""" + mock_qa_class.side_effect = Exception("Test error") + + state = create_initial_state("docker", session_type="qa", question="What?") + state["student_profile"] = {} + + result = qa_node(state) + + assert len(result["errors"]) > 0 + + +class TestReflectNode: + """Tests for reflect_node.""" + + def test_reflect_with_errors(self): + """Test reflect with non-critical errors.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {"summary": "Test"}, "source": "cache"} + add_error(state, "test", "Minor error", recoverable=True) + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is True + assert result["output"]["confidence"] < 1.0 + + def test_reflect_with_critical_error(self): + """Test reflect with critical error.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {"summary": "Test"}} + add_error(state, "test", "Critical error", recoverable=False) + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is False + + +class TestFailNode: + """Tests for fail_node.""" + + def test_fail_node_with_errors(self): + """Test fail node with multiple errors.""" + state = create_initial_state("docker") + add_error(state, "test1", "Error 1") + add_error(state, "test2", "Error 2") + state["cost_gbp"] = 0.01 + + result = fail_node(state) + + assert result["output"]["type"] == "error" + assert result["output"]["validation_passed"] is False + assert len(result["output"]["validation_errors"]) == 2 + + +class TestRouting: + """Tests for routing functions.""" + + def test_route_after_plan_fail_on_error(self): + """Test routing to fail on critical error.""" + state = create_initial_state("docker") + add_error(state, "test", "Critical", recoverable=False) + + route = route_after_plan(state) + assert route == "fail" + + def test_route_after_act_fail_no_results(self): + """Test routing to fail when no results.""" + state = create_initial_state("docker") + state["results"] = {} + + route = route_after_act(state) + assert route == "fail" + + def test_route_after_act_success(self): + """Test routing to reflect on success.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {}} + + route = route_after_act(state) + assert route == "reflect" + + +class TestGraphCreation: + """Tests for graph creation.""" + + def test_create_tutor_graph(self): + """Test graph is created successfully.""" + graph = create_tutor_graph() + assert graph is not None + + def test_get_tutor_graph_singleton(self): + """Test get_tutor_graph returns singleton.""" + graph1 = get_tutor_graph() + graph2 = get_tutor_graph() + assert graph1 is graph2 + + +class TestStateHelpers: + """Tests for state helper functions.""" + + def test_add_checkpoint(self): + """Test add_checkpoint adds to list.""" + state = create_initial_state("docker") + add_checkpoint(state, "test", "ok", "Test checkpoint") + + assert len(state["checkpoints"]) == 1 + assert state["checkpoints"][0]["name"] == "test" + assert state["checkpoints"][0]["status"] == "ok" + + def test_add_cost(self): + """Test add_cost accumulates.""" + state = create_initial_state("docker") + add_cost(state, 0.01) + add_cost(state, 0.02) + add_cost(state, 0.005) + + assert abs(state["cost_gbp"] - 0.035) < 0.0001 + + def test_get_session_type_default(self): + """Test default session type.""" + state = create_initial_state("docker") + assert get_session_type(state) == "lesson" + + def test_get_package_name(self): + """Test getting package name.""" + state = create_initial_state("nginx") + assert get_package_name(state) == "nginx" diff --git a/cortex/tutor/tests/test_agentic_tools.py b/cortex/tutor/tests/test_agentic_tools.py new file mode 100644 index 00000000..ce08119a --- /dev/null +++ b/cortex/tutor/tests/test_agentic_tools.py @@ -0,0 +1,196 @@ +""" +Tests for agentic tools structure methods. + +Tests the _structure_response methods with mocked responses. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock + + +class TestLessonGeneratorStructure: + """Tests for LessonGeneratorTool structure methods.""" + + @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") + @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") + def test_structure_response_full(self, mock_llm_class, mock_config): + """Test structure_response with full response.""" + from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = LessonGeneratorTool() + + response = { + "package_name": "docker", + "summary": "Docker is a platform.", + "explanation": "Docker allows...", + "use_cases": ["Dev", "Prod"], + "best_practices": ["Use official images"], + "code_examples": [{"title": "Run", "code": "docker run", "language": "bash"}], + "tutorial_steps": [{"step_number": 1, "title": "Start", "content": "Begin"}], + "installation_command": "apt install docker", + "related_packages": ["podman"], + "confidence": 0.9, + } + + result = tool._structure_response(response, "docker") + + assert result["package_name"] == "docker" + assert result["summary"] == "Docker is a platform." + assert len(result["use_cases"]) == 2 + assert result["confidence"] == 0.9 + + @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") + @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") + def test_structure_response_minimal(self, mock_llm_class, mock_config): + """Test structure_response with minimal response.""" + from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = LessonGeneratorTool() + + response = { + "package_name": "test", + "summary": "Test summary", + } + + result = tool._structure_response(response, "test") + + assert result["package_name"] == "test" + assert result["use_cases"] == [] + assert result["best_practices"] == [] + + +class TestExamplesProviderStructure: + """Tests for ExamplesProviderTool structure methods.""" + + @patch("cortex.tutor.tools.agentic.examples_provider.get_config") + @patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic") + def test_structure_response_full(self, mock_llm_class, mock_config): + """Test structure_response with full response.""" + from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = ExamplesProviderTool() + + response = { + "package_name": "git", + "topic": "branching", + "examples": [{"title": "Create", "code": "git checkout -b", "language": "bash"}], + "tips": ["Use descriptive names"], + "common_mistakes": ["Forgetting to commit"], + "confidence": 0.95, + } + + result = tool._structure_response(response, "git", "branching") + + assert result["package_name"] == "git" + assert result["topic"] == "branching" + assert len(result["examples"]) == 1 + + +class TestQAHandlerStructure: + """Tests for QAHandlerTool structure methods.""" + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_structure_response_full(self, mock_llm_class, mock_config): + """Test structure_response with full response.""" + from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = QAHandlerTool() + + response = { + "question_understood": "What is Docker?", + "answer": "Docker is a container platform.", + "explanation": "It allows packaging applications.", + "code_example": {"code": "docker run", "language": "bash"}, + "related_topics": ["containers", "images"], + "confidence": 0.9, + } + + result = tool._structure_response(response, "docker", "What is Docker?") + + assert result["answer"] == "Docker is a container platform." + assert result["code_example"] is not None + + +class TestConversationHandler: + """Tests for ConversationHandler.""" + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_build_context_empty(self, mock_llm_class, mock_config): + """Test context building with empty history.""" + from cortex.tutor.tools.agentic.qa_handler import ConversationHandler + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + handler = ConversationHandler("docker") + handler.history = [] + + context = handler._build_context() + assert "Starting fresh" in context + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_build_context_with_history(self, mock_llm_class, mock_config): + """Test context building with history.""" + from cortex.tutor.tools.agentic.qa_handler import ConversationHandler + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + handler = ConversationHandler("docker") + handler.history = [ + {"question": "What is Docker?", "answer": "A platform"}, + ] + + context = handler._build_context() + assert "What is Docker?" in context + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_clear_history(self, mock_llm_class, mock_config): + """Test clearing history.""" + from cortex.tutor.tools.agentic.qa_handler import ConversationHandler + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + handler = ConversationHandler("docker") + handler.history = [{"q": "test"}] + handler.clear_history() + + assert len(handler.history) == 0 diff --git a/cortex/tutor/tests/test_branding.py b/cortex/tutor/tests/test_branding.py new file mode 100644 index 00000000..e5f5c825 --- /dev/null +++ b/cortex/tutor/tests/test_branding.py @@ -0,0 +1,248 @@ +""" +Tests for branding/UI utilities. + +Tests Rich console output functions. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from io import StringIO + +from cortex.tutor.branding import ( + console, + tutor_print, + print_banner, + print_lesson_header, + print_code_example, + print_menu, + print_table, + print_progress_summary, + print_markdown, + print_best_practice, + print_tutorial_step, + print_error_panel, + print_success_panel, + get_user_input, +) + + +class TestConsole: + """Tests for console instance.""" + + def test_console_exists(self): + """Test console is initialized.""" + assert console is not None + + def test_console_is_rich(self): + """Test console is Rich Console.""" + from rich.console import Console + + assert isinstance(console, Console) + + +class TestTutorPrint: + """Tests for tutor_print function.""" + + def test_tutor_print_success(self, capsys): + """Test success status print.""" + tutor_print("Test message", "success") + # Rich output, just ensure no errors + + def test_tutor_print_error(self, capsys): + """Test error status print.""" + tutor_print("Error message", "error") + + def test_tutor_print_warning(self, capsys): + """Test warning status print.""" + tutor_print("Warning message", "warning") + + def test_tutor_print_info(self, capsys): + """Test info status print.""" + tutor_print("Info message", "info") + + def test_tutor_print_tutor(self, capsys): + """Test tutor status print.""" + tutor_print("Tutor message", "tutor") + + def test_tutor_print_default(self, capsys): + """Test default status print.""" + tutor_print("Default message") + + +class TestPrintBanner: + """Tests for print_banner function.""" + + def test_print_banner(self, capsys): + """Test banner prints without error.""" + print_banner() + # Just ensure no errors + + +class TestPrintLessonHeader: + """Tests for print_lesson_header function.""" + + def test_print_lesson_header(self, capsys): + """Test lesson header prints.""" + print_lesson_header("docker") + + def test_print_lesson_header_long_name(self, capsys): + """Test lesson header with long package name.""" + print_lesson_header("very-long-package-name-for-testing") + + +class TestPrintCodeExample: + """Tests for print_code_example function.""" + + def test_print_code_example_bash(self, capsys): + """Test code example with bash.""" + print_code_example("docker run nginx", "bash", "Run container") + + def test_print_code_example_python(self, capsys): + """Test code example with python.""" + print_code_example("print('hello')", "python", "Hello world") + + def test_print_code_example_no_title(self, capsys): + """Test code example without title.""" + print_code_example("echo hello", "bash") + + +class TestPrintMenu: + """Tests for print_menu function.""" + + def test_print_menu(self, capsys): + """Test menu prints.""" + options = ["Option 1", "Option 2", "Exit"] + print_menu(options) + + def test_print_menu_empty(self, capsys): + """Test empty menu.""" + print_menu([]) + + def test_print_menu_single(self, capsys): + """Test single option menu.""" + print_menu(["Only option"]) + + +class TestPrintTable: + """Tests for print_table function.""" + + def test_print_table(self, capsys): + """Test table prints.""" + headers = ["Name", "Value"] + rows = [["docker", "100"], ["nginx", "50"]] + print_table(headers, rows, "Test Table") + + def test_print_table_no_title(self, capsys): + """Test table without title.""" + headers = ["Col1", "Col2"] + rows = [["a", "b"]] + print_table(headers, rows) + + def test_print_table_empty_rows(self, capsys): + """Test table with empty rows.""" + headers = ["Header"] + print_table(headers, []) + + +class TestPrintProgressSummary: + """Tests for print_progress_summary function.""" + + def test_print_progress_summary(self, capsys): + """Test progress summary prints.""" + print_progress_summary(3, 5, "docker") + + def test_print_progress_summary_complete(self, capsys): + """Test progress summary when complete.""" + print_progress_summary(5, 5, "docker") + + def test_print_progress_summary_zero(self, capsys): + """Test progress summary with zero progress.""" + print_progress_summary(0, 5, "docker") + + +class TestPrintMarkdown: + """Tests for print_markdown function.""" + + def test_print_markdown(self, capsys): + """Test markdown prints.""" + print_markdown("# Header\n\nSome **bold** text.") + + def test_print_markdown_code(self, capsys): + """Test markdown with code block.""" + print_markdown("```bash\necho hello\n```") + + def test_print_markdown_list(self, capsys): + """Test markdown with list.""" + print_markdown("- Item 1\n- Item 2\n- Item 3") + + +class TestPrintBestPractice: + """Tests for print_best_practice function.""" + + def test_print_best_practice(self, capsys): + """Test best practice prints.""" + print_best_practice("Use official images", 1) + + def test_print_best_practice_long(self, capsys): + """Test best practice with long text.""" + long_text = "This is a very long best practice text " * 5 + print_best_practice(long_text, 10) + + +class TestPrintTutorialStep: + """Tests for print_tutorial_step function.""" + + def test_print_tutorial_step(self, capsys): + """Test tutorial step prints.""" + print_tutorial_step("Install Docker", 1, 5) + + def test_print_tutorial_step_last(self, capsys): + """Test last tutorial step.""" + print_tutorial_step("Finish setup", 5, 5) + + +class TestPrintErrorPanel: + """Tests for print_error_panel function.""" + + def test_print_error_panel(self, capsys): + """Test error panel prints.""" + print_error_panel("Something went wrong") + + def test_print_error_panel_long(self, capsys): + """Test error panel with long message.""" + print_error_panel("Error: " + "x" * 100) + + +class TestPrintSuccessPanel: + """Tests for print_success_panel function.""" + + def test_print_success_panel(self, capsys): + """Test success panel prints.""" + print_success_panel("Operation completed") + + def test_print_success_panel_long(self, capsys): + """Test success panel with long message.""" + print_success_panel("Success: " + "y" * 100) + + +class TestGetUserInput: + """Tests for get_user_input function.""" + + @patch("builtins.input", return_value="test input") + def test_get_user_input(self, mock_input): + """Test getting user input.""" + result = get_user_input("Enter value") + assert result == "test input" + + @patch("builtins.input", return_value="") + def test_get_user_input_empty(self, mock_input): + """Test empty user input.""" + result = get_user_input("Enter value") + assert result == "" + + @patch("builtins.input", return_value=" spaced ") + def test_get_user_input_strips(self, mock_input): + """Test input stripping is not done (raw input).""" + result = get_user_input("Enter value") + # Note: get_user_input should return raw input + assert "spaced" in result diff --git a/cortex/tutor/tests/test_cli.py b/cortex/tutor/tests/test_cli.py new file mode 100644 index 00000000..31966499 --- /dev/null +++ b/cortex/tutor/tests/test_cli.py @@ -0,0 +1,442 @@ +""" +Tests for CLI module. + +Comprehensive tests for command-line interface. +""" + +import os +import pytest +from unittest.mock import Mock, patch, MagicMock +from io import StringIO + +from cortex.tutor.cli import ( + create_parser, + cmd_teach, + cmd_question, + cmd_list_packages, + cmd_progress, + cmd_reset, + main, +) + + +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.""" + from cortex.tutor.config import reset_config + 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.""" + from cortex.tutor.config import reset_config + 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.""" + from cortex.tutor.config import reset_config + 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.""" + from cortex.tutor.config import reset_config + 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.""" + from cortex.tutor.config import reset_config + 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.""" + from cortex.tutor.config import reset_config + 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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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/cortex/tutor/tests/test_deterministic_tools.py b/cortex/tutor/tests/test_deterministic_tools.py new file mode 100644 index 00000000..f957677a --- /dev/null +++ b/cortex/tutor/tests/test_deterministic_tools.py @@ -0,0 +1,178 @@ +""" +Tests for deterministic tools. + +Tests for lesson_loader and progress_tracker. +""" + +import pytest +from unittest.mock import Mock, patch +import tempfile +from pathlib import Path + +from cortex.tutor.tools.deterministic.lesson_loader import ( + LessonLoaderTool, + get_fallback_lesson, + FALLBACK_LESSONS, +) +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/cortex/tutor/tests/test_integration.py b/cortex/tutor/tests/test_integration.py new file mode 100644 index 00000000..c12ca714 --- /dev/null +++ b/cortex/tutor/tests/test_integration.py @@ -0,0 +1,363 @@ +""" +Integration tests for Intelligent Tutor. + +End-to-end tests for the complete tutoring workflow. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import tempfile +from pathlib import Path +import os + +from cortex.tutor.config import Config, get_config, reset_config +from cortex.tutor.branding import tutor_print, console, print_banner +from cortex.tutor.contracts.lesson_context import LessonContext, CodeExample, TutorialStep +from cortex.tutor.contracts.progress_context import ( + ProgressContext, + PackageProgress, + 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 == 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 == 0.85 + + def test_lesson_context_display_dict(self): + """Test to_display_dict method.""" + lesson = LessonContext( + package_name="docker", + summary="Summary", + explanation="Explanation", + use_cases=["Use 1", "Use 2"], + best_practices=["Practice 1"], + installation_command="apt install docker.io", + confidence=0.9, + ) + + display = lesson.to_display_dict() + + assert display["package"] == "docker" + assert display["confidence"] == "90%" + + +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 == 50.0 + assert package.average_score == 0.7 + assert not package.is_complete() + assert package.get_next_topic() == "advanced" + + def test_progress_context_recommendations(self): + """Test getting learning recommendations.""" + progress = ProgressContext( + weak_concepts=["networking", "volumes"], + packages=[ + PackageProgress( + package_name="docker", + topics=[TopicProgress(topic="basics", completed=False)], + ) + ], + ) + + recommendations = progress.get_recommendations() + + assert len(recommendations) >= 1 + assert any("networking" in r.lower() or "docker" in r.lower() for r in recommendations) + + +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") + captured = capsys.readouterr() + # Rich console output is complex, just ensure no errors + + def test_tutor_print_error(self, capsys): + """Test tutor_print with error status.""" + tutor_print("Error message", "error") + captured = capsys.readouterr() + + 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() + + # Test help doesn't raise + 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 + + def test_parse_progress_flag(self): + """Test parsing progress flag.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + args = parser.parse_args(["--progress"]) + + assert args.progress is True + + def test_parse_reset_flag(self): + """Test parsing reset flag.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + + # Reset all + args = parser.parse_args(["--reset"]) + assert args.reset == "__all__" + + # Reset specific package + args = parser.parse_args(["--reset", "docker"]) + assert args.reset == "docker" + + +class TestEndToEnd: + """End-to-end workflow tests.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + def test_full_lesson_workflow_with_cache( + self, mock_tracker_class, mock_loader_class, mock_generator_class + ): + """Test complete lesson workflow with cache hit.""" + # Set up mocks + mock_tracker = Mock() + mock_tracker._run.return_value = { + "success": True, + "profile": { + "learning_style": "reading", + "mastered_concepts": [], + "weak_concepts": [], + }, + } + mock_tracker_class.return_value = mock_tracker + + cached_lesson = { + "package_name": "docker", + "summary": "Docker is a containerization platform.", + "explanation": "Docker allows...", + "use_cases": ["Development"], + "best_practices": ["Use official images"], + "code_examples": [], + "tutorial_steps": [], + "installation_command": "apt install docker.io", + "confidence": 0.9, + } + + mock_loader = Mock() + mock_loader._run.return_value = { + "cache_hit": True, + "lesson": cached_lesson, + "cost_saved_gbp": 0.02, + } + mock_loader.cache_lesson.return_value = True + mock_loader_class.return_value = mock_loader + + # Run workflow + from cortex.tutor.agents.tutor_agent.state import create_initial_state + from cortex.tutor.agents.tutor_agent.graph import ( + plan_node, + load_cache_node, + reflect_node, + ) + + state = create_initial_state("docker") + + # Execute nodes + state = plan_node(state) + assert state["plan"]["strategy"] == "use_cache" + assert state["cache_hit"] is True + + state = load_cache_node(state) + assert state["results"]["type"] == "lesson" + + state = reflect_node(state) + assert state["output"]["validation_passed"] is True + assert state["output"]["cache_hit"] is True + + # Note: Real API test removed - use manual testing for API integration + # Run: python -m cortex.tutor.cli docker diff --git a/cortex/tutor/tests/test_interactive_tutor.py b/cortex/tutor/tests/test_interactive_tutor.py new file mode 100644 index 00000000..a3e7b803 --- /dev/null +++ b/cortex/tutor/tests/test_interactive_tutor.py @@ -0,0 +1,262 @@ +""" +Tests for InteractiveTutor class. + +Tests the interactive menu-driven tutoring interface. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock, call + + +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 + assert tutor.current_step == 0 + + +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/cortex/tutor/tests/test_progress_tracker.py b/cortex/tutor/tests/test_progress_tracker.py new file mode 100644 index 00000000..fd4e500e --- /dev/null +++ b/cortex/tutor/tests/test_progress_tracker.py @@ -0,0 +1,304 @@ +""" +Tests for progress tracker and SQLite store. + +Tests learning progress persistence and retrieval. +""" + +import os +import tempfile +from pathlib import Path + +import pytest + +from cortex.tutor.memory.sqlite_store import ( + SQLiteStore, + LearningProgress, + QuizResult, + StudentProfile, +) +from cortex.tutor.tools.deterministic.progress_tracker import ( + ProgressTrackerTool, + get_learning_progress, + mark_topic_completed, + get_package_stats, +) + + +@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 == 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 == 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 == 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"] == 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"] == 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.""" + # Note: Uses global config, may need adjustment for isolated testing + pass + + def test_mark_topic_completed(self, temp_db): + """Test mark_topic_completed function.""" + pass + + def test_get_package_stats(self, temp_db): + """Test get_package_stats function.""" + pass diff --git a/cortex/tutor/tests/test_tools.py b/cortex/tutor/tests/test_tools.py new file mode 100644 index 00000000..859206d9 --- /dev/null +++ b/cortex/tutor/tests/test_tools.py @@ -0,0 +1,308 @@ +""" +Tests for deterministic and agentic tools. + +Tests tool functionality with mocked LLM calls. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import tempfile +from pathlib import Path + +from cortex.tutor.tools.deterministic.lesson_loader import ( + LessonLoaderTool, + get_fallback_lesson, + load_lesson_with_fallback, + FALLBACK_LESSONS, +) +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool +from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool +from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool, ConversationHandler + + +@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) + + # Cache a lesson + loader.cache_lesson("docker", {"summary": "cached"}) + + # Force fresh should skip cache + 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.""" + # First cache a lesson + 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"] + + +class TestLessonGeneratorTool: + """Tests for LessonGeneratorTool with mocked LLM.""" + + @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") + @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") + def test_generate_lesson_structure(self, mock_config, mock_llm_class): + """Test lesson generation returns proper structure.""" + # Mock config + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + + # Mock LLM response + mock_response = { + "package_name": "docker", + "summary": "Docker is a containerization platform.", + "explanation": "Docker allows you to...", + "use_cases": ["Development", "Deployment"], + "best_practices": ["Use official images"], + "code_examples": [ + { + "title": "Run container", + "code": "docker run nginx", + "language": "bash", + "description": "Runs nginx", + } + ], + "tutorial_steps": [ + { + "step_number": 1, + "title": "Install", + "content": "First, install Docker", + } + ], + "installation_command": "apt install docker.io", + "related_packages": ["podman"], + "confidence": 0.9, + } + + mock_chain = Mock() + mock_chain.invoke.return_value = mock_response + mock_llm = Mock() + mock_llm.__or__ = Mock(return_value=mock_chain) + mock_llm_class.return_value = mock_llm + + # Create tool and test + tool = LessonGeneratorTool() + tool.llm = mock_llm + + # Directly test structure method + result = tool._structure_response(mock_response, "docker") + + assert result["package_name"] == "docker" + assert "summary" in result + assert "explanation" in result + assert len(result["code_examples"]) == 1 + assert result["confidence"] == 0.9 + + def test_structure_response_handles_missing_fields(self): + """Test structure_response handles missing fields gracefully.""" + # Skip LLM initialization by mocking + with patch("cortex.tutor.tools.agentic.lesson_generator.get_config") as mock_config: + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + with patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic"): + tool = LessonGeneratorTool() + + incomplete_response = { + "package_name": "test", + "summary": "Test summary", + } + + result = tool._structure_response(incomplete_response, "test") + + assert result["package_name"] == "test" + assert result["summary"] == "Test summary" + assert result["use_cases"] == [] + assert result["best_practices"] == [] + + +class TestExamplesProviderTool: + """Tests for ExamplesProviderTool with mocked LLM.""" + + def test_structure_response(self): + """Test structure_response formats examples correctly.""" + with patch("cortex.tutor.tools.agentic.examples_provider.get_config") as mock_config: + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + with patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic"): + tool = ExamplesProviderTool() + + response = { + "package_name": "git", + "topic": "branching", + "examples": [ + { + "title": "Create branch", + "code": "git checkout -b feature", + "language": "bash", + "description": "Creates new branch", + } + ], + "tips": ["Use descriptive names"], + "common_mistakes": ["Forgetting to commit"], + "confidence": 0.95, + } + + result = tool._structure_response(response, "git", "branching") + + assert result["package_name"] == "git" + assert result["topic"] == "branching" + assert len(result["examples"]) == 1 + assert result["examples"][0]["title"] == "Create branch" + + +class TestQAHandlerTool: + """Tests for QAHandlerTool with mocked LLM.""" + + def test_structure_response(self): + """Test structure_response formats answers correctly.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config") as mock_config: + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + with patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic"): + tool = QAHandlerTool() + + response = { + "question_understood": "What is Docker?", + "answer": "Docker is a containerization platform.", + "explanation": "It allows you to package applications.", + "code_example": { + "code": "docker run hello-world", + "language": "bash", + "description": "Runs test container", + }, + "related_topics": ["containers", "images"], + "confidence": 0.9, + } + + result = tool._structure_response(response, "docker", "What is Docker?") + + assert result["answer"] == "Docker is a containerization platform." + assert result["code_example"] is not None + assert len(result["related_topics"]) == 2 + + +class TestConversationHandler: + """Tests for ConversationHandler.""" + + def test_build_context_empty(self): + """Test context building with empty history.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): + handler = ConversationHandler.__new__(ConversationHandler) + handler.history = [] + + context = handler._build_context() + assert "Starting fresh" in context + + def test_build_context_with_history(self): + """Test context building with history.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): + handler = ConversationHandler.__new__(ConversationHandler) + handler.history = [ + {"question": "What is Docker?", "answer": "A platform"}, + {"question": "How to install?", "answer": "Use apt"}, + ] + + context = handler._build_context() + assert "What is Docker?" in context + assert "Recent discussion" in context + + def test_clear_history(self): + """Test clearing conversation history.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): + handler = ConversationHandler.__new__(ConversationHandler) + handler.history = [{"question": "test", "answer": "test"}] + + handler.clear_history() + assert len(handler.history) == 0 diff --git a/cortex/tutor/tests/test_tutor_agent.py b/cortex/tutor/tests/test_tutor_agent.py new file mode 100644 index 00000000..abc24618 --- /dev/null +++ b/cortex/tutor/tests/test_tutor_agent.py @@ -0,0 +1,317 @@ +""" +Tests for TutorAgent and LangGraph workflow. + +Tests the main agent orchestrator and state management. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import tempfile +from pathlib import Path + +from cortex.tutor.agents.tutor_agent.state import ( + TutorAgentState, + create_initial_state, + add_error, + add_checkpoint, + add_cost, + has_critical_error, + get_session_type, + get_package_name, +) +from cortex.tutor.agents.tutor_agent.graph import ( + plan_node, + load_cache_node, + reflect_node, + fail_node, + route_after_plan, + route_after_act, +) + + +class TestTutorAgentState: + """Tests for TutorAgentState and state utilities.""" + + def test_create_initial_state(self): + """Test creating initial state.""" + state = create_initial_state( + package_name="docker", + session_type="lesson", + ) + + assert state["input"]["package_name"] == "docker" + assert state["input"]["session_type"] == "lesson" + assert state["force_fresh"] is False + assert state["errors"] == [] + assert state["cost_gbp"] == 0.0 + + def test_create_initial_state_qa_mode(self): + """Test creating initial state for Q&A.""" + state = create_initial_state( + package_name="docker", + session_type="qa", + question="What is Docker?", + ) + + assert state["input"]["session_type"] == "qa" + assert state["input"]["question"] == "What is Docker?" + + def test_add_error(self): + """Test adding errors to state.""" + state = create_initial_state("docker") + add_error(state, "test_node", "Test error", recoverable=True) + + assert len(state["errors"]) == 1 + assert state["errors"][0]["node"] == "test_node" + assert state["errors"][0]["error"] == "Test error" + assert state["errors"][0]["recoverable"] is True + + def test_add_checkpoint(self): + """Test adding checkpoints to state.""" + state = create_initial_state("docker") + add_checkpoint(state, "plan_start", "ok", "Planning started") + + assert len(state["checkpoints"]) == 1 + assert state["checkpoints"][0]["name"] == "plan_start" + assert state["checkpoints"][0]["status"] == "ok" + + def test_add_cost(self): + """Test adding cost to state.""" + state = create_initial_state("docker") + add_cost(state, 0.02) + add_cost(state, 0.01) + + assert state["cost_gbp"] == 0.03 + + def test_has_critical_error_false(self): + """Test has_critical_error returns False when no critical errors.""" + state = create_initial_state("docker") + add_error(state, "test", "Recoverable error", recoverable=True) + + assert has_critical_error(state) is False + + def test_has_critical_error_true(self): + """Test has_critical_error returns True when critical error exists.""" + state = create_initial_state("docker") + add_error(state, "test", "Critical error", recoverable=False) + + assert has_critical_error(state) is True + + def test_get_session_type(self): + """Test get_session_type utility.""" + state = create_initial_state("docker", session_type="qa") + assert get_session_type(state) == "qa" + + def test_get_package_name(self): + """Test get_package_name utility.""" + state = create_initial_state("nginx") + assert get_package_name(state) == "nginx" + + +class TestGraphNodes: + """Tests for LangGraph node functions.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + def test_plan_node_cache_hit(self, mock_loader_class, mock_tracker_class): + """Test plan_node with cache hit.""" + # Mock tracker + mock_tracker = Mock() + mock_tracker._run.return_value = { + "success": True, + "profile": { + "learning_style": "reading", + "mastered_concepts": [], + "weak_concepts": [], + }, + } + mock_tracker_class.return_value = mock_tracker + + # Mock loader with cache hit + mock_loader = Mock() + mock_loader._run.return_value = { + "cache_hit": True, + "lesson": {"summary": "Cached lesson"}, + } + mock_loader_class.return_value = mock_loader + + state = create_initial_state("docker") + result = plan_node(state) + + assert result["plan"]["strategy"] == "use_cache" + assert result["cache_hit"] is True + + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + def test_plan_node_cache_miss(self, mock_loader_class, mock_tracker_class): + """Test plan_node with cache miss.""" + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "profile": {}} + mock_tracker_class.return_value = mock_tracker + + mock_loader = Mock() + mock_loader._run.return_value = {"cache_hit": False, "lesson": None} + mock_loader_class.return_value = mock_loader + + state = create_initial_state("docker") + result = plan_node(state) + + assert result["plan"]["strategy"] == "generate_full" + + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + def test_plan_node_qa_mode(self, mock_tracker_class): + """Test plan_node in Q&A mode.""" + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "profile": {}} + mock_tracker_class.return_value = mock_tracker + + state = create_initial_state("docker", session_type="qa", question="What?") + result = plan_node(state) + + assert result["plan"]["strategy"] == "qa_mode" + + def test_load_cache_node(self): + """Test load_cache_node with cached data.""" + state = create_initial_state("docker") + state["plan"] = { + "strategy": "use_cache", + "cached_data": {"summary": "Cached lesson", "explanation": "..."}, + } + + result = load_cache_node(state) + + assert result["lesson_content"]["summary"] == "Cached lesson" + assert result["results"]["source"] == "cache" + + def test_load_cache_node_missing_data(self): + """Test load_cache_node handles missing cache data.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "use_cache", "cached_data": None} + + result = load_cache_node(state) + + assert len(result["errors"]) > 0 + + def test_reflect_node_success(self): + """Test reflect_node with successful results.""" + state = create_initial_state("docker") + state["results"] = { + "type": "lesson", + "content": {"summary": "Test"}, + "source": "generated", + } + state["errors"] = [] + state["cost_gbp"] = 0.02 + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is True + assert result["output"]["cost_gbp"] == 0.02 + + def test_reflect_node_failure(self): + """Test reflect_node with missing results.""" + state = create_initial_state("docker") + state["results"] = {} + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is False + assert "No content" in str(result["output"]["validation_errors"]) + + def test_fail_node(self): + """Test fail_node creates proper error output.""" + state = create_initial_state("docker") + add_error(state, "test", "Test error") + state["cost_gbp"] = 0.01 + + result = fail_node(state) + + assert result["output"]["type"] == "error" + assert result["output"]["validation_passed"] is False + assert "Test error" in result["output"]["validation_errors"] + + +class TestRouting: + """Tests for routing functions.""" + + def test_route_after_plan_use_cache(self): + """Test routing to cache path.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "use_cache"} + + route = route_after_plan(state) + assert route == "load_cache" + + def test_route_after_plan_generate(self): + """Test routing to generation path.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "generate_full"} + + route = route_after_plan(state) + assert route == "generate_lesson" + + def test_route_after_plan_qa(self): + """Test routing to Q&A path.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "qa_mode"} + + route = route_after_plan(state) + assert route == "qa" + + def test_route_after_plan_critical_error(self): + """Test routing to fail on critical error.""" + state = create_initial_state("docker") + add_error(state, "test", "Critical", recoverable=False) + + route = route_after_plan(state) + assert route == "fail" + + def test_route_after_act_success(self): + """Test routing after successful act phase.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {}} + + route = route_after_act(state) + assert route == "reflect" + + def test_route_after_act_no_results(self): + """Test routing to fail when no results.""" + state = create_initial_state("docker") + state["results"] = {} + + route = route_after_act(state) + assert route == "fail" + + +class TestTutorAgentIntegration: + """Integration tests for TutorAgent.""" + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + def test_teach_validation(self, mock_graph): + """Test teach validates package name.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + from cortex.tutor.config import reset_config + reset_config() + + with pytest.raises(ValueError) as exc_info: + agent = TutorAgent() + agent.teach("") + + assert "Invalid package name" in str(exc_info.value) + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + def test_ask_validation(self, mock_graph): + """Test ask validates inputs.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + from cortex.tutor.config import reset_config + reset_config() + + agent = TutorAgent() + + with pytest.raises(ValueError): + agent.ask("", "question") + + with pytest.raises(ValueError): + agent.ask("docker", "") diff --git a/cortex/tutor/tests/test_validators.py b/cortex/tutor/tests/test_validators.py new file mode 100644 index 00000000..fb041cba --- /dev/null +++ b/cortex/tutor/tests/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 ( + validate_package_name, + validate_input, + validate_question, + validate_topic, + validate_score, + validate_learning_style, + sanitize_input, + extract_package_name, + get_validation_errors, + validate_all, + ValidationResult, + MAX_INPUT_LENGTH, + MAX_PACKAGE_NAME_LENGTH, +) + + +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, error = 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, error = 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, error = validate_input("") + assert not is_valid + + def test_empty_input_allowed(self): + """Test empty input passes when allowed.""" + is_valid, error = 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, error = 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, error = validate_question("What is the difference between Docker and VMs?") + assert is_valid + + def test_empty_question(self): + """Test empty question fails.""" + is_valid, error = 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, error = validate_topic(topic) + assert is_valid, f"Expected {topic} to be valid" + + def test_empty_topic(self): + """Test empty topic fails.""" + is_valid, error = 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, error = 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, error = 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, error = validate_learning_style(style) + assert is_valid + + def test_invalid_style(self): + """Test invalid styles fail.""" + is_valid, error = 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 diff --git a/cortex/tutor/tools/__init__.py b/cortex/tutor/tools/__init__.py new file mode 100644 index 00000000..581822cc --- /dev/null +++ b/cortex/tutor/tools/__init__.py @@ -0,0 +1,20 @@ +""" +Tools for Intelligent Tutor. + +Provides deterministic and agentic tools for the tutoring workflow. +""" + +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.deterministic.validators import validate_package_name, validate_input +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool +from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool +from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + +__all__ = [ + "ProgressTrackerTool", + "validate_package_name", + "validate_input", + "LessonGeneratorTool", + "ExamplesProviderTool", + "QAHandlerTool", +] diff --git a/cortex/tutor/tools/agentic/__init__.py b/cortex/tutor/tools/agentic/__init__.py new file mode 100644 index 00000000..6d35abd4 --- /dev/null +++ b/cortex/tutor/tools/agentic/__init__.py @@ -0,0 +1,16 @@ +""" +Agentic tools for Intelligent Tutor. + +These tools use LLM calls for tasks requiring judgment and creativity. +Used for: lesson generation, code examples, Q&A handling. +""" + +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool +from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool +from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + +__all__ = [ + "LessonGeneratorTool", + "ExamplesProviderTool", + "QAHandlerTool", +] diff --git a/cortex/tutor/tools/agentic/examples_provider.py b/cortex/tutor/tools/agentic/examples_provider.py new file mode 100644 index 00000000..2e0d9471 --- /dev/null +++ b/cortex/tutor/tools/agentic/examples_provider.py @@ -0,0 +1,260 @@ +""" +Examples Provider Tool - Agentic tool for generating code examples. + +This tool uses LLM (Claude via LangChain) to generate contextual code examples. +""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from langchain.tools import BaseTool +from langchain_anthropic import ChatAnthropic +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import JsonOutputParser +from pydantic import Field + +from cortex.tutor.config import get_config + + +class ExamplesProviderTool(BaseTool): + """ + Agentic tool for generating code examples using LLM. + + Generates contextual, educational code examples for specific + package features and topics. + + Cost: ~$0.01 per generation + """ + + name: str = "examples_provider" + description: str = ( + "Generate contextual code examples for a package topic. " + "Use this when the user wants to see practical code demonstrations. " + "Returns examples with progressive complexity." + ) + + llm: Optional[ChatAnthropic] = Field(default=None, exclude=True) + model_name: str = Field(default="claude-sonnet-4-20250514") + + class Config: + arbitrary_types_allowed = True + + def __init__(self, model_name: Optional[str] = None) -> None: + """ + Initialize the examples provider tool. + + Args: + model_name: LLM model to use. + """ + super().__init__() + config = get_config() + self.model_name = model_name or config.model + self.llm = ChatAnthropic( + model=self.model_name, + api_key=config.anthropic_api_key, + temperature=0, + max_tokens=2048, + ) + + def _run( + self, + package_name: str, + topic: str, + difficulty: str = "beginner", + learning_style: str = "hands-on", + existing_knowledge: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Generate code examples for a package topic. + + Args: + package_name: Name of the package. + topic: Specific topic or feature to demonstrate. + difficulty: Example difficulty level. + learning_style: User's learning style. + existing_knowledge: Concepts user already knows. + + Returns: + Dict containing generated examples. + """ + try: + prompt = ChatPromptTemplate.from_messages( + [ + ("system", self._get_system_prompt()), + ("human", self._get_generation_prompt()), + ] + ) + + chain = prompt | self.llm | JsonOutputParser() + + result = chain.invoke( + { + "package_name": package_name, + "topic": topic, + "difficulty": difficulty, + "learning_style": learning_style, + "existing_knowledge": ", ".join(existing_knowledge or []) or "basics", + } + ) + + examples = self._structure_response(result, package_name, topic) + + return { + "success": True, + "examples": examples, + "cost_gbp": 0.01, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "examples": None, + } + + async def _arun( + self, + package_name: str, + topic: str, + difficulty: str = "beginner", + learning_style: str = "hands-on", + existing_knowledge: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Async version of example generation.""" + try: + prompt = ChatPromptTemplate.from_messages( + [ + ("system", self._get_system_prompt()), + ("human", self._get_generation_prompt()), + ] + ) + + chain = prompt | self.llm | JsonOutputParser() + + result = await chain.ainvoke( + { + "package_name": package_name, + "topic": topic, + "difficulty": difficulty, + "learning_style": learning_style, + "existing_knowledge": ", ".join(existing_knowledge or []) or "basics", + } + ) + + examples = self._structure_response(result, package_name, topic) + + return { + "success": True, + "examples": examples, + "cost_gbp": 0.01, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "examples": None, + } + + def _get_system_prompt(self) -> str: + """Get the system prompt for example generation.""" + return """You are an expert code example generator for educational purposes. + +CRITICAL RULES: +1. NEVER invent command flags that don't exist +2. NEVER generate fake output - use realistic but generic examples +3. NEVER include real credentials - use placeholders like 'your_api_key' +4. Flag potentially dangerous commands with warnings +5. Keep examples focused, practical, and safe to run + +Your examples should: +- Progress from simple to complex +- Include clear explanations +- Be safe and non-destructive +- Match the specified difficulty level""" + + def _get_generation_prompt(self) -> str: + """Get the generation prompt template.""" + return """Generate code examples for: {package_name} +Topic: {topic} +Difficulty: {difficulty} +Learning style: {learning_style} +User already knows: {existing_knowledge} + +Return a JSON object with this structure: +{{ + "package_name": "{package_name}", + "topic": "{topic}", + "examples": [ + {{ + "title": "Example Title", + "difficulty": "beginner", + "code": "actual code here", + "language": "bash", + "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.9 +}} + +Generate 2-4 examples with progressive complexity. +Ensure all examples are safe and educational.""" + + def _structure_response( + self, response: Dict[str, Any], package_name: str, topic: str + ) -> Dict[str, Any]: + """Structure and validate the LLM response.""" + structured = { + "package_name": response.get("package_name", package_name), + "topic": response.get("topic", topic), + "examples": [], + "tips": response.get("tips", [])[:5], + "common_mistakes": response.get("common_mistakes", [])[:5], + "confidence": min(max(response.get("confidence", 0.8), 0.0), 1.0), + } + + for ex in response.get("examples", [])[:4]: + if isinstance(ex, dict) and ex.get("code"): + structured["examples"].append( + { + "title": ex.get("title", "Example"), + "difficulty": ex.get("difficulty", "beginner"), + "code": ex.get("code", ""), + "language": ex.get("language", "bash"), + "description": ex.get("description", ""), + "expected_output": ex.get("expected_output"), + "warnings": ex.get("warnings", []), + "prerequisites": ex.get("prerequisites", []), + } + ) + + return structured + + +def generate_examples( + package_name: str, + topic: str, + difficulty: str = "beginner", +) -> Dict[str, Any]: + """ + Convenience function to generate code examples. + + Args: + package_name: Package name. + topic: Topic to demonstrate. + difficulty: Example difficulty. + + Returns: + Generated examples dictionary. + """ + tool = ExamplesProviderTool() + return tool._run( + package_name=package_name, + topic=topic, + difficulty=difficulty, + ) diff --git a/cortex/tutor/tools/agentic/lesson_generator.py b/cortex/tutor/tools/agentic/lesson_generator.py new file mode 100644 index 00000000..d314a4cf --- /dev/null +++ b/cortex/tutor/tools/agentic/lesson_generator.py @@ -0,0 +1,327 @@ +""" +Lesson Generator Tool - Agentic tool for generating educational content. + +This tool uses LLM (Claude via LangChain) to generate comprehensive lessons. +It is used when no cached lesson is available. +""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from langchain.tools import BaseTool +from langchain_anthropic import ChatAnthropic +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import JsonOutputParser +from pydantic import Field + +from cortex.tutor.config import get_config +from cortex.tutor.contracts.lesson_context import LessonContext, CodeExample, TutorialStep + + +# Load prompt template +def _load_prompt_template() -> str: + """Load the lesson generator prompt from file.""" + prompt_path = Path(__file__).parent.parent.parent / "prompts" / "tools" / "lesson_generator.md" + if prompt_path.exists(): + return prompt_path.read_text() + # Fallback inline prompt + return """You are a lesson content generator. Generate comprehensive educational content + for the package: {package_name} + + Student level: {student_level} + Learning style: {learning_style} + Focus areas: {focus_areas} + + Return a JSON object with: summary, explanation, use_cases, best_practices, + code_examples, tutorial_steps, installation_command, confidence.""" + + +class LessonGeneratorTool(BaseTool): + """ + Agentic tool for generating lesson content using LLM. + + This tool generates comprehensive lessons including: + - Package explanations + - Best practices + - Code examples + - Step-by-step tutorials + + Cost: ~$0.02 per generation + """ + + name: str = "lesson_generator" + description: str = ( + "Generate comprehensive lesson content for a package using AI. " + "Use this when no cached lesson exists. " + "Returns structured lesson with explanations, examples, and tutorials." + ) + + llm: Optional[ChatAnthropic] = Field(default=None, exclude=True) + model_name: str = Field(default="claude-sonnet-4-20250514") + + class Config: + arbitrary_types_allowed = True + + def __init__(self, model_name: Optional[str] = None) -> None: + """ + Initialize the lesson generator tool. + + Args: + model_name: LLM model to use. Uses config default if not provided. + """ + super().__init__() + config = get_config() + self.model_name = model_name or config.model + self.llm = ChatAnthropic( + model=self.model_name, + api_key=config.anthropic_api_key, + temperature=0, + max_tokens=4096, + ) + + def _run( + self, + package_name: str, + student_level: str = "beginner", + learning_style: str = "reading", + focus_areas: Optional[List[str]] = None, + skip_areas: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Generate lesson content for a package. + + Args: + package_name: Name of the package to generate lesson for. + student_level: Student level (beginner, intermediate, advanced). + learning_style: Learning style (visual, reading, hands-on). + focus_areas: Specific topics to emphasize. + skip_areas: Topics already mastered to skip. + + Returns: + Dict containing generated lesson content. + """ + try: + # Build the prompt + prompt = ChatPromptTemplate.from_messages( + [ + ("system", self._get_system_prompt()), + ("human", self._get_generation_prompt()), + ] + ) + + # Create the chain + chain = prompt | self.llm | JsonOutputParser() + + # Generate lesson + result = chain.invoke( + { + "package_name": package_name, + "student_level": student_level, + "learning_style": learning_style, + "focus_areas": ", ".join(focus_areas or []) or "all topics", + "skip_areas": ", ".join(skip_areas or []) or "none", + } + ) + + # Validate and structure the response + lesson = self._structure_response(result, package_name) + + return { + "success": True, + "lesson": lesson, + "cost_gbp": 0.02, # Estimated cost + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "lesson": None, + } + + async def _arun( + self, + package_name: str, + student_level: str = "beginner", + learning_style: str = "reading", + focus_areas: Optional[List[str]] = None, + skip_areas: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Async version of lesson generation.""" + try: + prompt = ChatPromptTemplate.from_messages( + [ + ("system", self._get_system_prompt()), + ("human", self._get_generation_prompt()), + ] + ) + + chain = prompt | self.llm | JsonOutputParser() + + result = await chain.ainvoke( + { + "package_name": package_name, + "student_level": student_level, + "learning_style": learning_style, + "focus_areas": ", ".join(focus_areas or []) or "all topics", + "skip_areas": ", ".join(skip_areas or []) or "none", + } + ) + + lesson = self._structure_response(result, package_name) + + return { + "success": True, + "lesson": lesson, + "cost_gbp": 0.02, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "lesson": None, + } + + def _get_system_prompt(self) -> str: + """Get the system prompt for lesson generation.""" + return """You are an expert educational content creator specializing in software packages and tools. +Your role is to create comprehensive, accurate, and engaging lessons. + +CRITICAL RULES: +1. NEVER invent features that don't exist in the package +2. NEVER fabricate URLs - suggest "official documentation" instead +3. NEVER claim specific version features unless certain +4. Express confidence levels honestly +5. Focus on stable, well-documented functionality + +Your lessons should be: +- Clear and accessible to the specified student level +- Practical with real-world examples +- Progressive in complexity +- Safe to follow (no destructive commands without warnings)""" + + def _get_generation_prompt(self) -> str: + """Get the generation prompt template.""" + return """Generate a comprehensive lesson for: {package_name} + +Student Level: {student_level} +Learning Style: {learning_style} +Focus Areas: {focus_areas} +Skip Areas: {skip_areas} + +Return a JSON object with this exact structure: +{{ + "package_name": "{package_name}", + "summary": "1-2 sentence overview", + "explanation": "Detailed explanation of what the package does and why it's useful", + "use_cases": ["use case 1", "use case 2", "use case 3", "use case 4"], + "best_practices": ["practice 1", "practice 2", "practice 3", "practice 4", "practice 5"], + "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", + "expected_output": "optional expected output" + }} + ], + "installation_command": "apt install package or pip install package", + "related_packages": ["related1", "related2"], + "confidence": 0.9 +}} + +Ensure: +- Summary is concise (max 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 +- Confidence reflects your actual certainty (0.5-1.0)""" + + def _structure_response(self, response: Dict[str, Any], package_name: str) -> Dict[str, Any]: + """ + Structure and validate the LLM response. + + Args: + response: Raw LLM response. + package_name: Package name for validation. + + Returns: + Structured lesson dictionary. + """ + # Ensure required fields with defaults + structured = { + "package_name": response.get("package_name", package_name), + "summary": response.get("summary", f"A lesson about {package_name}"), + "explanation": response.get("explanation", ""), + "use_cases": response.get("use_cases", [])[:5], + "best_practices": response.get("best_practices", [])[:7], + "code_examples": [], + "tutorial_steps": [], + "installation_command": response.get( + "installation_command", f"apt install {package_name}" + ), + "related_packages": response.get("related_packages", [])[:5], + "confidence": min(max(response.get("confidence", 0.8), 0.0), 1.0), + } + + # Structure code examples + for ex in response.get("code_examples", [])[:5]: + if isinstance(ex, dict) and ex.get("code"): + structured["code_examples"].append( + { + "title": ex.get("title", "Example"), + "code": ex.get("code", ""), + "language": ex.get("language", "bash"), + "description": ex.get("description", ""), + } + ) + + # Structure tutorial steps + for i, step in enumerate(response.get("tutorial_steps", [])[:10], 1): + if isinstance(step, dict): + structured["tutorial_steps"].append( + { + "step_number": step.get("step_number", i), + "title": step.get("title", f"Step {i}"), + "content": step.get("content", ""), + "code": step.get("code"), + "expected_output": step.get("expected_output"), + } + ) + + return structured + + +def generate_lesson( + package_name: str, + student_level: str = "beginner", + learning_style: str = "reading", +) -> Dict[str, Any]: + """ + Convenience function to generate a lesson. + + Args: + package_name: Package to generate lesson for. + student_level: Student level. + learning_style: Preferred learning style. + + Returns: + Generated lesson dictionary. + """ + tool = LessonGeneratorTool() + return tool._run( + package_name=package_name, + student_level=student_level, + learning_style=learning_style, + ) diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py new file mode 100644 index 00000000..30756948 --- /dev/null +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -0,0 +1,344 @@ +""" +Q&A Handler Tool - Agentic tool for handling user questions. + +This tool uses LLM (Claude via LangChain) to answer questions about packages. +""" + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from langchain.tools import BaseTool +from langchain_anthropic import ChatAnthropic +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.output_parsers import JsonOutputParser +from pydantic import Field + +from cortex.tutor.config import get_config + + +class QAHandlerTool(BaseTool): + """ + Agentic tool for handling Q&A using LLM. + + Answers user questions about packages in an educational context, + building on their existing knowledge. + + Cost: ~$0.02 per question + """ + + name: str = "qa_handler" + description: str = ( + "Answer user questions about a package. " + "Use this for free-form Q&A outside the structured lesson flow. " + "Provides contextual answers based on student profile." + ) + + llm: Optional[ChatAnthropic] = Field(default=None, exclude=True) + model_name: str = Field(default="claude-sonnet-4-20250514") + + class Config: + arbitrary_types_allowed = True + + def __init__(self, model_name: Optional[str] = None) -> None: + """ + Initialize the Q&A handler tool. + + Args: + model_name: LLM model to use. + """ + super().__init__() + config = get_config() + self.model_name = model_name or config.model + self.llm = ChatAnthropic( + model=self.model_name, + api_key=config.anthropic_api_key, + temperature=0.1, # Slight creativity for natural responses + max_tokens=2048, + ) + + def _run( + self, + package_name: str, + question: str, + learning_style: str = "reading", + mastered_concepts: Optional[List[str]] = None, + weak_concepts: Optional[List[str]] = None, + lesson_context: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Answer a user question about a package. + + Args: + package_name: Current package context. + question: The user's question. + learning_style: User's learning preference. + mastered_concepts: Concepts user has mastered. + weak_concepts: Concepts user struggles with. + lesson_context: What they've learned so far. + + Returns: + Dict containing the answer and related info. + """ + try: + prompt = ChatPromptTemplate.from_messages( + [ + ("system", self._get_system_prompt()), + ("human", self._get_qa_prompt()), + ] + ) + + chain = prompt | self.llm | JsonOutputParser() + + result = chain.invoke( + { + "package_name": package_name, + "question": question, + "learning_style": learning_style, + "mastered_concepts": ", ".join(mastered_concepts or []) or "none specified", + "weak_concepts": ", ".join(weak_concepts or []) or "none specified", + "lesson_context": lesson_context or "starting fresh", + } + ) + + answer = self._structure_response(result, package_name, question) + + return { + "success": True, + "answer": answer, + "cost_gbp": 0.02, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "answer": None, + } + + async def _arun( + self, + package_name: str, + question: str, + learning_style: str = "reading", + mastered_concepts: Optional[List[str]] = None, + weak_concepts: Optional[List[str]] = None, + lesson_context: Optional[str] = None, + ) -> Dict[str, Any]: + """Async version of Q&A handling.""" + try: + prompt = ChatPromptTemplate.from_messages( + [ + ("system", self._get_system_prompt()), + ("human", self._get_qa_prompt()), + ] + ) + + chain = prompt | self.llm | JsonOutputParser() + + result = await chain.ainvoke( + { + "package_name": package_name, + "question": question, + "learning_style": learning_style, + "mastered_concepts": ", ".join(mastered_concepts or []) or "none specified", + "weak_concepts": ", ".join(weak_concepts or []) or "none specified", + "lesson_context": lesson_context or "starting fresh", + } + ) + + answer = self._structure_response(result, package_name, question) + + return { + "success": True, + "answer": answer, + "cost_gbp": 0.02, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "answer": None, + } + + def _get_system_prompt(self) -> str: + """Get the system prompt for Q&A.""" + return """You are a patient, knowledgeable tutor answering questions about software packages. + +CRITICAL RULES: +1. NEVER fabricate features - only describe functionality you're confident exists +2. NEVER invent comparison data or benchmarks +3. NEVER generate fake URLs +4. Express confidence levels: "I'm confident...", "I believe...", "You should verify..." +5. Admit knowledge limits honestly + +Your answers should: +- Be clear and educational +- Build on the student's existing knowledge +- Avoid re-explaining concepts they've mastered +- Provide extra detail for their weak areas +- Match their preferred learning style""" + + def _get_qa_prompt(self) -> str: + """Get the Q&A prompt template.""" + return """Package context: {package_name} +Question: {question} + +Student Profile: +- Learning style: {learning_style} +- Already mastered: {mastered_concepts} +- Struggles with: {weak_concepts} +- Current lesson context: {lesson_context} + +Answer the question considering their profile. Return 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.9, + "verification_note": "Optional note if user should verify something" +}} + +If you don't know the answer, be honest and suggest where they might find it. +If the question is unclear, ask for clarification in the answer field.""" + + def _structure_response( + self, response: Dict[str, Any], package_name: str, question: str + ) -> Dict[str, Any]: + """Structure and validate the LLM response.""" + structured = { + "package_name": package_name, + "original_question": question, + "question_understood": response.get("question_understood", question), + "answer": response.get("answer", "I couldn't generate an answer."), + "explanation": response.get("explanation"), + "code_example": None, + "related_topics": response.get("related_topics", [])[:5], + "follow_up_suggestions": response.get("follow_up_suggestions", [])[:3], + "confidence": min(max(response.get("confidence", 0.7), 0.0), 1.0), + "verification_note": response.get("verification_note"), + } + + # Structure code example if present + code_ex = response.get("code_example") + if isinstance(code_ex, dict) and code_ex.get("code"): + structured["code_example"] = { + "code": code_ex.get("code", ""), + "language": code_ex.get("language", "bash"), + "description": code_ex.get("description", ""), + } + + return structured + + +def answer_question( + package_name: str, + question: str, + learning_style: str = "reading", +) -> Dict[str, Any]: + """ + Convenience function to answer a question. + + Args: + package_name: Package context. + question: User's question. + learning_style: Learning preference. + + Returns: + Answer dictionary. + """ + tool = QAHandlerTool() + return tool._run( + package_name=package_name, + question=question, + learning_style=learning_style, + ) + + +class ConversationHandler: + """ + Handles multi-turn Q&A conversations with context. + + Maintains conversation history for more contextual responses. + """ + + def __init__(self, package_name: str) -> None: + """ + Initialize conversation handler. + + Args: + package_name: Package being discussed. + """ + self.package_name = package_name + self.history: List[Dict[str, str]] = [] + self.qa_tool = QAHandlerTool() + + def ask( + self, + question: str, + learning_style: str = "reading", + mastered_concepts: Optional[List[str]] = None, + weak_concepts: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Ask a question with conversation history. + + Args: + question: The question to ask. + learning_style: Learning preference. + mastered_concepts: Mastered concepts. + weak_concepts: Weak concepts. + + Returns: + Answer with context. + """ + # Build context from history + context = self._build_context() + + # Get answer + result = self.qa_tool._run( + package_name=self.package_name, + question=question, + learning_style=learning_style, + mastered_concepts=mastered_concepts, + weak_concepts=weak_concepts, + lesson_context=context, + ) + + # Update history + if result.get("success"): + self.history.append( + { + "question": question, + "answer": result["answer"].get("answer", ""), + } + ) + + return result + + def _build_context(self) -> str: + """Build context string from conversation history.""" + if not self.history: + return "Starting fresh conversation" + + recent = self.history[-3:] # Last 3 exchanges + context_parts = [] + for h in recent: + context_parts.append(f"Q: {h['question'][:100]}") + context_parts.append(f"A: {h['answer'][:100]}") + + return "Recent discussion: " + " | ".join(context_parts) + + def clear_history(self) -> None: + """Clear conversation history.""" + self.history = [] diff --git a/cortex/tutor/tools/deterministic/__init__.py b/cortex/tutor/tools/deterministic/__init__.py new file mode 100644 index 00000000..1afed915 --- /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.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.deterministic.validators import validate_package_name, validate_input +from cortex.tutor.tools.deterministic.lesson_loader import LessonLoaderTool + +__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..950ae2b9 --- /dev/null +++ b/cortex/tutor/tools/deterministic/lesson_loader.py @@ -0,0 +1,276 @@ +""" +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, Dict, Optional + +from langchain.tools import BaseTool +from pydantic import Field + +from cortex.tutor.config import get_config +from cortex.tutor.memory.sqlite_store import SQLiteStore +from cortex.tutor.contracts.lesson_context import LessonContext + + +class LessonLoaderTool(BaseTool): + """ + Deterministic tool for loading cached lesson content. + + This tool retrieves lessons from SQLite cache without LLM calls. + It is fast, free, and should be checked before generating new lessons. + """ + + name: str = "lesson_loader" + description: str = ( + "Load cached lesson content for a package. " + "Use this before generating new lessons to save cost. " + "Returns None if no valid cache exists." + ) + + store: Optional[SQLiteStore] = Field(default=None, exclude=True) + + class Config: + arbitrary_types_allowed = True + + def __init__(self, db_path: Optional[Path] = None) -> None: + """ + Initialize the lesson loader tool. + + Args: + db_path: Path to SQLite database. Uses config default if not provided. + """ + super().__init__() + 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. + + Args: + package_name: Name of the package to load lesson for. + force_fresh: If True, skip cache and return cache miss. + + Returns: + Dict with cached lesson or cache miss indicator. + """ + 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, # Estimated LLM cost saved + } + + 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), + } + + async def _arun( + self, + package_name: str, + force_fresh: bool = False, + ) -> Dict[str, Any]: + """Async version - delegates to sync implementation.""" + return self._run(package_name, force_fresh) + + def cache_lesson( + self, + package_name: str, + lesson: Dict[str, Any], + ttl_hours: int = 24, + ) -> bool: + """ + Cache a lesson for future retrieval. + + Args: + package_name: Name of the package. + lesson: Lesson content to cache. + ttl_hours: Time-to-live in hours. + + Returns: + True if cached successfully. + """ + try: + self.store.cache_lesson(package_name, lesson, ttl_hours) + return True + except Exception: + return False + + def clear_cache(self, package_name: Optional[str] = None) -> int: + """ + Clear cached lessons. + + Args: + package_name: Specific package to clear, or None for all. + + Returns: + Number of entries cleared. + """ + if package_name: + # Clear specific package by caching empty with 0 TTL + 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 +# These can be used as fallbacks when LLM is unavailable + +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, # Lower confidence for fallback + }, + "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) -> Optional[Dict[str, Any]]: + """ + Get a fallback lesson template for common packages. + + Args: + package_name: Name of the package. + + Returns: + Fallback lesson dict or None. + """ + return FALLBACK_LESSONS.get(package_name.lower()) + + +def load_lesson_with_fallback( + package_name: str, + db_path: Optional[Path] = None, +) -> Dict[str, Any]: + """ + Load lesson from cache with fallback to templates. + + Args: + package_name: Name of the package. + db_path: Optional database path. + + Returns: + Lesson content dict. + """ + 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..b19f894f --- /dev/null +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -0,0 +1,346 @@ +""" +Progress Tracker Tool - Deterministic tool for learning progress management. + +This tool does NOT use LLM calls - it is fast, free, and predictable. +Used for tracking learning progress via SQLite operations. +""" + +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from langchain.tools import BaseTool +from pydantic import Field + +from cortex.tutor.config import get_config +from cortex.tutor.memory.sqlite_store import ( + SQLiteStore, + LearningProgress, + StudentProfile, +) + + +class ProgressTrackerTool(BaseTool): + """ + Deterministic tool for tracking learning progress. + + This tool manages SQLite-based progress tracking including: + - Recording topic completions + - Tracking time spent + - Managing student profiles + - Retrieving progress statistics + + No LLM calls are made - pure database operations. + """ + + name: str = "progress_tracker" + description: str = ( + "Track learning progress for packages and topics. " + "Use this to record completions, get progress stats, and manage student profiles. " + "This is a fast, deterministic tool with no LLM cost." + ) + + store: Optional[SQLiteStore] = Field(default=None, exclude=True) + + class Config: + arbitrary_types_allowed = True + + def __init__(self, db_path: Optional[Path] = None) -> None: + """ + Initialize the progress tracker tool. + + Args: + db_path: Path to SQLite database. Uses config default if not provided. + """ + super().__init__() + 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: Optional[str] = None, + topic: Optional[str] = None, + score: Optional[float] = None, + time_seconds: Optional[int] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """ + Execute a progress tracking action. + + Args: + action: Action to perform (get_progress, mark_completed, get_stats, etc.) + package_name: Name of the package (required for most actions) + topic: Topic within the package + score: Score achieved (0.0 to 1.0) + time_seconds: Time spent in seconds + **kwargs: Additional arguments for specific actions + + Returns: + Dict containing action results + """ + 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: + return {"success": False, "error": str(e)} + + async def _arun(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: + """Async version - delegates to sync implementation.""" + return self._run(*args, **kwargs) + + def _get_progress( + self, + package_name: Optional[str], + topic: Optional[str], + **kwargs: Any, + ) -> Dict[str, Any]: + """Get progress for a specific package/topic.""" + if not package_name or not topic: + return {"success": False, "error": "package_name and 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: Optional[str] = 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: Optional[str], + topic: Optional[str], + score: Optional[float] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + """Mark a topic as completed.""" + if not package_name or not topic: + return {"success": False, "error": "package_name and topic required"} + + self.store.mark_topic_completed(package_name, topic, score or 1.0) + return { + "success": True, + "message": f"Marked {package_name}/{topic} as completed", + "score": score or 1.0, + } + + def _update_progress( + self, + package_name: Optional[str], + topic: Optional[str], + score: Optional[float] = None, + time_seconds: Optional[int] = None, + completed: bool = False, + **kwargs: Any, + ) -> Dict[str, Any]: + """Update progress for a topic.""" + if not package_name or not topic: + return {"success": False, "error": "package_name and topic required"} + + # Get existing progress to preserve values + existing = self.store.get_progress(package_name, topic) + total_time = (existing.total_time_seconds if existing else 0) + (time_seconds or 0) + + progress = LearningProgress( + package_name=package_name, + topic=topic, + completed=completed, + score=score or (existing.score if existing else 0.0), + 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: Optional[str], + **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: Optional[str] = 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: Optional[str] = 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: Optional[str] = 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: Optional[str] = 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, "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) -> Optional[Dict[str, Any]]: + """ + Get learning progress for a specific topic. + + Args: + package_name: Name of the package. + topic: Topic within the package. + + Returns: + Progress dictionary or None. + """ + 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. + + Args: + package_name: Name of the package. + topic: Topic to mark as completed. + score: Score achieved (0.0 to 1.0). + + Returns: + True if successful. + """ + 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. + + Args: + package_name: Name of the package. + + Returns: + Statistics dictionary. + """ + 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..88d720de --- /dev/null +++ b/cortex/tutor/tools/deterministic/validators.py @@ -0,0 +1,368 @@ +""" +Validators - Deterministic input validation for Intelligent Tutor. + +Provides security-focused validation functions following Cortex patterns. +No LLM calls - pure rule-based validation. +""" + +import re +from typing import Tuple, List, Optional + +# 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, Optional[str]]: + """ + 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, Optional[str]]: + """ + 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, Optional[str]]: + """ + 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, Optional[str]]: + """ + 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, Optional[str]]: + """ + 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, Optional[str]]: + """ + 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) -> str: + """ + Sanitize user input by removing potentially dangerous content. + + Args: + input_text: The input to sanitize. + + 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) -> Optional[str]: + """ + Extract a potential package name from user input. + + Args: + input_text: User input that may contain a package name. + + 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: Optional[str] = None, + topic: Optional[str] = None, + question: Optional[str] = None, + score: Optional[float] = 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: Optional[List[str]] = 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: Optional[str] = None, + topic: Optional[str] = None, + question: Optional[str] = None, + score: Optional[float] = 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..0367afd9 --- /dev/null +++ b/docs/AI_TUTOR.md @@ -0,0 +1,857 @@ +# 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/) +[![Test Coverage](https://img.shields.io/badge/coverage-87%25-brightgreen.svg)](https://github.com/cortexlinux/cortex) +[![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 the Plan→Act→Reflect workflow │ +│ • Manages state across phases │ +│ • Coordinates tools and LLM calls │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Deterministic │ │ Agentic │ │ Memory │ +│ Tools │ │ Tools │ │ Layer │ +│ │ │ │ │ │ +│ • validators │ │ • lesson_gen │ │ • SQLite store │ +│ • lesson_loader │ │ • examples │ │ • Cache mgmt │ +│ • progress_trk │ │ • qa_handler │ │ • Progress DB │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ Claude API │ │ + │ │ (Anthropic) │ │ + │ └─────────────────┘ │ + │ │ │ + └───────────────────┼───────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Output Layer │ +│ (cortex/tutor/branding.py) │ +│ │ +│ • Rich console formatting │ +│ • Syntax-highlighted code blocks │ +│ • Progress bars and tables │ +│ • Interactive menus │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Plan→Act→Reflect Pattern + +The tutor uses **LangGraph** to implement a robust 3-phase workflow: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PLAN PHASE │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Check Cache │───▶│ Has Cache? │───▶│ Use Cached │ │ +│ │ (FREE) │ │ │ Y │ Lesson │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ N │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ LLM Planner │ │ +│ │ (~$0.01) │ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ACT PHASE │ +│ │ +│ ┌────────────────────────┐ ┌────────────────────────┐ │ +│ │ Deterministic Tools │ │ Agentic Tools │ │ +│ │ (FREE) │ │ (LLM-Powered) │ │ +│ │ │ │ │ │ +│ │ • Load user progress │ │ • Generate lesson │ │ +│ │ • Validate inputs │ │ • Create examples │ │ +│ │ • Check cache │ │ • Answer questions │ │ +│ │ • Track time │ │ • Build tutorials │ │ +│ └────────────────────────┘ └────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ REFLECT PHASE │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Validate │───▶│ Passed? │───▶│ Cache & │ │ +│ │ Output │ │ │ Y │ Return │ │ +│ │ (FREE) │ └──────────────┘ └──────────────┘ │ +│ │ N │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ Retry or │ │ +│ │ Fallback │ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Tool Classification + +Tools are classified by whether they require LLM calls: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 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% │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ AGENTIC TOOLS │ +│ (LLM-Powered - Smart) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │lesson_generator │ │examples_provider│ │ qa_handler │ │ +│ │ │ │ │ │ │ │ +│ │ • Explanations │ │ • Code samples │ │ • Q&A responses │ │ +│ │ • Concepts │ │ • Use cases │ │ • Follow-ups │ │ +│ │ • Theory │ │ • Best practice │ │ • Clarifications│ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ Speed: 2-5s | Cost: ~$0.02 | Reliability: 95%+ (with fallback) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow Diagram + +``` +User Input Processing Output +───────── ────────── ────── + +"cortex tutor docker" + │ + ▼ +┌───────────────┐ +│ CLI Parser │ +└───────────────┘ + │ + ▼ +┌───────────────┐ ┌───────────────┐ +│ TutorAgent │────▶│ SQLite Cache │──── Cache Hit? ────┐ +└───────────────┘ └───────────────┘ │ + │ │ + │ Cache Miss │ + ▼ │ +┌───────────────┐ ┌───────────────┐ │ +│ LangGraph │────▶│ Claude API │ │ +│ Workflow │ └───────────────┘ │ +└───────────────┘ │ + │ │ + │ 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 +│ +├── agents/ # LangGraph Agents +│ └── tutor_agent/ +│ ├── __init__.py +│ ├── tutor_agent.py # Main TutorAgent class +│ ├── graph.py # LangGraph workflow definition +│ └── state.py # TypedDict state management +│ +├── 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 +│ │ +│ └── agentic/ # LLM-powered (smart, costs $) +│ ├── lesson_generator.py # Generate lesson content +│ ├── examples_provider.py# Generate code examples +│ └── qa_handler.py # Handle Q&A interactions +│ +├── contracts/ # Pydantic output schemas +│ ├── lesson_context.py # Lesson data structure +│ └── progress_context.py # Progress data structure +│ +├── prompts/ # 7-Layer prompt templates +│ ├── agents/tutor/system.md # TutorAgent system prompt +│ └── tools/ +│ ├── lesson_generator.md +│ ├── examples_provider.md +│ └── qa_handler.md +│ +├── memory/ # Persistence layer +│ └── sqlite_store.py # SQLite operations +│ +└── tests/ # Test suite (87% coverage) + ├── test_tutor_agent.py + ├── test_tools.py + ├── test_progress_tracker.py + ├── test_integration.py + └── ... +``` + +### 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 +); +``` + +### State Management + +The TutorAgent uses TypedDict for type-safe state: + +```python +class TutorAgentState(TypedDict): + """State passed through the LangGraph workflow.""" + + # Input + input: Dict[str, Any] # package_name, question, etc. + force_fresh: bool # Skip cache flag + + # Planning + plan: Dict[str, Any] # Plan phase output + cache_hit: bool # Whether cache was used + + # Context + student_profile: Dict # User's learning history + lesson_content: Dict # Current lesson data + + # Execution + results: Dict[str, Any] # Tool execution results + errors: List[Dict] # Any errors encountered + + # Metadata + cost_gbp: float # Accumulated API cost + checkpoints: List[str] # Workflow checkpoints +``` + +### 7-Layer Prompt Architecture + +Each prompt follows a structured format: + +```markdown +# Layer 1: IDENTITY +You are an expert Linux package tutor... + +# Layer 2: ROLE & BOUNDARIES +You can explain packages, provide examples... +You cannot execute commands, access the filesystem... + +# Layer 3: ANTI-HALLUCINATION RULES +- NEVER invent package features +- NEVER claim capabilities that don't exist +- If unsure, say "I don't have information about..." + +# Layer 4: CONTEXT & INPUTS +Package: {package_name} +User Level: {skill_level} +Previous Topics: {completed_topics} + +# Layer 5: TOOLS & USAGE +Available: lesson_generator, examples_provider +When to use each tool... + +# Layer 6: WORKFLOW & REASONING +1. First, assess user's current knowledge +2. Then, generate appropriate content +3. Finally, validate and structure output + +# Layer 7: OUTPUT FORMAT +Return JSON matching LessonContext schema: +{ + "summary": "...", + "explanation": "...", + "code_examples": [...], + "best_practices": [...] +} +``` + +--- + +## 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=sk-ant-... + +# Optional: Override model (default: claude-sonnet-4-20250514) +export TUTOR_MODEL=claude-sonnet-4-20250514 + +# Optional: Enable debug output +export TUTOR_DEBUG=true + +# Optional: Custom data directory (default: ~/.cortex) +export TUTOR_DATA_DIR=~/.cortex +``` + +### Config File + +Configuration can also be set in `~/.cortex/config.yaml`: + +```yaml +tutor: + model: claude-sonnet-4-20250514 + cache_ttl_hours: 24 + max_retries: 3 + debug: false +``` + +--- + +## Testing + +### Run Tests + +```bash +# Run all tutor tests +pytest cortex/tutor/tests/ -v + +# Run with coverage +pytest cortex/tutor/tests/ -v --cov=cortex.tutor --cov-report=term-missing + +# Run specific test file +pytest cortex/tutor/tests/test_tutor_agent.py -v +``` + +### Test Coverage + +Current coverage: **87%** (266 tests) + +| Module | Coverage | +|--------|----------| +| `agents/tutor_agent/graph.py` | 99% | +| `agents/tutor_agent/state.py` | 97% | +| `agents/tutor_agent/tutor_agent.py` | 88% | +| `memory/sqlite_store.py` | 95% | +| `tools/deterministic/validators.py` | 95% | +| `branding.py` | 92% | +| `cli.py` | 89% | + +--- + +## Troubleshooting + +### Common Issues + +
+"No API key found" + +```bash +# Set the API key +export ANTHROPIC_API_KEY=sk-ant-your-key-here + +# Or add to ~/.cortex/.env +echo 'ANTHROPIC_API_KEY=sk-ant-...' >> ~/.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/YOUR_USERNAME/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 cortex/tutor/tests/ -v --cov=cortex.tutor +``` + +--- + +## License + +Apache 2.0 - See [LICENSE](../LICENSE) for details. + +--- + +

+ Built with ❤️ for the Cortex Linux community +

diff --git a/requirements.txt b/requirements.txt index 166a777e..1f730b2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,9 @@ pyyaml>=6.0.0 # Type hints for older Python versions typing-extensions>=4.0.0 PyYAML==6.0.3 + +# AI Tutor Dependencies (Issue #131) +langchain>=0.3.0 +langchain-anthropic>=0.3.0 +langgraph>=0.2.0 +pydantic>=2.0.0 From 654370c5413e7f611d1d5559e8cb0cf21e1f1480 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 12:21:32 +0530 Subject: [PATCH 02/32] fix(tutor): address PR review comments from Gemini and CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes identified in PR #566 code review: - Fix coverage badge discrepancy (87% → 85.6%) in docs/AI_TUTOR.md - Fix db_path type annotation to Optional[Path] in config.py - Remove unused ExamplesProviderTool import from graph.py - Add DEFAULT_TUTOR_TOPICS constant to avoid magic numbers - Fix --fresh flag propagation to InteractiveTutor - Fix race condition in profile creation with INSERT OR IGNORE - Tighten version constraints in requirements.txt (<2.0.0 bounds) - Update clear_cache docstring to match actual behavior - Implement empty test methods in TestConvenienceFunctions All 266 tests pass with 86% coverage. --- cortex/cli.py | 2 +- cortex/tutor/agents/tutor_agent/graph.py | 1 - .../tutor/agents/tutor_agent/tutor_agent.py | 11 +++-- cortex/tutor/cli.py | 7 ++- cortex/tutor/config.py | 2 +- cortex/tutor/memory/sqlite_store.py | 16 ++++++- cortex/tutor/tests/test_progress_tracker.py | 48 +++++++++++++++++-- .../tools/deterministic/lesson_loader.py | 14 ++++-- docs/AI_TUTOR.md | 4 +- requirements.txt | 8 ++-- 10 files changed, 90 insertions(+), 23 deletions(-) diff --git a/cortex/cli.py b/cortex/cli.py index 7c3d0f71..23fb41f9 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -1063,7 +1063,7 @@ def tutor(self, args: argparse.Namespace) -> int: if package: print_banner() fresh = getattr(args, "fresh", False) - return cmd_teach(package, verbose=self.verbose, force_fresh=fresh) + return cmd_teach(package, verbose=self.verbose, fresh=fresh) # No arguments - show help cx_print("Usage: cortex tutor [options]", "info") diff --git a/cortex/tutor/agents/tutor_agent/graph.py b/cortex/tutor/agents/tutor_agent/graph.py index 30573605..731f5136 100644 --- a/cortex/tutor/agents/tutor_agent/graph.py +++ b/cortex/tutor/agents/tutor_agent/graph.py @@ -21,7 +21,6 @@ from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool -from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool # ==================== Node Functions ==================== diff --git a/cortex/tutor/agents/tutor_agent/tutor_agent.py b/cortex/tutor/agents/tutor_agent/tutor_agent.py index a6a38d45..a624848f 100644 --- a/cortex/tutor/agents/tutor_agent/tutor_agent.py +++ b/cortex/tutor/agents/tutor_agent/tutor_agent.py @@ -16,6 +16,9 @@ from cortex.tutor.contracts.lesson_context import LessonContext from cortex.tutor.branding import tutor_print, console +# Default number of topics per package for progress tracking +DEFAULT_TUTOR_TOPICS = 5 + class TutorAgent: """ @@ -245,14 +248,16 @@ class InteractiveTutor: Provides a menu-driven interface for learning packages. """ - def __init__(self, package_name: str) -> None: + def __init__(self, package_name: str, force_fresh: bool = False) -> None: """ Initialize interactive tutor for a package. Args: package_name: Package to learn. + force_fresh: Skip cache and generate fresh content. """ self.package_name = package_name + self.force_fresh = force_fresh self.agent = TutorAgent(verbose=False) self.lesson: Optional[Dict[str, Any]] = None self.current_step = 0 @@ -271,7 +276,7 @@ def start(self) -> None: # Load lesson tutor_print(f"Loading lesson for {self.package_name}...", "tutor") - result = self.agent.teach(self.package_name) + 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") @@ -440,7 +445,7 @@ def _show_progress(self) -> None: stats = result.get("stats", {}) print_progress_summary( stats.get("completed", 0), - stats.get("total", 0) or 5, # Default topics + stats.get("total", 0) or DEFAULT_TUTOR_TOPICS, self.package_name, ) else: diff --git a/cortex/tutor/cli.py b/cortex/tutor/cli.py index eb5c4730..ddca8c30 100644 --- a/cortex/tutor/cli.py +++ b/cortex/tutor/cli.py @@ -29,6 +29,9 @@ from cortex.tutor.config import Config from cortex.tutor.memory.sqlite_store import SQLiteStore +# Default number of topics per package for progress tracking +DEFAULT_TUTOR_TOPICS = 5 + def create_parser() -> argparse.ArgumentParser: """ @@ -130,7 +133,7 @@ def cmd_teach(package: str, verbose: bool = False, fresh: bool = False) -> int: from cortex.tutor.agents.tutor_agent import InteractiveTutor # Start interactive tutor - interactive = InteractiveTutor(package) + interactive = InteractiveTutor(package, force_fresh=fresh) interactive.start() return 0 @@ -259,7 +262,7 @@ def cmd_progress(package: Optional[str] = None, verbose: bool = False) -> int: if stats: print_progress_summary( stats.get("completed", 0), - stats.get("total", 0) or 5, + stats.get("total", 0) or DEFAULT_TUTOR_TOPICS, package, ) console.print(f"[dim]Average score: {stats.get('avg_score', 0):.0%}[/dim]") diff --git a/cortex/tutor/config.py b/cortex/tutor/config.py index 097a74dc..4dfab3bf 100644 --- a/cortex/tutor/config.py +++ b/cortex/tutor/config.py @@ -41,7 +41,7 @@ class Config(BaseModel): 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 = Field(default=None, description="Path to SQLite database") + db_path: Optional[Path] = Field(default=None, description="Path to SQLite database") def model_post_init(self, __context) -> None: """Initialize computed fields after model creation.""" diff --git a/cortex/tutor/memory/sqlite_store.py b/cortex/tutor/memory/sqlite_store.py index 0b855598..4a4e8db1 100644 --- a/cortex/tutor/memory/sqlite_store.py +++ b/cortex/tutor/memory/sqlite_store.py @@ -359,12 +359,13 @@ def get_student_profile(self) -> StudentProfile: return self._create_default_profile() def _create_default_profile(self) -> StudentProfile: - """Create and return a default student profile.""" + """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 INTO student_profile (mastered_concepts, weak_concepts, learning_style) + INSERT OR IGNORE INTO student_profile (mastered_concepts, weak_concepts, learning_style) VALUES (?, ?, ?) """, ( @@ -374,6 +375,17 @@ def _create_default_profile(self) -> StudentProfile: ), ) conn.commit() + # Re-fetch to return actual profile (in case another thread created it) + cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + 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: diff --git a/cortex/tutor/tests/test_progress_tracker.py b/cortex/tutor/tests/test_progress_tracker.py index fd4e500e..53c476a6 100644 --- a/cortex/tutor/tests/test_progress_tracker.py +++ b/cortex/tutor/tests/test_progress_tracker.py @@ -292,13 +292,53 @@ class TestConvenienceFunctions: def test_get_learning_progress(self, temp_db): """Test get_learning_progress function.""" - # Note: Uses global config, may need adjustment for isolated testing - pass + from unittest.mock import patch, Mock + + # 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"] == 0.85 def test_mark_topic_completed(self, temp_db): """Test mark_topic_completed function.""" - pass + from unittest.mock import patch, Mock + + 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.""" - pass + from unittest.mock import patch, Mock + + 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"] == 0.8 # (0.9 + 0.7) / 2 diff --git a/cortex/tutor/tools/deterministic/lesson_loader.py b/cortex/tutor/tools/deterministic/lesson_loader.py index 950ae2b9..a938923d 100644 --- a/cortex/tutor/tools/deterministic/lesson_loader.py +++ b/cortex/tutor/tools/deterministic/lesson_loader.py @@ -133,13 +133,21 @@ def clear_cache(self, package_name: Optional[str] = None) -> int: Clear cached lessons. Args: - package_name: Specific package to clear, or None for all. + package_name: Specific package to mark as expired (makes it + unretrievable via get_cached_lesson). If None, removes + only already-expired entries from the database. Returns: - Number of entries cleared. + int: For specific package - 1 if marked as expired, 0 on error. + For None - number of expired entries actually deleted. + + Note: + When package_name is provided, this marks the entry as expired + by calling cache_lesson with ttl_hours=0, rather than deleting it. + The entry persists until clear_expired_cache() runs. """ if package_name: - # Clear specific package by caching empty with 0 TTL + # Mark specific package as expired by caching empty with 0 TTL try: self.store.cache_lesson(package_name, {}, ttl_hours=0) return 1 diff --git a/docs/AI_TUTOR.md b/docs/AI_TUTOR.md index 0367afd9..a3c9c750 100644 --- a/docs/AI_TUTOR.md +++ b/docs/AI_TUTOR.md @@ -3,7 +3,7 @@ > **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/) -[![Test Coverage](https://img.shields.io/badge/coverage-87%25-brightgreen.svg)](https://github.com/cortexlinux/cortex) +[![Test Coverage](https://img.shields.io/badge/coverage-85.6%25-brightgreen.svg)](https://github.com/cortexlinux/cortex) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/licenses/Apache-2.0) --- @@ -750,7 +750,7 @@ pytest cortex/tutor/tests/test_tutor_agent.py -v ### Test Coverage -Current coverage: **87%** (266 tests) +Current coverage: **85.6%** (266 tests) | Module | Coverage | |--------|----------| diff --git a/requirements.txt b/requirements.txt index 1f730b2a..d8e1e038 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ typing-extensions>=4.0.0 PyYAML==6.0.3 # AI Tutor Dependencies (Issue #131) -langchain>=0.3.0 -langchain-anthropic>=0.3.0 -langgraph>=0.2.0 -pydantic>=2.0.0 +langchain>=0.3.0,<2.0.0 +langchain-anthropic>=0.3.0,<2.0.0 +langgraph>=0.2.0,<2.0.0 +pydantic>=2.0.0,<3.0.0 From d95ee00ed4c51ac40461c7ae7e58f21dc0082593 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 12:36:48 +0530 Subject: [PATCH 03/32] style(tutor): fix ruff lint errors - Fix import sorting (I001) with ruff format - Modernize type annotations for Python 3.10+: - Replace Dict/List/Tuple with dict/list/tuple - Replace Optional[X] with X | None - Remove deprecated typing imports - Fix f-string without placeholders All 266 tests pass. --- cortex/cli.py | 14 ++-- cortex/tutor/__init__.py | 2 +- cortex/tutor/agents/tutor_agent/__init__.py | 2 +- cortex/tutor/agents/tutor_agent/graph.py | 13 ++-- cortex/tutor/agents/tutor_agent/state.py | 34 ++++----- .../tutor/agents/tutor_agent/tutor_agent.py | 34 ++++----- cortex/tutor/branding.py | 11 ++- cortex/tutor/cli.py | 19 +++-- cortex/tutor/config.py | 9 +-- cortex/tutor/contracts/lesson_context.py | 30 ++++---- cortex/tutor/contracts/progress_context.py | 28 +++---- cortex/tutor/memory/sqlite_store.py | 37 ++++----- cortex/tutor/tests/test_agent_methods.py | 37 ++++----- cortex/tutor/tests/test_agentic_tools.py | 3 +- cortex/tutor/tests/test_branding.py | 21 ++--- cortex/tutor/tests/test_cli.py | 22 ++++-- .../tutor/tests/test_deterministic_tools.py | 7 +- cortex/tutor/tests/test_integration.py | 17 +++-- cortex/tutor/tests/test_interactive_tutor.py | 3 +- cortex/tutor/tests/test_progress_tracker.py | 22 ++++-- cortex/tutor/tests/test_tools.py | 13 ++-- cortex/tutor/tests/test_tutor_agent.py | 31 ++++---- cortex/tutor/tests/test_validators.py | 20 ++--- cortex/tutor/tools/__init__.py | 6 +- cortex/tutor/tools/agentic/__init__.py | 2 +- .../tutor/tools/agentic/examples_provider.py | 22 +++--- .../tutor/tools/agentic/lesson_generator.py | 26 +++---- cortex/tutor/tools/agentic/qa_handler.py | 38 +++++----- cortex/tutor/tools/deterministic/__init__.py | 4 +- .../tools/deterministic/lesson_loader.py | 22 +++--- .../tools/deterministic/progress_tracker.py | 76 +++++++++---------- .../tutor/tools/deterministic/validators.py | 35 +++++---- 32 files changed, 342 insertions(+), 318 deletions(-) diff --git a/cortex/cli.py b/cortex/cli.py index 23fb41f9..aaf448c5 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -1025,14 +1025,14 @@ def tutor(self, args: argparse.Namespace) -> int: Returns: Exit code (0 for success, 1 for error). """ + from cortex.tutor.branding import print_banner from cortex.tutor.cli import ( - cmd_teach, - cmd_question, cmd_list_packages, cmd_progress, + cmd_question, cmd_reset, + cmd_teach, ) - from cortex.tutor.branding import print_banner # Handle --list flag if getattr(args, "list", False): @@ -2197,11 +2197,15 @@ def main(): "-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( + "--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") + tutor_parser.add_argument( + "--fresh", "-f", action="store_true", help="Skip cache, generate fresh" + ) # Ask command ask_parser = subparsers.add_parser("ask", help="Ask a question about your system") diff --git a/cortex/tutor/__init__.py b/cortex/tutor/__init__.py index 6c6ed85f..e6757375 100644 --- a/cortex/tutor/__init__.py +++ b/cortex/tutor/__init__.py @@ -8,7 +8,7 @@ __version__ = "0.1.0" __author__ = "Sri Krishna Vamsi" -from cortex.tutor.config import Config 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/tutor_agent/__init__.py b/cortex/tutor/agents/tutor_agent/__init__.py index 9ec39200..2e42e858 100644 --- a/cortex/tutor/agents/tutor_agent/__init__.py +++ b/cortex/tutor/agents/tutor_agent/__init__.py @@ -5,6 +5,6 @@ """ from cortex.tutor.agents.tutor_agent.state import TutorAgentState -from cortex.tutor.agents.tutor_agent.tutor_agent import TutorAgent, InteractiveTutor +from cortex.tutor.agents.tutor_agent.tutor_agent import InteractiveTutor, TutorAgent __all__ = ["TutorAgent", "TutorAgentState", "InteractiveTutor"] diff --git a/cortex/tutor/agents/tutor_agent/graph.py b/cortex/tutor/agents/tutor_agent/graph.py index 731f5136..6b9b39d1 100644 --- a/cortex/tutor/agents/tutor_agent/graph.py +++ b/cortex/tutor/agents/tutor_agent/graph.py @@ -6,22 +6,21 @@ from typing import Literal -from langgraph.graph import StateGraph, END +from langgraph.graph import END, StateGraph from cortex.tutor.agents.tutor_agent.state import ( TutorAgentState, - add_error, add_checkpoint, add_cost, - has_critical_error, - get_session_type, + add_error, get_package_name, + get_session_type, + has_critical_error, ) -from cortex.tutor.tools.deterministic.lesson_loader import LessonLoaderTool -from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - +from cortex.tutor.tools.deterministic.lesson_loader import LessonLoaderTool +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool # ==================== Node Functions ==================== diff --git a/cortex/tutor/agents/tutor_agent/state.py b/cortex/tutor/agents/tutor_agent/state.py index 51a2eb95..b437d1be 100644 --- a/cortex/tutor/agents/tutor_agent/state.py +++ b/cortex/tutor/agents/tutor_agent/state.py @@ -4,16 +4,16 @@ Defines the state schema that flows through the Plan→Act→Reflect workflow. """ -from typing import Any, Dict, List, Optional, TypedDict +from typing import Any, TypedDict class StudentProfileState(TypedDict, total=False): """Student profile state within the agent.""" learning_style: str - mastered_concepts: List[str] - weak_concepts: List[str] - last_session: Optional[str] + mastered_concepts: list[str] + weak_concepts: list[str] + last_session: str | None class LessonContentState(TypedDict, total=False): @@ -22,10 +22,10 @@ class LessonContentState(TypedDict, total=False): package_name: str summary: str explanation: str - use_cases: List[str] - best_practices: List[str] - code_examples: List[Dict[str, Any]] - tutorial_steps: List[Dict[str, Any]] + use_cases: list[str] + best_practices: list[str] + code_examples: list[dict[str, Any]] + tutorial_steps: list[dict[str, Any]] installation_command: str confidence: float @@ -34,7 +34,7 @@ class PlanState(TypedDict, total=False): """Plan phase output state.""" strategy: str # "use_cache", "generate_full", "generate_quick", "qa_mode" - cached_data: Optional[Dict[str, Any]] + cached_data: dict[str, Any] | None estimated_cost: float reasoning: str @@ -71,7 +71,7 @@ class TutorAgentState(TypedDict, total=False): """ # Input - input: Dict[str, Any] + input: dict[str, Any] force_fresh: bool # PLAN phase @@ -82,15 +82,15 @@ class TutorAgentState(TypedDict, total=False): # ACT phase outputs lesson_content: LessonContentState - qa_result: Optional[Dict[str, Any]] - examples_result: Optional[Dict[str, Any]] + qa_result: dict[str, Any] | None + examples_result: dict[str, Any] | None # Combined results - results: Dict[str, Any] + results: dict[str, Any] # Errors and monitoring - errors: List[ErrorState] - checkpoints: List[Dict[str, Any]] + errors: list[ErrorState] + checkpoints: list[dict[str, Any]] # Costs cost_gbp: float @@ -101,13 +101,13 @@ class TutorAgentState(TypedDict, total=False): replan_count: int # Final output - output: Optional[Dict[str, Any]] + output: dict[str, Any] | None def create_initial_state( package_name: str, session_type: str = "lesson", - question: Optional[str] = None, + question: str | None = None, force_fresh: bool = False, ) -> TutorAgentState: """ diff --git a/cortex/tutor/agents/tutor_agent/tutor_agent.py b/cortex/tutor/agents/tutor_agent/tutor_agent.py index a624848f..a53d4d99 100644 --- a/cortex/tutor/agents/tutor_agent/tutor_agent.py +++ b/cortex/tutor/agents/tutor_agent/tutor_agent.py @@ -4,17 +4,17 @@ Provides high-level interface for the Plan→Act→Reflect workflow. """ -from typing import Any, Dict, List, Optional +from typing import Any -from cortex.tutor.agents.tutor_agent.state import TutorAgentState, create_initial_state from cortex.tutor.agents.tutor_agent.graph import get_tutor_graph +from cortex.tutor.agents.tutor_agent.state import TutorAgentState, create_initial_state +from cortex.tutor.branding import console, tutor_print +from cortex.tutor.contracts.lesson_context import LessonContext from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool from cortex.tutor.tools.deterministic.validators import ( validate_package_name, validate_question, ) -from cortex.tutor.contracts.lesson_context import LessonContext -from cortex.tutor.branding import tutor_print, console # Default number of topics per package for progress tracking DEFAULT_TUTOR_TOPICS = 5 @@ -51,7 +51,7 @@ def teach( self, package_name: str, force_fresh: bool = False, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Start a tutoring session for a package. @@ -100,7 +100,7 @@ def ask( self, package_name: str, question: str, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Ask a question about a package. @@ -141,7 +141,7 @@ def ask( return result.get("output", {}) - def get_progress(self, package_name: Optional[str] = None) -> Dict[str, Any]: + def get_progress(self, package_name: str | None = None) -> dict[str, Any]: """ Get learning progress. @@ -155,7 +155,7 @@ def get_progress(self, package_name: Optional[str] = None) -> Dict[str, Any]: 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]: + def get_profile(self) -> dict[str, Any]: """ Get student profile. @@ -197,7 +197,7 @@ def mark_completed(self, package_name: str, topic: str, score: float = 1.0) -> b ) return result.get("success", False) - def reset_progress(self, package_name: Optional[str] = None) -> int: + def reset_progress(self, package_name: str | None = None) -> int: """ Reset learning progress. @@ -210,7 +210,7 @@ def reset_progress(self, package_name: Optional[str] = None) -> int: 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]: + def get_packages_studied(self) -> list[str]: """ Get list of packages that have been studied. @@ -220,7 +220,7 @@ def get_packages_studied(self) -> List[str]: result = self.progress_tool._run("get_packages") return result.get("packages", []) if result.get("success") else [] - def _print_execution_summary(self, result: Dict[str, Any]) -> None: + def _print_execution_summary(self, result: dict[str, Any]) -> None: """Print execution summary for verbose mode.""" output = result.get("output", {}) @@ -259,18 +259,18 @@ def __init__(self, package_name: str, force_fresh: bool = False) -> None: self.package_name = package_name self.force_fresh = force_fresh self.agent = TutorAgent(verbose=False) - self.lesson: Optional[Dict[str, Any]] = None + self.lesson: dict[str, Any] | None = None self.current_step = 0 def start(self) -> None: """Start the interactive tutoring session.""" from cortex.tutor.branding import ( - print_lesson_header, - print_menu, get_user_input, - print_markdown, - print_code_example, print_best_practice, + print_code_example, + print_lesson_header, + print_markdown, + print_menu, print_tutorial_step, ) @@ -365,7 +365,7 @@ def _show_examples(self) -> None: def _run_tutorial(self) -> None: """Run step-by-step tutorial.""" - from cortex.tutor.branding import print_tutorial_step, get_user_input + from cortex.tutor.branding import get_user_input, print_tutorial_step if not self.lesson: return diff --git a/cortex/tutor/branding.py b/cortex/tutor/branding.py index da10143e..e7bde21b 100644 --- a/cortex/tutor/branding.py +++ b/cortex/tutor/branding.py @@ -7,13 +7,12 @@ from typing import Literal, Optional from rich.console import Console +from rich.markdown import Markdown from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn +from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn +from rich.syntax import Syntax from rich.table import Table from rich.text import Text -from rich.markdown import Markdown -from rich.syntax import Syntax - # Global Rich console instance console = Console() @@ -99,7 +98,7 @@ def print_menu(options: list[str], title: str = "Select an option") -> None: console.print() -def print_code_example(code: str, language: str = "python", title: Optional[str] = None) -> None: +def print_code_example(code: str, language: str = "python", title: str | None = None) -> None: """ Print a syntax-highlighted code block. @@ -191,7 +190,7 @@ def print_markdown(content: str) -> None: console.print(md) -def get_user_input(prompt: str, default: Optional[str] = None) -> str: +def get_user_input(prompt: str, default: str | None = None) -> str: """ Get user input with optional default value. diff --git a/cortex/tutor/cli.py b/cortex/tutor/cli.py index ddca8c30..641e00d1 100644 --- a/cortex/tutor/cli.py +++ b/cortex/tutor/cli.py @@ -12,22 +12,21 @@ import argparse import sys -from typing import List, Optional from cortex.tutor import __version__ from cortex.tutor.branding import ( console, + get_user_input, print_banner, - tutor_print, - print_table, - print_progress_summary, print_error_panel, + print_progress_summary, print_success_panel, - get_user_input, + print_table, + tutor_print, ) -from cortex.tutor.tools.deterministic.validators import validate_package_name from cortex.tutor.config import Config from cortex.tutor.memory.sqlite_store import SQLiteStore +from cortex.tutor.tools.deterministic.validators import validate_package_name # Default number of topics per package for progress tracking DEFAULT_TUTOR_TOPICS = 5 @@ -182,7 +181,7 @@ def cmd_question(package: str, question: str, verbose: bool = False) -> int: content = result.get("content", {}) # Print answer - console.print(f"\n[bold cyan]Answer:[/bold cyan]") + console.print("\n[bold cyan]Answer:[/bold cyan]") console.print(content.get("answer", "No answer available")) # Print code example if available @@ -241,7 +240,7 @@ def cmd_list_packages(verbose: bool = False) -> int: return 1 -def cmd_progress(package: Optional[str] = None, verbose: bool = False) -> int: +def cmd_progress(package: str | None = None, verbose: bool = False) -> int: """ Show learning progress. @@ -314,7 +313,7 @@ def cmd_progress(package: Optional[str] = None, verbose: bool = False) -> int: return 1 -def cmd_reset(package: Optional[str] = None, verbose: bool = False) -> int: +def cmd_reset(package: str | None = None, verbose: bool = False) -> int: """ Reset learning progress. @@ -348,7 +347,7 @@ def cmd_reset(package: Optional[str] = None, verbose: bool = False) -> int: return 1 -def main(args: Optional[List[str]] = None) -> int: +def main(args: list[str] | None = None) -> int: """ Main entry point for the CLI. diff --git a/cortex/tutor/config.py b/cortex/tutor/config.py index 4dfab3bf..3597169f 100644 --- a/cortex/tutor/config.py +++ b/cortex/tutor/config.py @@ -11,7 +11,6 @@ from dotenv import load_dotenv from pydantic import BaseModel, Field, field_validator - # Load environment variables from .env file load_dotenv() @@ -28,10 +27,10 @@ class Config(BaseModel): debug: Enable debug mode for verbose logging. """ - anthropic_api_key: Optional[str] = Field( + anthropic_api_key: str | None = Field( default=None, description="Anthropic API key for Claude access" ) - openai_api_key: Optional[str] = Field( + openai_api_key: str | None = Field( default=None, description="Optional OpenAI API key for fallback" ) model: str = Field( @@ -41,7 +40,7 @@ class Config(BaseModel): default=Path.home() / ".cortex", description="Directory for storing tutor data" ) debug: bool = Field(default=False, description="Enable debug mode for verbose logging") - db_path: Optional[Path] = Field(default=None, description="Path to SQLite database") + 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.""" @@ -122,7 +121,7 @@ def validate_api_key(self) -> bool: # Global configuration instance (lazy loaded) -_config: Optional[Config] = None +_config: Config | None = None def get_config() -> Config: diff --git a/cortex/tutor/contracts/lesson_context.py b/cortex/tutor/contracts/lesson_context.py index 31ffcf02..07aea75d 100644 --- a/cortex/tutor/contracts/lesson_context.py +++ b/cortex/tutor/contracts/lesson_context.py @@ -5,7 +5,7 @@ """ from datetime import datetime -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from pydantic import BaseModel, Field @@ -27,8 +27,8 @@ class TutorialStep(BaseModel): 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: Optional[str] = Field(default=None, description="Optional code for this step") - expected_output: Optional[str] = Field( + 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" ) @@ -53,22 +53,22 @@ class LessonContext(BaseModel): description="Detailed explanation of the package functionality", max_length=5000, ) - use_cases: List[str] = Field( + use_cases: list[str] = Field( default_factory=list, description="Common use cases for this package", max_length=10, ) - best_practices: List[str] = Field( + best_practices: list[str] = Field( default_factory=list, description="Best practices when using this package", max_length=10, ) - code_examples: List[CodeExample] = Field( + code_examples: list[CodeExample] = Field( default_factory=list, description="Code examples demonstrating package usage", max_length=5, ) - tutorial_steps: List[TutorialStep] = Field( + tutorial_steps: list[TutorialStep] = Field( default_factory=list, description="Step-by-step tutorial for hands-on learning", max_length=10, @@ -78,10 +78,8 @@ class LessonContext(BaseModel): installation_command: str = Field( ..., description="Command to install the package (apt, pip, etc.)" ) - official_docs_url: Optional[str] = Field( - default=None, description="URL to official documentation" - ) - related_packages: List[str] = Field( + 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, @@ -117,7 +115,7 @@ def get_practice_count(self) -> int: """Get count of best practices.""" return len(self.best_practices) - def to_display_dict(self) -> Dict[str, Any]: + def to_display_dict(self) -> dict[str, Any]: """Convert to dictionary for display purposes.""" return { "package": self.package_name, @@ -138,7 +136,7 @@ class LessonPlanOutput(BaseModel): strategy: Literal["use_cache", "generate_full", "generate_quick"] = Field( ..., description="Strategy chosen for lesson generation" ) - cached_data: Optional[Dict[str, Any]] = Field( + cached_data: dict[str, Any] | None = Field( default=None, description="Cached lesson data if strategy is use_cache" ) estimated_cost: float = Field( @@ -162,18 +160,18 @@ class LessonReflectionOutput(BaseModel): ge=0.0, le=1.0, ) - insights: List[str] = Field( + insights: list[str] = Field( default_factory=list, description="Key insights about the generated lesson", ) - improvements: List[str] = Field( + 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( + 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 index f68355f7..79f9a4c1 100644 --- a/cortex/tutor/contracts/progress_context.py +++ b/cortex/tutor/contracts/progress_context.py @@ -5,7 +5,7 @@ """ from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any from pydantic import BaseModel, Field, computed_field @@ -17,16 +17,16 @@ class TopicProgress(BaseModel): 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: Optional[datetime] = Field(default=None, description="Last access time") + 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: Optional[datetime] = Field(default=None, description="When learning started") - last_session: Optional[datetime] = Field(default=None, description="Last learning session") + 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 @@ -51,7 +51,7 @@ 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) -> Optional[str]: + def get_next_topic(self) -> str | None: """Get the next uncompleted topic.""" for topic in self.topics: if not topic.completed: @@ -78,13 +78,13 @@ class ProgressContext(BaseModel): ) # Progress data - packages: List[PackageProgress] = Field( + packages: list[PackageProgress] = Field( default_factory=list, description="Progress for each package" ) - mastered_concepts: List[str] = Field( + mastered_concepts: list[str] = Field( default_factory=list, description="Concepts the student has mastered" ) - weak_concepts: List[str] = Field( + weak_concepts: list[str] = Field( default_factory=list, description="Concepts the student struggles with" ) @@ -99,7 +99,7 @@ class ProgressContext(BaseModel): default_factory=datetime.utcnow, description="Last update timestamp" ) - def get_package_progress(self, package_name: str) -> Optional[PackageProgress]: + 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: @@ -112,7 +112,7 @@ def get_overall_completion(self) -> float: return 0.0 return sum(p.completion_percentage for p in self.packages) / len(self.packages) - def get_recommendations(self) -> List[str]: + def get_recommendations(self) -> list[str]: """Get learning recommendations based on progress.""" recommendations = [] @@ -130,7 +130,7 @@ def get_recommendations(self) -> List[str]: return recommendations - def to_summary_dict(self) -> Dict[str, Any]: + def to_summary_dict(self) -> dict[str, Any]: """Create a summary dictionary for display.""" return { "packages_started": self.total_packages_started, @@ -152,8 +152,8 @@ class QuizContext(BaseModel): 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") + 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=datetime.utcnow, description="Quiz completion time") @classmethod diff --git a/cortex/tutor/memory/sqlite_store.py b/cortex/tutor/memory/sqlite_store.py index 4a4e8db1..cb632837 100644 --- a/cortex/tutor/memory/sqlite_store.py +++ b/cortex/tutor/memory/sqlite_store.py @@ -6,11 +6,12 @@ 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, Dict, Generator, List, Optional +from typing import Any from pydantic import BaseModel @@ -18,34 +19,34 @@ class LearningProgress(BaseModel): """Model for learning progress records.""" - id: Optional[int] = None + id: int | None = None package_name: str topic: str completed: bool = False score: float = 0.0 - last_accessed: Optional[str] = None + last_accessed: str | None = None total_time_seconds: int = 0 class QuizResult(BaseModel): """Model for quiz result records.""" - id: Optional[int] = None + id: int | None = None package_name: str question: str - user_answer: Optional[str] = None + user_answer: str | None = None correct: bool = False - timestamp: Optional[str] = None + timestamp: str | None = None class StudentProfile(BaseModel): """Model for student profile.""" - id: Optional[int] = None - mastered_concepts: List[str] = [] - weak_concepts: List[str] = [] + id: int | None = None + mastered_concepts: list[str] = [] + weak_concepts: list[str] = [] learning_style: str = "reading" # visual, reading, hands-on - last_session: Optional[str] = None + last_session: str | None = None class SQLiteStore: @@ -139,7 +140,7 @@ def _get_connection(self) -> Generator[sqlite3.Connection, None, None]: # ==================== Learning Progress Methods ==================== - def get_progress(self, package_name: str, topic: str) -> Optional[LearningProgress]: + def get_progress(self, package_name: str, topic: str) -> LearningProgress | None: """ Get learning progress for a specific package and topic. @@ -168,7 +169,7 @@ def get_progress(self, package_name: str, topic: str) -> Optional[LearningProgre ) return None - def get_all_progress(self, package_name: Optional[str] = None) -> List[LearningProgress]: + def get_all_progress(self, package_name: str | None = None) -> list[LearningProgress]: """ Get all learning progress records. @@ -254,7 +255,7 @@ def mark_topic_completed(self, package_name: str, topic: str, score: float = 1.0 ) self.upsert_progress(progress) - def get_completion_stats(self, package_name: str) -> Dict[str, Any]: + def get_completion_stats(self, package_name: str) -> dict[str, Any]: """ Get completion statistics for a package. @@ -308,7 +309,7 @@ def add_quiz_result(self, result: QuizResult) -> int: conn.commit() return cursor.lastrowid - def get_quiz_results(self, package_name: str) -> List[QuizResult]: + def get_quiz_results(self, package_name: str) -> list[QuizResult]: """ Get quiz results for a package. @@ -444,7 +445,7 @@ def add_weak_concept(self, concept: str) -> None: # ==================== Lesson Cache Methods ==================== - def cache_lesson(self, package_name: str, content: Dict[str, Any], ttl_hours: int = 24) -> None: + def cache_lesson(self, package_name: str, content: dict[str, Any], ttl_hours: int = 24) -> None: """ Cache lesson content. @@ -470,7 +471,7 @@ def cache_lesson(self, package_name: str, content: Dict[str, Any], ttl_hours: in ) conn.commit() - def get_cached_lesson(self, package_name: str) -> Optional[Dict[str, Any]]: + def get_cached_lesson(self, package_name: str) -> dict[str, Any] | None: """ Get cached lesson content if not expired. @@ -509,7 +510,7 @@ def clear_expired_cache(self) -> int: # ==================== Utility Methods ==================== - def reset_progress(self, package_name: Optional[str] = None) -> int: + def reset_progress(self, package_name: str | None = None) -> int: """ Reset learning progress. @@ -529,7 +530,7 @@ def reset_progress(self, package_name: Optional[str] = None) -> int: conn.commit() return cursor.rowcount - def get_packages_studied(self) -> List[str]: + def get_packages_studied(self) -> list[str]: """ Get list of all packages that have been studied. diff --git a/cortex/tutor/tests/test_agent_methods.py b/cortex/tutor/tests/test_agent_methods.py index 31d97e05..9d1e6002 100644 --- a/cortex/tutor/tests/test_agent_methods.py +++ b/cortex/tutor/tests/test_agent_methods.py @@ -4,32 +4,33 @@ Comprehensive tests for agent functionality. """ -import pytest -from unittest.mock import Mock, patch, MagicMock import tempfile from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest -from cortex.tutor.agents.tutor_agent.state import ( - TutorAgentState, - create_initial_state, - add_error, - add_checkpoint, - add_cost, - has_critical_error, - get_session_type, - get_package_name, -) from cortex.tutor.agents.tutor_agent.graph import ( - plan_node, - load_cache_node, + create_tutor_graph, + fail_node, generate_lesson_node, + get_tutor_graph, + load_cache_node, + plan_node, qa_node, reflect_node, - fail_node, - route_after_plan, route_after_act, - create_tutor_graph, - get_tutor_graph, + route_after_plan, +) +from cortex.tutor.agents.tutor_agent.state import ( + TutorAgentState, + add_checkpoint, + add_cost, + add_error, + create_initial_state, + get_package_name, + get_session_type, + has_critical_error, ) diff --git a/cortex/tutor/tests/test_agentic_tools.py b/cortex/tutor/tests/test_agentic_tools.py index ce08119a..9c500191 100644 --- a/cortex/tutor/tests/test_agentic_tools.py +++ b/cortex/tutor/tests/test_agentic_tools.py @@ -4,8 +4,9 @@ Tests the _structure_response methods with mocked responses. """ +from unittest.mock import MagicMock, Mock, patch + import pytest -from unittest.mock import Mock, patch, MagicMock class TestLessonGeneratorStructure: diff --git a/cortex/tutor/tests/test_branding.py b/cortex/tutor/tests/test_branding.py index e5f5c825..e8a81701 100644 --- a/cortex/tutor/tests/test_branding.py +++ b/cortex/tutor/tests/test_branding.py @@ -4,25 +4,26 @@ Tests Rich console output functions. """ -import pytest -from unittest.mock import Mock, patch, MagicMock from io import StringIO +from unittest.mock import MagicMock, Mock, patch + +import pytest from cortex.tutor.branding import ( console, - tutor_print, + get_user_input, print_banner, - print_lesson_header, + print_best_practice, print_code_example, + print_error_panel, + print_lesson_header, + print_markdown, print_menu, - print_table, print_progress_summary, - print_markdown, - print_best_practice, - print_tutorial_step, - print_error_panel, print_success_panel, - get_user_input, + print_table, + print_tutorial_step, + tutor_print, ) diff --git a/cortex/tutor/tests/test_cli.py b/cortex/tutor/tests/test_cli.py index 31966499..005d0c9f 100644 --- a/cortex/tutor/tests/test_cli.py +++ b/cortex/tutor/tests/test_cli.py @@ -5,17 +5,18 @@ """ import os -import pytest -from unittest.mock import Mock, patch, MagicMock from io import StringIO +from unittest.mock import MagicMock, Mock, patch + +import pytest from cortex.tutor.cli import ( - create_parser, - cmd_teach, - cmd_question, cmd_list_packages, cmd_progress, + cmd_question, cmd_reset, + cmd_teach, + create_parser, main, ) @@ -105,6 +106,7 @@ def test_blocked_package_name(self): def test_successful_teach(self, mock_tutor_class): """Test successful teach session.""" from cortex.tutor.config import reset_config + reset_config() # Reset config singleton mock_tutor = Mock() @@ -119,6 +121,7 @@ def test_successful_teach(self, mock_tutor_class): def test_teach_with_value_error(self, mock_tutor_class): """Test teach handles ValueError.""" from cortex.tutor.config import reset_config + reset_config() mock_tutor_class.side_effect = ValueError("Test error") @@ -132,6 +135,7 @@ def test_teach_with_value_error(self, mock_tutor_class): def test_teach_with_keyboard_interrupt(self, mock_tutor_class): """Test teach handles KeyboardInterrupt.""" from cortex.tutor.config import reset_config + reset_config() mock_tutor = Mock() @@ -158,6 +162,7 @@ def test_invalid_package(self): def test_successful_question(self, mock_agent_class): """Test successful question.""" from cortex.tutor.config import reset_config + reset_config() mock_agent = Mock() @@ -180,6 +185,7 @@ def test_successful_question(self, mock_agent_class): def test_question_with_code_example(self, mock_agent_class): """Test question with code example in response.""" from cortex.tutor.config import reset_config + reset_config() mock_agent = Mock() @@ -206,6 +212,7 @@ def test_question_with_code_example(self, mock_agent_class): def test_question_validation_failed(self, mock_agent_class): """Test question when validation fails.""" from cortex.tutor.config import reset_config + reset_config() mock_agent = Mock() @@ -275,7 +282,10 @@ def test_progress_for_package(self, mock_config_class, mock_store_class): mock_store = Mock() mock_store.get_completion_stats.return_value = { - "completed": 3, "total": 5, "avg_score": 0.8, "total_time_seconds": 600 + "completed": 3, + "total": 5, + "avg_score": 0.8, + "total_time_seconds": 600, } mock_store_class.return_value = mock_store diff --git a/cortex/tutor/tests/test_deterministic_tools.py b/cortex/tutor/tests/test_deterministic_tools.py index f957677a..15095fc0 100644 --- a/cortex/tutor/tests/test_deterministic_tools.py +++ b/cortex/tutor/tests/test_deterministic_tools.py @@ -4,15 +4,16 @@ Tests for lesson_loader and progress_tracker. """ -import pytest -from unittest.mock import Mock, patch 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, - FALLBACK_LESSONS, ) from cortex.tutor.tools.deterministic.progress_tracker import ( ProgressTrackerTool, diff --git a/cortex/tutor/tests/test_integration.py b/cortex/tutor/tests/test_integration.py index c12ca714..437be378 100644 --- a/cortex/tutor/tests/test_integration.py +++ b/cortex/tutor/tests/test_integration.py @@ -4,18 +4,19 @@ End-to-end tests for the complete tutoring workflow. """ -import pytest -from unittest.mock import Mock, patch, MagicMock +import os import tempfile from pathlib import Path -import os +from unittest.mock import MagicMock, Mock, patch +import pytest + +from cortex.tutor.branding import console, print_banner, tutor_print from cortex.tutor.config import Config, get_config, reset_config -from cortex.tutor.branding import tutor_print, console, print_banner -from cortex.tutor.contracts.lesson_context import LessonContext, CodeExample, TutorialStep +from cortex.tutor.contracts.lesson_context import CodeExample, LessonContext, TutorialStep from cortex.tutor.contracts.progress_context import ( - ProgressContext, PackageProgress, + ProgressContext, TopicProgress, ) @@ -338,12 +339,12 @@ def test_full_lesson_workflow_with_cache( mock_loader_class.return_value = mock_loader # Run workflow - from cortex.tutor.agents.tutor_agent.state import create_initial_state from cortex.tutor.agents.tutor_agent.graph import ( - plan_node, load_cache_node, + plan_node, reflect_node, ) + from cortex.tutor.agents.tutor_agent.state import create_initial_state state = create_initial_state("docker") diff --git a/cortex/tutor/tests/test_interactive_tutor.py b/cortex/tutor/tests/test_interactive_tutor.py index a3e7b803..a3fdfb6d 100644 --- a/cortex/tutor/tests/test_interactive_tutor.py +++ b/cortex/tutor/tests/test_interactive_tutor.py @@ -4,8 +4,9 @@ Tests the interactive menu-driven tutoring interface. """ +from unittest.mock import MagicMock, Mock, call, patch + import pytest -from unittest.mock import Mock, patch, MagicMock, call class TestInteractiveTutorInit: diff --git a/cortex/tutor/tests/test_progress_tracker.py b/cortex/tutor/tests/test_progress_tracker.py index 53c476a6..bb148a19 100644 --- a/cortex/tutor/tests/test_progress_tracker.py +++ b/cortex/tutor/tests/test_progress_tracker.py @@ -11,16 +11,16 @@ import pytest from cortex.tutor.memory.sqlite_store import ( - SQLiteStore, LearningProgress, QuizResult, + SQLiteStore, StudentProfile, ) from cortex.tutor.tools.deterministic.progress_tracker import ( ProgressTrackerTool, get_learning_progress, - mark_topic_completed, get_package_stats, + mark_topic_completed, ) @@ -292,13 +292,15 @@ class TestConvenienceFunctions: def test_get_learning_progress(self, temp_db): """Test get_learning_progress function.""" - from unittest.mock import patch, Mock + 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): + 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 @@ -311,12 +313,14 @@ def test_get_learning_progress(self, temp_db): def test_mark_topic_completed(self, temp_db): """Test mark_topic_completed function.""" - from unittest.mock import patch, Mock + 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): + 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 @@ -327,12 +331,14 @@ def test_mark_topic_completed(self, temp_db): def test_get_package_stats(self, temp_db): """Test get_package_stats function.""" - from unittest.mock import patch, Mock + 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): + 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) diff --git a/cortex/tutor/tests/test_tools.py b/cortex/tutor/tests/test_tools.py index 859206d9..567bc4b9 100644 --- a/cortex/tutor/tests/test_tools.py +++ b/cortex/tutor/tests/test_tools.py @@ -4,20 +4,21 @@ Tests tool functionality with mocked LLM calls. """ -import pytest -from unittest.mock import Mock, patch, MagicMock import tempfile from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest +from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool +from cortex.tutor.tools.agentic.qa_handler import ConversationHandler, QAHandlerTool from cortex.tutor.tools.deterministic.lesson_loader import ( + FALLBACK_LESSONS, LessonLoaderTool, get_fallback_lesson, load_lesson_with_fallback, - FALLBACK_LESSONS, ) -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool -from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool -from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool, ConversationHandler @pytest.fixture diff --git a/cortex/tutor/tests/test_tutor_agent.py b/cortex/tutor/tests/test_tutor_agent.py index abc24618..eb3c7066 100644 --- a/cortex/tutor/tests/test_tutor_agent.py +++ b/cortex/tutor/tests/test_tutor_agent.py @@ -4,28 +4,29 @@ Tests the main agent orchestrator and state management. """ -import pytest -from unittest.mock import Mock, patch, MagicMock import tempfile from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest +from cortex.tutor.agents.tutor_agent.graph import ( + fail_node, + load_cache_node, + plan_node, + reflect_node, + route_after_act, + route_after_plan, +) from cortex.tutor.agents.tutor_agent.state import ( TutorAgentState, - create_initial_state, - add_error, add_checkpoint, add_cost, - has_critical_error, - get_session_type, + add_error, + create_initial_state, get_package_name, -) -from cortex.tutor.agents.tutor_agent.graph import ( - plan_node, - load_cache_node, - reflect_node, - fail_node, - route_after_plan, - route_after_act, + get_session_type, + has_critical_error, ) @@ -292,6 +293,7 @@ def test_teach_validation(self, mock_graph): """Test teach validates package name.""" from cortex.tutor.agents.tutor_agent import TutorAgent from cortex.tutor.config import reset_config + reset_config() with pytest.raises(ValueError) as exc_info: @@ -306,6 +308,7 @@ def test_ask_validation(self, mock_graph): """Test ask validates inputs.""" from cortex.tutor.agents.tutor_agent import TutorAgent from cortex.tutor.config import reset_config + reset_config() agent = TutorAgent() diff --git a/cortex/tutor/tests/test_validators.py b/cortex/tutor/tests/test_validators.py index fb041cba..176cabf5 100644 --- a/cortex/tutor/tests/test_validators.py +++ b/cortex/tutor/tests/test_validators.py @@ -7,19 +7,19 @@ import pytest from cortex.tutor.tools.deterministic.validators import ( - validate_package_name, - validate_input, - validate_question, - validate_topic, - validate_score, - validate_learning_style, - sanitize_input, + MAX_INPUT_LENGTH, + MAX_PACKAGE_NAME_LENGTH, + ValidationResult, extract_package_name, get_validation_errors, + sanitize_input, validate_all, - ValidationResult, - MAX_INPUT_LENGTH, - MAX_PACKAGE_NAME_LENGTH, + validate_input, + validate_learning_style, + validate_package_name, + validate_question, + validate_score, + validate_topic, ) diff --git a/cortex/tutor/tools/__init__.py b/cortex/tutor/tools/__init__.py index 581822cc..6a62ff27 100644 --- a/cortex/tutor/tools/__init__.py +++ b/cortex/tutor/tools/__init__.py @@ -4,11 +4,11 @@ Provides deterministic and agentic tools for the tutoring workflow. """ -from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool -from cortex.tutor.tools.deterministic.validators import validate_package_name, validate_input -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool +from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool +from cortex.tutor.tools.deterministic.validators import validate_input, validate_package_name __all__ = [ "ProgressTrackerTool", diff --git a/cortex/tutor/tools/agentic/__init__.py b/cortex/tutor/tools/agentic/__init__.py index 6d35abd4..3d02b819 100644 --- a/cortex/tutor/tools/agentic/__init__.py +++ b/cortex/tutor/tools/agentic/__init__.py @@ -5,8 +5,8 @@ Used for: lesson generation, code examples, Q&A handling. """ -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool __all__ = [ diff --git a/cortex/tutor/tools/agentic/examples_provider.py b/cortex/tutor/tools/agentic/examples_provider.py index 2e0d9471..108b935c 100644 --- a/cortex/tutor/tools/agentic/examples_provider.py +++ b/cortex/tutor/tools/agentic/examples_provider.py @@ -5,12 +5,12 @@ """ from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from langchain.tools import BaseTool from langchain_anthropic import ChatAnthropic -from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import JsonOutputParser +from langchain_core.prompts import ChatPromptTemplate from pydantic import Field from cortex.tutor.config import get_config @@ -33,13 +33,13 @@ class ExamplesProviderTool(BaseTool): "Returns examples with progressive complexity." ) - llm: Optional[ChatAnthropic] = Field(default=None, exclude=True) + llm: ChatAnthropic | None = Field(default=None, exclude=True) model_name: str = Field(default="claude-sonnet-4-20250514") class Config: arbitrary_types_allowed = True - def __init__(self, model_name: Optional[str] = None) -> None: + def __init__(self, model_name: str | None = None) -> None: """ Initialize the examples provider tool. @@ -62,8 +62,8 @@ def _run( topic: str, difficulty: str = "beginner", learning_style: str = "hands-on", - existing_knowledge: Optional[List[str]] = None, - ) -> Dict[str, Any]: + existing_knowledge: list[str] | None = None, + ) -> dict[str, Any]: """ Generate code examples for a package topic. @@ -118,8 +118,8 @@ async def _arun( topic: str, difficulty: str = "beginner", learning_style: str = "hands-on", - existing_knowledge: Optional[List[str]] = None, - ) -> Dict[str, Any]: + existing_knowledge: list[str] | None = None, + ) -> dict[str, Any]: """Async version of example generation.""" try: prompt = ChatPromptTemplate.from_messages( @@ -206,8 +206,8 @@ def _get_generation_prompt(self) -> str: Ensure all examples are safe and educational.""" def _structure_response( - self, response: Dict[str, Any], package_name: str, topic: str - ) -> Dict[str, Any]: + self, response: dict[str, Any], package_name: str, topic: str + ) -> dict[str, Any]: """Structure and validate the LLM response.""" structured = { "package_name": response.get("package_name", package_name), @@ -240,7 +240,7 @@ def generate_examples( package_name: str, topic: str, difficulty: str = "beginner", -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Convenience function to generate code examples. diff --git a/cortex/tutor/tools/agentic/lesson_generator.py b/cortex/tutor/tools/agentic/lesson_generator.py index d314a4cf..1d7b7f59 100644 --- a/cortex/tutor/tools/agentic/lesson_generator.py +++ b/cortex/tutor/tools/agentic/lesson_generator.py @@ -6,16 +6,16 @@ """ from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from langchain.tools import BaseTool from langchain_anthropic import ChatAnthropic -from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import JsonOutputParser +from langchain_core.prompts import ChatPromptTemplate from pydantic import Field from cortex.tutor.config import get_config -from cortex.tutor.contracts.lesson_context import LessonContext, CodeExample, TutorialStep +from cortex.tutor.contracts.lesson_context import CodeExample, LessonContext, TutorialStep # Load prompt template @@ -56,13 +56,13 @@ class LessonGeneratorTool(BaseTool): "Returns structured lesson with explanations, examples, and tutorials." ) - llm: Optional[ChatAnthropic] = Field(default=None, exclude=True) + llm: ChatAnthropic | None = Field(default=None, exclude=True) model_name: str = Field(default="claude-sonnet-4-20250514") class Config: arbitrary_types_allowed = True - def __init__(self, model_name: Optional[str] = None) -> None: + def __init__(self, model_name: str | None = None) -> None: """ Initialize the lesson generator tool. @@ -84,9 +84,9 @@ def _run( package_name: str, student_level: str = "beginner", learning_style: str = "reading", - focus_areas: Optional[List[str]] = None, - skip_areas: Optional[List[str]] = None, - ) -> Dict[str, Any]: + focus_areas: list[str] | None = None, + skip_areas: list[str] | None = None, + ) -> dict[str, Any]: """ Generate lesson content for a package. @@ -144,9 +144,9 @@ async def _arun( package_name: str, student_level: str = "beginner", learning_style: str = "reading", - focus_areas: Optional[List[str]] = None, - skip_areas: Optional[List[str]] = None, - ) -> Dict[str, Any]: + focus_areas: list[str] | None = None, + skip_areas: list[str] | None = None, + ) -> dict[str, Any]: """Async version of lesson generation.""" try: prompt = ChatPromptTemplate.from_messages( @@ -248,7 +248,7 @@ def _get_generation_prompt(self) -> str: - Tutorial steps have logical progression - Confidence reflects your actual certainty (0.5-1.0)""" - def _structure_response(self, response: Dict[str, Any], package_name: str) -> Dict[str, Any]: + def _structure_response(self, response: dict[str, Any], package_name: str) -> dict[str, Any]: """ Structure and validate the LLM response. @@ -307,7 +307,7 @@ def generate_lesson( package_name: str, student_level: str = "beginner", learning_style: str = "reading", -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Convenience function to generate a lesson. diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index 30756948..efc54449 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -5,12 +5,12 @@ """ from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from langchain.tools import BaseTool from langchain_anthropic import ChatAnthropic -from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import JsonOutputParser +from langchain_core.prompts import ChatPromptTemplate from pydantic import Field from cortex.tutor.config import get_config @@ -33,13 +33,13 @@ class QAHandlerTool(BaseTool): "Provides contextual answers based on student profile." ) - llm: Optional[ChatAnthropic] = Field(default=None, exclude=True) + llm: ChatAnthropic | None = Field(default=None, exclude=True) model_name: str = Field(default="claude-sonnet-4-20250514") class Config: arbitrary_types_allowed = True - def __init__(self, model_name: Optional[str] = None) -> None: + def __init__(self, model_name: str | None = None) -> None: """ Initialize the Q&A handler tool. @@ -61,10 +61,10 @@ def _run( package_name: str, question: str, learning_style: str = "reading", - mastered_concepts: Optional[List[str]] = None, - weak_concepts: Optional[List[str]] = None, - lesson_context: Optional[str] = None, - ) -> Dict[str, Any]: + mastered_concepts: list[str] | None = None, + weak_concepts: list[str] | None = None, + lesson_context: str | None = None, + ) -> dict[str, Any]: """ Answer a user question about a package. @@ -120,10 +120,10 @@ async def _arun( package_name: str, question: str, learning_style: str = "reading", - mastered_concepts: Optional[List[str]] = None, - weak_concepts: Optional[List[str]] = None, - lesson_context: Optional[str] = None, - ) -> Dict[str, Any]: + mastered_concepts: list[str] | None = None, + weak_concepts: list[str] | None = None, + lesson_context: str | None = None, + ) -> dict[str, Any]: """Async version of Q&A handling.""" try: prompt = ChatPromptTemplate.from_messages( @@ -213,8 +213,8 @@ def _get_qa_prompt(self) -> str: If the question is unclear, ask for clarification in the answer field.""" def _structure_response( - self, response: Dict[str, Any], package_name: str, question: str - ) -> Dict[str, Any]: + self, response: dict[str, Any], package_name: str, question: str + ) -> dict[str, Any]: """Structure and validate the LLM response.""" structured = { "package_name": package_name, @@ -245,7 +245,7 @@ def answer_question( package_name: str, question: str, learning_style: str = "reading", -) -> Dict[str, Any]: +) -> dict[str, Any]: """ Convenience function to answer a question. @@ -280,16 +280,16 @@ def __init__(self, package_name: str) -> None: package_name: Package being discussed. """ self.package_name = package_name - self.history: List[Dict[str, str]] = [] + self.history: list[dict[str, str]] = [] self.qa_tool = QAHandlerTool() def ask( self, question: str, learning_style: str = "reading", - mastered_concepts: Optional[List[str]] = None, - weak_concepts: Optional[List[str]] = None, - ) -> Dict[str, Any]: + mastered_concepts: list[str] | None = None, + weak_concepts: list[str] | None = None, + ) -> dict[str, Any]: """ Ask a question with conversation history. diff --git a/cortex/tutor/tools/deterministic/__init__.py b/cortex/tutor/tools/deterministic/__init__.py index 1afed915..38dedd29 100644 --- a/cortex/tutor/tools/deterministic/__init__.py +++ b/cortex/tutor/tools/deterministic/__init__.py @@ -5,9 +5,9 @@ Used for: progress tracking, input validation, lesson loading. """ -from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool -from cortex.tutor.tools.deterministic.validators import validate_package_name, validate_input 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", diff --git a/cortex/tutor/tools/deterministic/lesson_loader.py b/cortex/tutor/tools/deterministic/lesson_loader.py index a938923d..0ef1d288 100644 --- a/cortex/tutor/tools/deterministic/lesson_loader.py +++ b/cortex/tutor/tools/deterministic/lesson_loader.py @@ -5,14 +5,14 @@ """ from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any from langchain.tools import BaseTool from pydantic import Field from cortex.tutor.config import get_config -from cortex.tutor.memory.sqlite_store import SQLiteStore from cortex.tutor.contracts.lesson_context import LessonContext +from cortex.tutor.memory.sqlite_store import SQLiteStore class LessonLoaderTool(BaseTool): @@ -30,12 +30,12 @@ class LessonLoaderTool(BaseTool): "Returns None if no valid cache exists." ) - store: Optional[SQLiteStore] = Field(default=None, exclude=True) + store: SQLiteStore | None = Field(default=None, exclude=True) class Config: arbitrary_types_allowed = True - def __init__(self, db_path: Optional[Path] = None) -> None: + def __init__(self, db_path: Path | None = None) -> None: """ Initialize the lesson loader tool. @@ -52,7 +52,7 @@ def _run( self, package_name: str, force_fresh: bool = False, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Load cached lesson content. @@ -101,14 +101,14 @@ async def _arun( self, package_name: str, force_fresh: bool = False, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Async version - delegates to sync implementation.""" return self._run(package_name, force_fresh) def cache_lesson( self, package_name: str, - lesson: Dict[str, Any], + lesson: dict[str, Any], ttl_hours: int = 24, ) -> bool: """ @@ -128,7 +128,7 @@ def cache_lesson( except Exception: return False - def clear_cache(self, package_name: Optional[str] = None) -> int: + def clear_cache(self, package_name: str | None = None) -> int: """ Clear cached lessons. @@ -232,7 +232,7 @@ def clear_cache(self, package_name: Optional[str] = None) -> int: } -def get_fallback_lesson(package_name: str) -> Optional[Dict[str, Any]]: +def get_fallback_lesson(package_name: str) -> dict[str, Any] | None: """ Get a fallback lesson template for common packages. @@ -247,8 +247,8 @@ def get_fallback_lesson(package_name: str) -> Optional[Dict[str, Any]]: def load_lesson_with_fallback( package_name: str, - db_path: Optional[Path] = None, -) -> Dict[str, Any]: + db_path: Path | None = None, +) -> dict[str, Any]: """ Load lesson from cache with fallback to templates. diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py index b19f894f..61550713 100644 --- a/cortex/tutor/tools/deterministic/progress_tracker.py +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -7,15 +7,15 @@ from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any from langchain.tools import BaseTool from pydantic import Field from cortex.tutor.config import get_config from cortex.tutor.memory.sqlite_store import ( - SQLiteStore, LearningProgress, + SQLiteStore, StudentProfile, ) @@ -40,12 +40,12 @@ class ProgressTrackerTool(BaseTool): "This is a fast, deterministic tool with no LLM cost." ) - store: Optional[SQLiteStore] = Field(default=None, exclude=True) + store: SQLiteStore | None = Field(default=None, exclude=True) class Config: arbitrary_types_allowed = True - def __init__(self, db_path: Optional[Path] = None) -> None: + def __init__(self, db_path: Path | None = None) -> None: """ Initialize the progress tracker tool. @@ -61,12 +61,12 @@ def __init__(self, db_path: Optional[Path] = None) -> None: def _run( self, action: str, - package_name: Optional[str] = None, - topic: Optional[str] = None, - score: Optional[float] = None, - time_seconds: Optional[int] = None, + package_name: str | None = None, + topic: str | None = None, + score: float | None = None, + time_seconds: int | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Execute a progress tracking action. @@ -112,16 +112,16 @@ def _run( except Exception as e: return {"success": False, "error": str(e)} - async def _arun(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: + async def _arun(self, *args: Any, **kwargs: Any) -> dict[str, Any]: """Async version - delegates to sync implementation.""" return self._run(*args, **kwargs) def _get_progress( self, - package_name: Optional[str], - topic: Optional[str], + package_name: str | None, + topic: str | None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get progress for a specific package/topic.""" if not package_name or not topic: return {"success": False, "error": "package_name and topic required"} @@ -143,9 +143,9 @@ def _get_progress( def _get_all_progress( self, - package_name: Optional[str] = None, + package_name: str | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get all progress, optionally filtered by package.""" progress_list = self.store.get_all_progress(package_name) return { @@ -165,11 +165,11 @@ def _get_all_progress( def _mark_completed( self, - package_name: Optional[str], - topic: Optional[str], - score: Optional[float] = None, + package_name: str | None, + topic: str | None, + score: float | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Mark a topic as completed.""" if not package_name or not topic: return {"success": False, "error": "package_name and topic required"} @@ -183,13 +183,13 @@ def _mark_completed( def _update_progress( self, - package_name: Optional[str], - topic: Optional[str], - score: Optional[float] = None, - time_seconds: Optional[int] = None, + package_name: str | None, + topic: str | None, + score: float | None = None, + time_seconds: int | None = None, completed: bool = False, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Update progress for a topic.""" if not package_name or not topic: return {"success": False, "error": "package_name and topic required"} @@ -214,9 +214,9 @@ def _update_progress( def _get_stats( self, - package_name: Optional[str], + package_name: str | None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Get completion statistics for a package.""" if not package_name: return {"success": False, "error": "package_name required"} @@ -230,7 +230,7 @@ def _get_stats( ), } - def _get_profile(self, **kwargs: Any) -> Dict[str, Any]: + def _get_profile(self, **kwargs: Any) -> dict[str, Any]: """Get student profile.""" profile = self.store.get_student_profile() return { @@ -245,9 +245,9 @@ def _get_profile(self, **kwargs: Any) -> Dict[str, Any]: def _update_profile( self, - learning_style: Optional[str] = None, + learning_style: str | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Update student profile.""" profile = self.store.get_student_profile() if learning_style: @@ -257,9 +257,9 @@ def _update_profile( def _add_mastered_concept( self, - concept: Optional[str] = None, + concept: str | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Add a mastered concept to student profile.""" concept = kwargs.get("concept") or concept if not concept: @@ -269,9 +269,9 @@ def _add_mastered_concept( def _add_weak_concept( self, - concept: Optional[str] = None, + concept: str | None = None, **kwargs: Any, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Add a weak concept to student profile.""" concept = kwargs.get("concept") or concept if not concept: @@ -281,15 +281,15 @@ def _add_weak_concept( def _reset_progress( self, - package_name: Optional[str] = None, + package_name: str | None = None, **kwargs: Any, - ) -> Dict[str, 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, "message": f"Reset {count} progress records {scope}"} - def _get_packages_studied(self, **kwargs: Any) -> Dict[str, Any]: + 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)} @@ -298,7 +298,7 @@ def _get_packages_studied(self, **kwargs: Any) -> Dict[str, Any]: # Convenience functions for direct usage -def get_learning_progress(package_name: str, topic: str) -> Optional[Dict[str, Any]]: +def get_learning_progress(package_name: str, topic: str) -> dict[str, Any] | None: """ Get learning progress for a specific topic. @@ -331,7 +331,7 @@ def mark_topic_completed(package_name: str, topic: str, score: float = 1.0) -> b return result.get("success", False) -def get_package_stats(package_name: str) -> Dict[str, Any]: +def get_package_stats(package_name: str) -> dict[str, Any]: """ Get completion statistics for a package. diff --git a/cortex/tutor/tools/deterministic/validators.py b/cortex/tutor/tools/deterministic/validators.py index 88d720de..b6191ad7 100644 --- a/cortex/tutor/tools/deterministic/validators.py +++ b/cortex/tutor/tools/deterministic/validators.py @@ -6,7 +6,6 @@ """ import re -from typing import Tuple, List, Optional # Maximum input length to prevent abuse MAX_INPUT_LENGTH = 1000 @@ -49,7 +48,7 @@ ] -def validate_package_name(package_name: str) -> Tuple[bool, Optional[str]]: +def validate_package_name(package_name: str) -> tuple[bool, str | None]: """ Validate a package name for safety and format. @@ -98,7 +97,7 @@ def validate_input( input_text: str, max_length: int = MAX_INPUT_LENGTH, allow_empty: bool = False, -) -> Tuple[bool, Optional[str]]: +) -> tuple[bool, str | None]: """ Validate general user input for safety. @@ -136,7 +135,7 @@ def validate_input( return True, None -def validate_question(question: str) -> Tuple[bool, Optional[str]]: +def validate_question(question: str) -> tuple[bool, str | None]: """ Validate a user question for the Q&A system. @@ -149,7 +148,7 @@ def validate_question(question: str) -> Tuple[bool, Optional[str]]: return validate_input(question, max_length=MAX_QUESTION_LENGTH, allow_empty=False) -def validate_topic(topic: str) -> Tuple[bool, Optional[str]]: +def validate_topic(topic: str) -> tuple[bool, str | None]: """ Validate a topic name. @@ -174,7 +173,7 @@ def validate_topic(topic: str) -> Tuple[bool, Optional[str]]: return True, None -def validate_score(score: float) -> Tuple[bool, Optional[str]]: +def validate_score(score: float) -> tuple[bool, str | None]: """ Validate a score value. @@ -193,7 +192,7 @@ def validate_score(score: float) -> Tuple[bool, Optional[str]]: return True, None -def validate_learning_style(style: str) -> Tuple[bool, Optional[str]]: +def validate_learning_style(style: str) -> tuple[bool, str | None]: """ Validate a learning style preference. @@ -237,7 +236,7 @@ def sanitize_input(input_text: str) -> str: return sanitized -def extract_package_name(input_text: str) -> Optional[str]: +def extract_package_name(input_text: str) -> str | None: """ Extract a potential package name from user input. @@ -279,11 +278,11 @@ def extract_package_name(input_text: str) -> Optional[str]: def get_validation_errors( - package_name: Optional[str] = None, - topic: Optional[str] = None, - question: Optional[str] = None, - score: Optional[float] = None, -) -> List[str]: + 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. @@ -324,7 +323,7 @@ def get_validation_errors( class ValidationResult: """Result of a validation operation.""" - def __init__(self, is_valid: bool, errors: Optional[List[str]] = None) -> None: + def __init__(self, is_valid: bool, errors: list[str] | None = None) -> None: """ Initialize validation result. @@ -347,10 +346,10 @@ def __str__(self) -> str: def validate_all( - package_name: Optional[str] = None, - topic: Optional[str] = None, - question: Optional[str] = None, - score: Optional[float] = None, + 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. From a799d273fc7eec54f48eb50886053ff53175b200 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 12:46:28 +0530 Subject: [PATCH 04/32] test(tutor): move tests to standard location for CI coverage The tutor tests were in cortex/tutor/tests/ but CI runs pytest tests/ - causing 0% coverage on tutor code. Copied tests to tests/tutor/ so CI discovers them and includes tutor code in coverage metrics. All 266 tutor tests pass from new location. --- tests/tutor/__init__.py | 5 + tests/tutor/test_agent_methods.py | 440 +++++++++++++++++++++++ tests/tutor/test_agentic_tools.py | 197 +++++++++++ tests/tutor/test_branding.py | 249 +++++++++++++ tests/tutor/test_cli.py | 452 ++++++++++++++++++++++++ tests/tutor/test_deterministic_tools.py | 179 ++++++++++ tests/tutor/test_integration.py | 364 +++++++++++++++++++ tests/tutor/test_interactive_tutor.py | 263 ++++++++++++++ tests/tutor/test_progress_tracker.py | 350 ++++++++++++++++++ tests/tutor/test_tools.py | 309 ++++++++++++++++ tests/tutor/test_tutor_agent.py | 320 +++++++++++++++++ tests/tutor/test_validators.py | 302 ++++++++++++++++ 12 files changed, 3430 insertions(+) create mode 100644 tests/tutor/__init__.py create mode 100644 tests/tutor/test_agent_methods.py create mode 100644 tests/tutor/test_agentic_tools.py create mode 100644 tests/tutor/test_branding.py create mode 100644 tests/tutor/test_cli.py create mode 100644 tests/tutor/test_deterministic_tools.py create mode 100644 tests/tutor/test_integration.py create mode 100644 tests/tutor/test_interactive_tutor.py create mode 100644 tests/tutor/test_progress_tracker.py create mode 100644 tests/tutor/test_tools.py create mode 100644 tests/tutor/test_tutor_agent.py create mode 100644 tests/tutor/test_validators.py 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_agent_methods.py b/tests/tutor/test_agent_methods.py new file mode 100644 index 00000000..9d1e6002 --- /dev/null +++ b/tests/tutor/test_agent_methods.py @@ -0,0 +1,440 @@ +""" +Tests for TutorAgent methods and graph nodes. + +Comprehensive tests for agent functionality. +""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cortex.tutor.agents.tutor_agent.graph import ( + create_tutor_graph, + fail_node, + generate_lesson_node, + get_tutor_graph, + load_cache_node, + plan_node, + qa_node, + reflect_node, + route_after_act, + route_after_plan, +) +from cortex.tutor.agents.tutor_agent.state import ( + TutorAgentState, + add_checkpoint, + add_cost, + add_error, + create_initial_state, + get_package_name, + get_session_type, + has_critical_error, +) + + +class TestTutorAgentMethods: + """Tests for TutorAgent class methods.""" + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_teach_success(self, mock_tracker_class, mock_graph): + """Test successful teach method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + mock_g = Mock() + mock_g.invoke.return_value = { + "output": { + "validation_passed": True, + "type": "lesson", + "content": {"summary": "Docker is..."}, + } + } + mock_graph.return_value = mock_g + + agent = TutorAgent(verbose=False) + result = agent.teach("docker") + + assert result["validation_passed"] is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_teach_verbose(self, mock_tracker_class, mock_graph): + """Test teach with verbose mode.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + mock_g = Mock() + mock_g.invoke.return_value = { + "output": { + "validation_passed": True, + "type": "lesson", + "source": "cache", + "cache_hit": True, + "cost_gbp": 0.0, + "cost_saved_gbp": 0.02, + "confidence": 0.9, + } + } + mock_graph.return_value = mock_g + + with patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print"): + with patch("cortex.tutor.agents.tutor_agent.tutor_agent.console"): + agent = TutorAgent(verbose=True) + result = agent.teach("docker") + + assert result["validation_passed"] is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_ask_success(self, mock_tracker_class, mock_graph): + """Test successful ask method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker_class.return_value = mock_tracker + + mock_g = Mock() + mock_g.invoke.return_value = { + "output": { + "validation_passed": True, + "type": "qa", + "content": {"answer": "Docker is a container platform."}, + } + } + mock_graph.return_value = mock_g + + agent = TutorAgent() + result = agent.ask("docker", "What is Docker?") + + assert result["validation_passed"] is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_get_profile(self, mock_tracker_class, mock_graph): + """Test get_profile method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = { + "success": True, + "profile": {"learning_style": "visual"}, + } + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.get_profile() + + assert result["success"] is True + assert "profile" in result + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_update_learning_style(self, mock_tracker_class, mock_graph): + """Test update_learning_style method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.update_learning_style("visual") + + assert result is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_mark_completed(self, mock_tracker_class, mock_graph): + """Test mark_completed method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.mark_completed("docker", "basics", 0.9) + + assert result is True + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_reset_progress(self, mock_tracker_class, mock_graph): + """Test reset_progress method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "count": 5} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.reset_progress() + + assert result == 5 + + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") + def test_get_packages_studied(self, mock_tracker_class, mock_graph): + """Test get_packages_studied method.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "packages": ["docker", "nginx"]} + mock_tracker_class.return_value = mock_tracker + + agent = TutorAgent() + result = agent.get_packages_studied() + + assert result == ["docker", "nginx"] + + +class TestGenerateLessonNode: + """Tests for generate_lesson_node.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + def test_generate_lesson_success(self, mock_generator_class, mock_loader_class): + """Test successful lesson generation.""" + mock_generator = Mock() + mock_generator._run.return_value = { + "success": True, + "lesson": { + "package_name": "docker", + "summary": "Docker is a container platform.", + "explanation": "Docker allows...", + }, + "cost_gbp": 0.02, + } + mock_generator_class.return_value = mock_generator + + mock_loader = Mock() + mock_loader.cache_lesson.return_value = True + mock_loader_class.return_value = mock_loader + + state = create_initial_state("docker") + state["student_profile"] = {"learning_style": "reading"} + + result = generate_lesson_node(state) + + assert result["results"]["type"] == "lesson" + assert result["results"]["source"] == "generated" + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + def test_generate_lesson_failure(self, mock_generator_class): + """Test lesson generation failure.""" + mock_generator = Mock() + mock_generator._run.return_value = { + "success": False, + "error": "API error", + } + mock_generator_class.return_value = mock_generator + + state = create_initial_state("docker") + state["student_profile"] = {} + + result = generate_lesson_node(state) + + assert len(result["errors"]) > 0 + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + def test_generate_lesson_exception(self, mock_generator_class): + """Test lesson generation with exception.""" + mock_generator_class.side_effect = Exception("Test exception") + + state = create_initial_state("docker") + state["student_profile"] = {} + + result = generate_lesson_node(state) + + assert len(result["errors"]) > 0 + + +class TestQANode: + """Tests for qa_node.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_success(self, mock_qa_class): + """Test successful Q&A.""" + mock_qa = Mock() + mock_qa._run.return_value = { + "success": True, + "answer": { + "answer": "Docker is a containerization platform.", + "explanation": "It allows...", + }, + "cost_gbp": 0.02, + } + mock_qa_class.return_value = mock_qa + + state = create_initial_state("docker", session_type="qa", question="What is Docker?") + state["student_profile"] = {} + + result = qa_node(state) + + assert result["results"]["type"] == "qa" + assert result["qa_result"] is not None + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_no_question(self, mock_qa_class): + """Test Q&A without question.""" + state = create_initial_state("docker", session_type="qa") + # No question provided + + result = qa_node(state) + + assert len(result["errors"]) > 0 + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_failure(self, mock_qa_class): + """Test Q&A failure.""" + mock_qa = Mock() + mock_qa._run.return_value = { + "success": False, + "error": "Could not answer", + } + mock_qa_class.return_value = mock_qa + + state = create_initial_state("docker", session_type="qa", question="What?") + state["student_profile"] = {} + + result = qa_node(state) + + assert len(result["errors"]) > 0 + + @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") + def test_qa_exception(self, mock_qa_class): + """Test Q&A with exception.""" + mock_qa_class.side_effect = Exception("Test error") + + state = create_initial_state("docker", session_type="qa", question="What?") + state["student_profile"] = {} + + result = qa_node(state) + + assert len(result["errors"]) > 0 + + +class TestReflectNode: + """Tests for reflect_node.""" + + def test_reflect_with_errors(self): + """Test reflect with non-critical errors.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {"summary": "Test"}, "source": "cache"} + add_error(state, "test", "Minor error", recoverable=True) + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is True + assert result["output"]["confidence"] < 1.0 + + def test_reflect_with_critical_error(self): + """Test reflect with critical error.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {"summary": "Test"}} + add_error(state, "test", "Critical error", recoverable=False) + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is False + + +class TestFailNode: + """Tests for fail_node.""" + + def test_fail_node_with_errors(self): + """Test fail node with multiple errors.""" + state = create_initial_state("docker") + add_error(state, "test1", "Error 1") + add_error(state, "test2", "Error 2") + state["cost_gbp"] = 0.01 + + result = fail_node(state) + + assert result["output"]["type"] == "error" + assert result["output"]["validation_passed"] is False + assert len(result["output"]["validation_errors"]) == 2 + + +class TestRouting: + """Tests for routing functions.""" + + def test_route_after_plan_fail_on_error(self): + """Test routing to fail on critical error.""" + state = create_initial_state("docker") + add_error(state, "test", "Critical", recoverable=False) + + route = route_after_plan(state) + assert route == "fail" + + def test_route_after_act_fail_no_results(self): + """Test routing to fail when no results.""" + state = create_initial_state("docker") + state["results"] = {} + + route = route_after_act(state) + assert route == "fail" + + def test_route_after_act_success(self): + """Test routing to reflect on success.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {}} + + route = route_after_act(state) + assert route == "reflect" + + +class TestGraphCreation: + """Tests for graph creation.""" + + def test_create_tutor_graph(self): + """Test graph is created successfully.""" + graph = create_tutor_graph() + assert graph is not None + + def test_get_tutor_graph_singleton(self): + """Test get_tutor_graph returns singleton.""" + graph1 = get_tutor_graph() + graph2 = get_tutor_graph() + assert graph1 is graph2 + + +class TestStateHelpers: + """Tests for state helper functions.""" + + def test_add_checkpoint(self): + """Test add_checkpoint adds to list.""" + state = create_initial_state("docker") + add_checkpoint(state, "test", "ok", "Test checkpoint") + + assert len(state["checkpoints"]) == 1 + assert state["checkpoints"][0]["name"] == "test" + assert state["checkpoints"][0]["status"] == "ok" + + def test_add_cost(self): + """Test add_cost accumulates.""" + state = create_initial_state("docker") + add_cost(state, 0.01) + add_cost(state, 0.02) + add_cost(state, 0.005) + + assert abs(state["cost_gbp"] - 0.035) < 0.0001 + + def test_get_session_type_default(self): + """Test default session type.""" + state = create_initial_state("docker") + assert get_session_type(state) == "lesson" + + def test_get_package_name(self): + """Test getting package name.""" + state = create_initial_state("nginx") + assert get_package_name(state) == "nginx" diff --git a/tests/tutor/test_agentic_tools.py b/tests/tutor/test_agentic_tools.py new file mode 100644 index 00000000..9c500191 --- /dev/null +++ b/tests/tutor/test_agentic_tools.py @@ -0,0 +1,197 @@ +""" +Tests for agentic tools structure methods. + +Tests the _structure_response methods with mocked responses. +""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + + +class TestLessonGeneratorStructure: + """Tests for LessonGeneratorTool structure methods.""" + + @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") + @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") + def test_structure_response_full(self, mock_llm_class, mock_config): + """Test structure_response with full response.""" + from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = LessonGeneratorTool() + + response = { + "package_name": "docker", + "summary": "Docker is a platform.", + "explanation": "Docker allows...", + "use_cases": ["Dev", "Prod"], + "best_practices": ["Use official images"], + "code_examples": [{"title": "Run", "code": "docker run", "language": "bash"}], + "tutorial_steps": [{"step_number": 1, "title": "Start", "content": "Begin"}], + "installation_command": "apt install docker", + "related_packages": ["podman"], + "confidence": 0.9, + } + + result = tool._structure_response(response, "docker") + + assert result["package_name"] == "docker" + assert result["summary"] == "Docker is a platform." + assert len(result["use_cases"]) == 2 + assert result["confidence"] == 0.9 + + @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") + @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") + def test_structure_response_minimal(self, mock_llm_class, mock_config): + """Test structure_response with minimal response.""" + from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = LessonGeneratorTool() + + response = { + "package_name": "test", + "summary": "Test summary", + } + + result = tool._structure_response(response, "test") + + assert result["package_name"] == "test" + assert result["use_cases"] == [] + assert result["best_practices"] == [] + + +class TestExamplesProviderStructure: + """Tests for ExamplesProviderTool structure methods.""" + + @patch("cortex.tutor.tools.agentic.examples_provider.get_config") + @patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic") + def test_structure_response_full(self, mock_llm_class, mock_config): + """Test structure_response with full response.""" + from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = ExamplesProviderTool() + + response = { + "package_name": "git", + "topic": "branching", + "examples": [{"title": "Create", "code": "git checkout -b", "language": "bash"}], + "tips": ["Use descriptive names"], + "common_mistakes": ["Forgetting to commit"], + "confidence": 0.95, + } + + result = tool._structure_response(response, "git", "branching") + + assert result["package_name"] == "git" + assert result["topic"] == "branching" + assert len(result["examples"]) == 1 + + +class TestQAHandlerStructure: + """Tests for QAHandlerTool structure methods.""" + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_structure_response_full(self, mock_llm_class, mock_config): + """Test structure_response with full response.""" + from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + tool = QAHandlerTool() + + response = { + "question_understood": "What is Docker?", + "answer": "Docker is a container platform.", + "explanation": "It allows packaging applications.", + "code_example": {"code": "docker run", "language": "bash"}, + "related_topics": ["containers", "images"], + "confidence": 0.9, + } + + result = tool._structure_response(response, "docker", "What is Docker?") + + assert result["answer"] == "Docker is a container platform." + assert result["code_example"] is not None + + +class TestConversationHandler: + """Tests for ConversationHandler.""" + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_build_context_empty(self, mock_llm_class, mock_config): + """Test context building with empty history.""" + from cortex.tutor.tools.agentic.qa_handler import ConversationHandler + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + handler = ConversationHandler("docker") + handler.history = [] + + context = handler._build_context() + assert "Starting fresh" in context + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_build_context_with_history(self, mock_llm_class, mock_config): + """Test context building with history.""" + from cortex.tutor.tools.agentic.qa_handler import ConversationHandler + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + handler = ConversationHandler("docker") + handler.history = [ + {"question": "What is Docker?", "answer": "A platform"}, + ] + + context = handler._build_context() + assert "What is Docker?" in context + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") + def test_clear_history(self, mock_llm_class, mock_config): + """Test clearing history.""" + from cortex.tutor.tools.agentic.qa_handler import ConversationHandler + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + mock_llm_class.return_value = Mock() + + handler = ConversationHandler("docker") + handler.history = [{"q": "test"}] + handler.clear_history() + + assert len(handler.history) == 0 diff --git a/tests/tutor/test_branding.py b/tests/tutor/test_branding.py new file mode 100644 index 00000000..e8a81701 --- /dev/null +++ b/tests/tutor/test_branding.py @@ -0,0 +1,249 @@ +""" +Tests for branding/UI utilities. + +Tests Rich console output functions. +""" + +from io import StringIO +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cortex.tutor.branding import ( + console, + get_user_input, + print_banner, + print_best_practice, + print_code_example, + print_error_panel, + print_lesson_header, + print_markdown, + print_menu, + print_progress_summary, + print_success_panel, + print_table, + print_tutorial_step, + tutor_print, +) + + +class TestConsole: + """Tests for console instance.""" + + def test_console_exists(self): + """Test console is initialized.""" + assert console is not None + + def test_console_is_rich(self): + """Test console is Rich Console.""" + from rich.console import Console + + assert isinstance(console, Console) + + +class TestTutorPrint: + """Tests for tutor_print function.""" + + def test_tutor_print_success(self, capsys): + """Test success status print.""" + tutor_print("Test message", "success") + # Rich output, just ensure no errors + + def test_tutor_print_error(self, capsys): + """Test error status print.""" + tutor_print("Error message", "error") + + def test_tutor_print_warning(self, capsys): + """Test warning status print.""" + tutor_print("Warning message", "warning") + + def test_tutor_print_info(self, capsys): + """Test info status print.""" + tutor_print("Info message", "info") + + def test_tutor_print_tutor(self, capsys): + """Test tutor status print.""" + tutor_print("Tutor message", "tutor") + + def test_tutor_print_default(self, capsys): + """Test default status print.""" + tutor_print("Default message") + + +class TestPrintBanner: + """Tests for print_banner function.""" + + def test_print_banner(self, capsys): + """Test banner prints without error.""" + print_banner() + # Just ensure no errors + + +class TestPrintLessonHeader: + """Tests for print_lesson_header function.""" + + def test_print_lesson_header(self, capsys): + """Test lesson header prints.""" + print_lesson_header("docker") + + def test_print_lesson_header_long_name(self, capsys): + """Test lesson header with long package name.""" + print_lesson_header("very-long-package-name-for-testing") + + +class TestPrintCodeExample: + """Tests for print_code_example function.""" + + def test_print_code_example_bash(self, capsys): + """Test code example with bash.""" + print_code_example("docker run nginx", "bash", "Run container") + + def test_print_code_example_python(self, capsys): + """Test code example with python.""" + print_code_example("print('hello')", "python", "Hello world") + + def test_print_code_example_no_title(self, capsys): + """Test code example without title.""" + print_code_example("echo hello", "bash") + + +class TestPrintMenu: + """Tests for print_menu function.""" + + def test_print_menu(self, capsys): + """Test menu prints.""" + options = ["Option 1", "Option 2", "Exit"] + print_menu(options) + + def test_print_menu_empty(self, capsys): + """Test empty menu.""" + print_menu([]) + + def test_print_menu_single(self, capsys): + """Test single option menu.""" + print_menu(["Only option"]) + + +class TestPrintTable: + """Tests for print_table function.""" + + def test_print_table(self, capsys): + """Test table prints.""" + headers = ["Name", "Value"] + rows = [["docker", "100"], ["nginx", "50"]] + print_table(headers, rows, "Test Table") + + def test_print_table_no_title(self, capsys): + """Test table without title.""" + headers = ["Col1", "Col2"] + rows = [["a", "b"]] + print_table(headers, rows) + + def test_print_table_empty_rows(self, capsys): + """Test table with empty rows.""" + headers = ["Header"] + print_table(headers, []) + + +class TestPrintProgressSummary: + """Tests for print_progress_summary function.""" + + def test_print_progress_summary(self, capsys): + """Test progress summary prints.""" + print_progress_summary(3, 5, "docker") + + def test_print_progress_summary_complete(self, capsys): + """Test progress summary when complete.""" + print_progress_summary(5, 5, "docker") + + def test_print_progress_summary_zero(self, capsys): + """Test progress summary with zero progress.""" + print_progress_summary(0, 5, "docker") + + +class TestPrintMarkdown: + """Tests for print_markdown function.""" + + def test_print_markdown(self, capsys): + """Test markdown prints.""" + print_markdown("# Header\n\nSome **bold** text.") + + def test_print_markdown_code(self, capsys): + """Test markdown with code block.""" + print_markdown("```bash\necho hello\n```") + + def test_print_markdown_list(self, capsys): + """Test markdown with list.""" + print_markdown("- Item 1\n- Item 2\n- Item 3") + + +class TestPrintBestPractice: + """Tests for print_best_practice function.""" + + def test_print_best_practice(self, capsys): + """Test best practice prints.""" + print_best_practice("Use official images", 1) + + def test_print_best_practice_long(self, capsys): + """Test best practice with long text.""" + long_text = "This is a very long best practice text " * 5 + print_best_practice(long_text, 10) + + +class TestPrintTutorialStep: + """Tests for print_tutorial_step function.""" + + def test_print_tutorial_step(self, capsys): + """Test tutorial step prints.""" + print_tutorial_step("Install Docker", 1, 5) + + def test_print_tutorial_step_last(self, capsys): + """Test last tutorial step.""" + print_tutorial_step("Finish setup", 5, 5) + + +class TestPrintErrorPanel: + """Tests for print_error_panel function.""" + + def test_print_error_panel(self, capsys): + """Test error panel prints.""" + print_error_panel("Something went wrong") + + def test_print_error_panel_long(self, capsys): + """Test error panel with long message.""" + print_error_panel("Error: " + "x" * 100) + + +class TestPrintSuccessPanel: + """Tests for print_success_panel function.""" + + def test_print_success_panel(self, capsys): + """Test success panel prints.""" + print_success_panel("Operation completed") + + def test_print_success_panel_long(self, capsys): + """Test success panel with long message.""" + print_success_panel("Success: " + "y" * 100) + + +class TestGetUserInput: + """Tests for get_user_input function.""" + + @patch("builtins.input", return_value="test input") + def test_get_user_input(self, mock_input): + """Test getting user input.""" + result = get_user_input("Enter value") + assert result == "test input" + + @patch("builtins.input", return_value="") + def test_get_user_input_empty(self, mock_input): + """Test empty user input.""" + result = get_user_input("Enter value") + assert result == "" + + @patch("builtins.input", return_value=" spaced ") + def test_get_user_input_strips(self, mock_input): + """Test input stripping is not done (raw input).""" + result = get_user_input("Enter value") + # Note: get_user_input should return raw input + assert "spaced" in result diff --git a/tests/tutor/test_cli.py b/tests/tutor/test_cli.py new file mode 100644 index 00000000..005d0c9f --- /dev/null +++ b/tests/tutor/test_cli.py @@ -0,0 +1,452 @@ +""" +Tests for CLI module. + +Comprehensive tests for command-line interface. +""" + +import os +from io import StringIO +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, +) + + +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.""" + from cortex.tutor.config import reset_config + + 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.""" + from cortex.tutor.config import reset_config + + 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.""" + from cortex.tutor.config import reset_config + + 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.""" + from cortex.tutor.config import reset_config + + 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.""" + from cortex.tutor.config import reset_config + + 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.""" + from cortex.tutor.config import reset_config + + 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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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 = "/tmp/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..437be378 --- /dev/null +++ b/tests/tutor/test_integration.py @@ -0,0 +1,364 @@ +""" +Integration tests for Intelligent Tutor. + +End-to-end tests for the complete tutoring workflow. +""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cortex.tutor.branding import console, print_banner, tutor_print +from cortex.tutor.config import Config, get_config, reset_config +from cortex.tutor.contracts.lesson_context import CodeExample, LessonContext, TutorialStep +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 == 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 == 0.85 + + def test_lesson_context_display_dict(self): + """Test to_display_dict method.""" + lesson = LessonContext( + package_name="docker", + summary="Summary", + explanation="Explanation", + use_cases=["Use 1", "Use 2"], + best_practices=["Practice 1"], + installation_command="apt install docker.io", + confidence=0.9, + ) + + display = lesson.to_display_dict() + + assert display["package"] == "docker" + assert display["confidence"] == "90%" + + +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 == 50.0 + assert package.average_score == 0.7 + assert not package.is_complete() + assert package.get_next_topic() == "advanced" + + def test_progress_context_recommendations(self): + """Test getting learning recommendations.""" + progress = ProgressContext( + weak_concepts=["networking", "volumes"], + packages=[ + PackageProgress( + package_name="docker", + topics=[TopicProgress(topic="basics", completed=False)], + ) + ], + ) + + recommendations = progress.get_recommendations() + + assert len(recommendations) >= 1 + assert any("networking" in r.lower() or "docker" in r.lower() for r in recommendations) + + +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") + captured = capsys.readouterr() + # Rich console output is complex, just ensure no errors + + def test_tutor_print_error(self, capsys): + """Test tutor_print with error status.""" + tutor_print("Error message", "error") + captured = capsys.readouterr() + + 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() + + # Test help doesn't raise + 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 + + def test_parse_progress_flag(self): + """Test parsing progress flag.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + args = parser.parse_args(["--progress"]) + + assert args.progress is True + + def test_parse_reset_flag(self): + """Test parsing reset flag.""" + from cortex.tutor.cli import create_parser + + parser = create_parser() + + # Reset all + args = parser.parse_args(["--reset"]) + assert args.reset == "__all__" + + # Reset specific package + args = parser.parse_args(["--reset", "docker"]) + assert args.reset == "docker" + + +class TestEndToEnd: + """End-to-end workflow tests.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + def test_full_lesson_workflow_with_cache( + self, mock_tracker_class, mock_loader_class, mock_generator_class + ): + """Test complete lesson workflow with cache hit.""" + # Set up mocks + mock_tracker = Mock() + mock_tracker._run.return_value = { + "success": True, + "profile": { + "learning_style": "reading", + "mastered_concepts": [], + "weak_concepts": [], + }, + } + mock_tracker_class.return_value = mock_tracker + + cached_lesson = { + "package_name": "docker", + "summary": "Docker is a containerization platform.", + "explanation": "Docker allows...", + "use_cases": ["Development"], + "best_practices": ["Use official images"], + "code_examples": [], + "tutorial_steps": [], + "installation_command": "apt install docker.io", + "confidence": 0.9, + } + + mock_loader = Mock() + mock_loader._run.return_value = { + "cache_hit": True, + "lesson": cached_lesson, + "cost_saved_gbp": 0.02, + } + mock_loader.cache_lesson.return_value = True + mock_loader_class.return_value = mock_loader + + # Run workflow + from cortex.tutor.agents.tutor_agent.graph import ( + load_cache_node, + plan_node, + reflect_node, + ) + from cortex.tutor.agents.tutor_agent.state import create_initial_state + + state = create_initial_state("docker") + + # Execute nodes + state = plan_node(state) + assert state["plan"]["strategy"] == "use_cache" + assert state["cache_hit"] is True + + state = load_cache_node(state) + assert state["results"]["type"] == "lesson" + + state = reflect_node(state) + assert state["output"]["validation_passed"] is True + assert state["output"]["cache_hit"] is True + + # Note: Real API test removed - use manual testing for API integration + # Run: python -m cortex.tutor.cli docker diff --git a/tests/tutor/test_interactive_tutor.py b/tests/tutor/test_interactive_tutor.py new file mode 100644 index 00000000..a3fdfb6d --- /dev/null +++ b/tests/tutor/test_interactive_tutor.py @@ -0,0 +1,263 @@ +""" +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 + assert tutor.current_step == 0 + + +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_progress_tracker.py b/tests/tutor/test_progress_tracker.py new file mode 100644 index 00000000..bb148a19 --- /dev/null +++ b/tests/tutor/test_progress_tracker.py @@ -0,0 +1,350 @@ +""" +Tests for progress tracker and SQLite store. + +Tests learning progress persistence and retrieval. +""" + +import os +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 == 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 == 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 == 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"] == 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"] == 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"] == 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"] == 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..567bc4b9 --- /dev/null +++ b/tests/tutor/test_tools.py @@ -0,0 +1,309 @@ +""" +Tests for deterministic and agentic tools. + +Tests tool functionality with mocked LLM calls. +""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool +from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool +from cortex.tutor.tools.agentic.qa_handler import ConversationHandler, QAHandlerTool +from cortex.tutor.tools.deterministic.lesson_loader import ( + FALLBACK_LESSONS, + 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) + + # Cache a lesson + loader.cache_lesson("docker", {"summary": "cached"}) + + # Force fresh should skip cache + 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.""" + # First cache a lesson + 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"] + + +class TestLessonGeneratorTool: + """Tests for LessonGeneratorTool with mocked LLM.""" + + @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") + @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") + def test_generate_lesson_structure(self, mock_config, mock_llm_class): + """Test lesson generation returns proper structure.""" + # Mock config + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + + # Mock LLM response + mock_response = { + "package_name": "docker", + "summary": "Docker is a containerization platform.", + "explanation": "Docker allows you to...", + "use_cases": ["Development", "Deployment"], + "best_practices": ["Use official images"], + "code_examples": [ + { + "title": "Run container", + "code": "docker run nginx", + "language": "bash", + "description": "Runs nginx", + } + ], + "tutorial_steps": [ + { + "step_number": 1, + "title": "Install", + "content": "First, install Docker", + } + ], + "installation_command": "apt install docker.io", + "related_packages": ["podman"], + "confidence": 0.9, + } + + mock_chain = Mock() + mock_chain.invoke.return_value = mock_response + mock_llm = Mock() + mock_llm.__or__ = Mock(return_value=mock_chain) + mock_llm_class.return_value = mock_llm + + # Create tool and test + tool = LessonGeneratorTool() + tool.llm = mock_llm + + # Directly test structure method + result = tool._structure_response(mock_response, "docker") + + assert result["package_name"] == "docker" + assert "summary" in result + assert "explanation" in result + assert len(result["code_examples"]) == 1 + assert result["confidence"] == 0.9 + + def test_structure_response_handles_missing_fields(self): + """Test structure_response handles missing fields gracefully.""" + # Skip LLM initialization by mocking + with patch("cortex.tutor.tools.agentic.lesson_generator.get_config") as mock_config: + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + with patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic"): + tool = LessonGeneratorTool() + + incomplete_response = { + "package_name": "test", + "summary": "Test summary", + } + + result = tool._structure_response(incomplete_response, "test") + + assert result["package_name"] == "test" + assert result["summary"] == "Test summary" + assert result["use_cases"] == [] + assert result["best_practices"] == [] + + +class TestExamplesProviderTool: + """Tests for ExamplesProviderTool with mocked LLM.""" + + def test_structure_response(self): + """Test structure_response formats examples correctly.""" + with patch("cortex.tutor.tools.agentic.examples_provider.get_config") as mock_config: + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + with patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic"): + tool = ExamplesProviderTool() + + response = { + "package_name": "git", + "topic": "branching", + "examples": [ + { + "title": "Create branch", + "code": "git checkout -b feature", + "language": "bash", + "description": "Creates new branch", + } + ], + "tips": ["Use descriptive names"], + "common_mistakes": ["Forgetting to commit"], + "confidence": 0.95, + } + + result = tool._structure_response(response, "git", "branching") + + assert result["package_name"] == "git" + assert result["topic"] == "branching" + assert len(result["examples"]) == 1 + assert result["examples"][0]["title"] == "Create branch" + + +class TestQAHandlerTool: + """Tests for QAHandlerTool with mocked LLM.""" + + def test_structure_response(self): + """Test structure_response formats answers correctly.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config") as mock_config: + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + with patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic"): + tool = QAHandlerTool() + + response = { + "question_understood": "What is Docker?", + "answer": "Docker is a containerization platform.", + "explanation": "It allows you to package applications.", + "code_example": { + "code": "docker run hello-world", + "language": "bash", + "description": "Runs test container", + }, + "related_topics": ["containers", "images"], + "confidence": 0.9, + } + + result = tool._structure_response(response, "docker", "What is Docker?") + + assert result["answer"] == "Docker is a containerization platform." + assert result["code_example"] is not None + assert len(result["related_topics"]) == 2 + + +class TestConversationHandler: + """Tests for ConversationHandler.""" + + def test_build_context_empty(self): + """Test context building with empty history.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): + handler = ConversationHandler.__new__(ConversationHandler) + handler.history = [] + + context = handler._build_context() + assert "Starting fresh" in context + + def test_build_context_with_history(self): + """Test context building with history.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): + handler = ConversationHandler.__new__(ConversationHandler) + handler.history = [ + {"question": "What is Docker?", "answer": "A platform"}, + {"question": "How to install?", "answer": "Use apt"}, + ] + + context = handler._build_context() + assert "What is Docker?" in context + assert "Recent discussion" in context + + def test_clear_history(self): + """Test clearing conversation history.""" + with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): + handler = ConversationHandler.__new__(ConversationHandler) + handler.history = [{"question": "test", "answer": "test"}] + + handler.clear_history() + assert len(handler.history) == 0 diff --git a/tests/tutor/test_tutor_agent.py b/tests/tutor/test_tutor_agent.py new file mode 100644 index 00000000..eb3c7066 --- /dev/null +++ b/tests/tutor/test_tutor_agent.py @@ -0,0 +1,320 @@ +""" +Tests for TutorAgent and LangGraph workflow. + +Tests the main agent orchestrator and state management. +""" + +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cortex.tutor.agents.tutor_agent.graph import ( + fail_node, + load_cache_node, + plan_node, + reflect_node, + route_after_act, + route_after_plan, +) +from cortex.tutor.agents.tutor_agent.state import ( + TutorAgentState, + add_checkpoint, + add_cost, + add_error, + create_initial_state, + get_package_name, + get_session_type, + has_critical_error, +) + + +class TestTutorAgentState: + """Tests for TutorAgentState and state utilities.""" + + def test_create_initial_state(self): + """Test creating initial state.""" + state = create_initial_state( + package_name="docker", + session_type="lesson", + ) + + assert state["input"]["package_name"] == "docker" + assert state["input"]["session_type"] == "lesson" + assert state["force_fresh"] is False + assert state["errors"] == [] + assert state["cost_gbp"] == 0.0 + + def test_create_initial_state_qa_mode(self): + """Test creating initial state for Q&A.""" + state = create_initial_state( + package_name="docker", + session_type="qa", + question="What is Docker?", + ) + + assert state["input"]["session_type"] == "qa" + assert state["input"]["question"] == "What is Docker?" + + def test_add_error(self): + """Test adding errors to state.""" + state = create_initial_state("docker") + add_error(state, "test_node", "Test error", recoverable=True) + + assert len(state["errors"]) == 1 + assert state["errors"][0]["node"] == "test_node" + assert state["errors"][0]["error"] == "Test error" + assert state["errors"][0]["recoverable"] is True + + def test_add_checkpoint(self): + """Test adding checkpoints to state.""" + state = create_initial_state("docker") + add_checkpoint(state, "plan_start", "ok", "Planning started") + + assert len(state["checkpoints"]) == 1 + assert state["checkpoints"][0]["name"] == "plan_start" + assert state["checkpoints"][0]["status"] == "ok" + + def test_add_cost(self): + """Test adding cost to state.""" + state = create_initial_state("docker") + add_cost(state, 0.02) + add_cost(state, 0.01) + + assert state["cost_gbp"] == 0.03 + + def test_has_critical_error_false(self): + """Test has_critical_error returns False when no critical errors.""" + state = create_initial_state("docker") + add_error(state, "test", "Recoverable error", recoverable=True) + + assert has_critical_error(state) is False + + def test_has_critical_error_true(self): + """Test has_critical_error returns True when critical error exists.""" + state = create_initial_state("docker") + add_error(state, "test", "Critical error", recoverable=False) + + assert has_critical_error(state) is True + + def test_get_session_type(self): + """Test get_session_type utility.""" + state = create_initial_state("docker", session_type="qa") + assert get_session_type(state) == "qa" + + def test_get_package_name(self): + """Test get_package_name utility.""" + state = create_initial_state("nginx") + assert get_package_name(state) == "nginx" + + +class TestGraphNodes: + """Tests for LangGraph node functions.""" + + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + def test_plan_node_cache_hit(self, mock_loader_class, mock_tracker_class): + """Test plan_node with cache hit.""" + # Mock tracker + mock_tracker = Mock() + mock_tracker._run.return_value = { + "success": True, + "profile": { + "learning_style": "reading", + "mastered_concepts": [], + "weak_concepts": [], + }, + } + mock_tracker_class.return_value = mock_tracker + + # Mock loader with cache hit + mock_loader = Mock() + mock_loader._run.return_value = { + "cache_hit": True, + "lesson": {"summary": "Cached lesson"}, + } + mock_loader_class.return_value = mock_loader + + state = create_initial_state("docker") + result = plan_node(state) + + assert result["plan"]["strategy"] == "use_cache" + assert result["cache_hit"] is True + + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") + def test_plan_node_cache_miss(self, mock_loader_class, mock_tracker_class): + """Test plan_node with cache miss.""" + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "profile": {}} + mock_tracker_class.return_value = mock_tracker + + mock_loader = Mock() + mock_loader._run.return_value = {"cache_hit": False, "lesson": None} + mock_loader_class.return_value = mock_loader + + state = create_initial_state("docker") + result = plan_node(state) + + assert result["plan"]["strategy"] == "generate_full" + + @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") + def test_plan_node_qa_mode(self, mock_tracker_class): + """Test plan_node in Q&A mode.""" + mock_tracker = Mock() + mock_tracker._run.return_value = {"success": True, "profile": {}} + mock_tracker_class.return_value = mock_tracker + + state = create_initial_state("docker", session_type="qa", question="What?") + result = plan_node(state) + + assert result["plan"]["strategy"] == "qa_mode" + + def test_load_cache_node(self): + """Test load_cache_node with cached data.""" + state = create_initial_state("docker") + state["plan"] = { + "strategy": "use_cache", + "cached_data": {"summary": "Cached lesson", "explanation": "..."}, + } + + result = load_cache_node(state) + + assert result["lesson_content"]["summary"] == "Cached lesson" + assert result["results"]["source"] == "cache" + + def test_load_cache_node_missing_data(self): + """Test load_cache_node handles missing cache data.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "use_cache", "cached_data": None} + + result = load_cache_node(state) + + assert len(result["errors"]) > 0 + + def test_reflect_node_success(self): + """Test reflect_node with successful results.""" + state = create_initial_state("docker") + state["results"] = { + "type": "lesson", + "content": {"summary": "Test"}, + "source": "generated", + } + state["errors"] = [] + state["cost_gbp"] = 0.02 + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is True + assert result["output"]["cost_gbp"] == 0.02 + + def test_reflect_node_failure(self): + """Test reflect_node with missing results.""" + state = create_initial_state("docker") + state["results"] = {} + + result = reflect_node(state) + + assert result["output"]["validation_passed"] is False + assert "No content" in str(result["output"]["validation_errors"]) + + def test_fail_node(self): + """Test fail_node creates proper error output.""" + state = create_initial_state("docker") + add_error(state, "test", "Test error") + state["cost_gbp"] = 0.01 + + result = fail_node(state) + + assert result["output"]["type"] == "error" + assert result["output"]["validation_passed"] is False + assert "Test error" in result["output"]["validation_errors"] + + +class TestRouting: + """Tests for routing functions.""" + + def test_route_after_plan_use_cache(self): + """Test routing to cache path.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "use_cache"} + + route = route_after_plan(state) + assert route == "load_cache" + + def test_route_after_plan_generate(self): + """Test routing to generation path.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "generate_full"} + + route = route_after_plan(state) + assert route == "generate_lesson" + + def test_route_after_plan_qa(self): + """Test routing to Q&A path.""" + state = create_initial_state("docker") + state["plan"] = {"strategy": "qa_mode"} + + route = route_after_plan(state) + assert route == "qa" + + def test_route_after_plan_critical_error(self): + """Test routing to fail on critical error.""" + state = create_initial_state("docker") + add_error(state, "test", "Critical", recoverable=False) + + route = route_after_plan(state) + assert route == "fail" + + def test_route_after_act_success(self): + """Test routing after successful act phase.""" + state = create_initial_state("docker") + state["results"] = {"type": "lesson", "content": {}} + + route = route_after_act(state) + assert route == "reflect" + + def test_route_after_act_no_results(self): + """Test routing to fail when no results.""" + state = create_initial_state("docker") + state["results"] = {} + + route = route_after_act(state) + assert route == "fail" + + +class TestTutorAgentIntegration: + """Integration tests for TutorAgent.""" + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + def test_teach_validation(self, mock_graph): + """Test teach validates package name.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + from cortex.tutor.config import reset_config + + reset_config() + + with pytest.raises(ValueError) as exc_info: + agent = TutorAgent() + agent.teach("") + + assert "Invalid package name" in str(exc_info.value) + + @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) + @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") + def test_ask_validation(self, mock_graph): + """Test ask validates inputs.""" + from cortex.tutor.agents.tutor_agent import TutorAgent + from cortex.tutor.config import reset_config + + reset_config() + + agent = TutorAgent() + + with pytest.raises(ValueError): + agent.ask("", "question") + + with pytest.raises(ValueError): + agent.ask("docker", "") diff --git a/tests/tutor/test_validators.py b/tests/tutor/test_validators.py new file mode 100644 index 00000000..176cabf5 --- /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, error = 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, error = 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, error = validate_input("") + assert not is_valid + + def test_empty_input_allowed(self): + """Test empty input passes when allowed.""" + is_valid, error = 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, error = 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, error = validate_question("What is the difference between Docker and VMs?") + assert is_valid + + def test_empty_question(self): + """Test empty question fails.""" + is_valid, error = 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, error = validate_topic(topic) + assert is_valid, f"Expected {topic} to be valid" + + def test_empty_topic(self): + """Test empty topic fails.""" + is_valid, error = 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, error = 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, error = 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, error = validate_learning_style(style) + assert is_valid + + def test_invalid_style(self): + """Test invalid styles fail.""" + is_valid, error = 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 From 049185d35f805d98b12d150c18c6e3a71bb885c7 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 13:09:17 +0530 Subject: [PATCH 05/32] fix(tutor): address CodeRabbit review comments - Replace deprecated datetime.utcnow() with datetime.now(timezone.utc) in lesson_context.py and progress_context.py - Fix upsert_progress to return actual row ID when lastrowid is 0 (happens on ON CONFLICT UPDATE) - Fix TOCTOU race condition in add_mastered_concept and add_weak_concept by using atomic read-modify-write within single connection All 266 tests pass. --- cortex/tutor/contracts/lesson_context.py | 5 +- cortex/tutor/contracts/progress_context.py | 10 ++- cortex/tutor/memory/sqlite_store.py | 74 ++++++++++++++++++---- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/cortex/tutor/contracts/lesson_context.py b/cortex/tutor/contracts/lesson_context.py index 07aea75d..ba541bb6 100644 --- a/cortex/tutor/contracts/lesson_context.py +++ b/cortex/tutor/contracts/lesson_context.py @@ -4,7 +4,7 @@ Defines the structured output schema for lesson content. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Literal from pydantic import BaseModel, Field @@ -95,7 +95,8 @@ class LessonContext(BaseModel): 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=datetime.utcnow, description="Timestamp of generation (UTC)" + default_factory=lambda: datetime.now(timezone.utc), + description="Timestamp of generation (UTC)", ) def to_json(self) -> str: diff --git a/cortex/tutor/contracts/progress_context.py b/cortex/tutor/contracts/progress_context.py index 79f9a4c1..591df930 100644 --- a/cortex/tutor/contracts/progress_context.py +++ b/cortex/tutor/contracts/progress_context.py @@ -4,7 +4,7 @@ Defines the structured output schema for progress tracking. """ -from datetime import datetime +from datetime import datetime, timezone from typing import Any from pydantic import BaseModel, Field, computed_field @@ -96,7 +96,8 @@ class ProgressContext(BaseModel): # Metadata last_updated: datetime = Field( - default_factory=datetime.utcnow, description="Last update timestamp" + default_factory=lambda: datetime.now(timezone.utc), + description="Last update timestamp", ) def get_package_progress(self, package_name: str) -> PackageProgress | None: @@ -154,7 +155,10 @@ class QuizContext(BaseModel): 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=datetime.utcnow, description="Quiz completion time") + timestamp: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="Quiz completion time", + ) @classmethod def from_results( diff --git a/cortex/tutor/memory/sqlite_store.py b/cortex/tutor/memory/sqlite_store.py index cb632837..d0ba619c 100644 --- a/cortex/tutor/memory/sqlite_store.py +++ b/cortex/tutor/memory/sqlite_store.py @@ -236,6 +236,14 @@ def upsert_progress(self, progress: LearningProgress) -> int: ), ) 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: @@ -418,30 +426,70 @@ def update_student_profile(self, profile: StudentProfile) -> None: def add_mastered_concept(self, concept: str) -> None: """ - Add a mastered concept to the student profile. + Add a mastered concept to the student profile (atomic operation). Args: concept: Concept that was mastered. """ - profile = self.get_student_profile() - if concept not in profile.mastered_concepts: - profile.mastered_concepts.append(concept) - # Remove from weak concepts if present - if concept in profile.weak_concepts: - profile.weak_concepts.remove(concept) - self.update_student_profile(profile) + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + # Atomic read-modify-write within single connection + cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + row = cursor.fetchone() + if not row: + self._create_default_profile() + cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + 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. + Add a weak concept to the student profile (atomic operation). Args: concept: Concept the student struggles with. """ - profile = self.get_student_profile() - if concept not in profile.weak_concepts and concept not in profile.mastered_concepts: - profile.weak_concepts.append(concept) - self.update_student_profile(profile) + now = datetime.now(timezone.utc).isoformat() + with self._get_connection() as conn: + # Atomic read-modify-write within single connection + cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + row = cursor.fetchone() + if not row: + self._create_default_profile() + cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + 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 ==================== From 4e86d2882d1cd5aa38852ca8990c1e59b4a84a40 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 13:50:02 +0530 Subject: [PATCH 06/32] fix(security): use tempfile.gettempdir() instead of hardcoded /tmp path Fixes SonarQube security hotspot S5443 (publicly writable directories). Changed all instances of hardcoded "/tmp/test.db" mock paths to use `Path(tempfile.gettempdir()) / "test.db"` for secure temporary file handling. Co-Authored-By: Claude Opus 4.5 --- cortex/tutor/tests/test_cli.py | 18 ++++++++++-------- tests/tutor/test_cli.py | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/cortex/tutor/tests/test_cli.py b/cortex/tutor/tests/test_cli.py index 005d0c9f..aa86dd4b 100644 --- a/cortex/tutor/tests/test_cli.py +++ b/cortex/tutor/tests/test_cli.py @@ -5,7 +5,9 @@ """ import os +import tempfile from io import StringIO +from pathlib import Path from unittest.mock import MagicMock, Mock, patch import pytest @@ -232,7 +234,7 @@ class TestCmdListPackages: 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -248,7 +250,7 @@ def test_no_packages(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -277,7 +279,7 @@ class TestCmdProgress: 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -299,7 +301,7 @@ def test_progress_for_package(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -315,7 +317,7 @@ def test_progress_no_package_found(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + 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 @@ -335,7 +337,7 @@ def test_progress_all(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -367,7 +369,7 @@ def test_reset_confirmed(self, mock_input, mock_config_class, mock_store_class): mock_input.return_value = "y" mock_config = Mock() - mock_config.get_db_path.return_value = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -386,7 +388,7 @@ def test_reset_specific_package(self, mock_input, mock_config_class, mock_store_ mock_input.return_value = "y" mock_config = Mock() - mock_config.get_db_path.return_value = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() diff --git a/tests/tutor/test_cli.py b/tests/tutor/test_cli.py index 005d0c9f..aa86dd4b 100644 --- a/tests/tutor/test_cli.py +++ b/tests/tutor/test_cli.py @@ -5,7 +5,9 @@ """ import os +import tempfile from io import StringIO +from pathlib import Path from unittest.mock import MagicMock, Mock, patch import pytest @@ -232,7 +234,7 @@ class TestCmdListPackages: 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -248,7 +250,7 @@ def test_no_packages(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -277,7 +279,7 @@ class TestCmdProgress: 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -299,7 +301,7 @@ def test_progress_for_package(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -315,7 +317,7 @@ def test_progress_no_package_found(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + 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 @@ -335,7 +337,7 @@ def test_progress_all(self, mock_config_class, mock_store_class): 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 = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -367,7 +369,7 @@ def test_reset_confirmed(self, mock_input, mock_config_class, mock_store_class): mock_input.return_value = "y" mock_config = Mock() - mock_config.get_db_path.return_value = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() @@ -386,7 +388,7 @@ def test_reset_specific_package(self, mock_input, mock_config_class, mock_store_ mock_input.return_value = "y" mock_config = Mock() - mock_config.get_db_path.return_value = "/tmp/test.db" + mock_config.get_db_path.return_value = Path(tempfile.gettempdir()) / "test.db" mock_config_class.from_env.return_value = mock_config mock_store = Mock() From bce506ad99b90876a55a50eb5761d8c891d263bd Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 14:32:23 +0530 Subject: [PATCH 07/32] fix: address SonarQube code quality issues Fixes for 86 SonarQube issues: Critical fixes: - Extract SQL constant for duplicated "SELECT * FROM student_profile" query - Extract constants for duplicated "none specified" and error messages - Reduce cognitive complexity in cmd_progress() by extracting helper functions - Fix type hints for sanitize_input() and extract_package_name() to accept None Major fixes: - Use pytest.approx() for all floating point equality checks - Prefix unused function parameters with underscore (_verbose, _description) - Prefix unused local variables with underscore (_result, _captured, _store) - Replace unused error variables with underscore in test assertions Files changed: - cortex/tutor/memory/sqlite_store.py: Add _SELECT_PROFILE constant - cortex/tutor/tools/agentic/qa_handler.py: Add _NONE_SPECIFIED constant - cortex/tutor/tools/deterministic/progress_tracker.py: Add _ERR_PKG_TOPIC_REQUIRED - cortex/tutor/tools/deterministic/validators.py: Fix type hints - cortex/tutor/cli.py: Reduce cognitive complexity, fix unused parameters - cortex/tutor/branding.py: Fix unused parameter - All test files: Fix floating point comparisons and unused variables --- cortex/tutor/branding.py | 4 +- cortex/tutor/cli.py | 107 +++++++++--------- cortex/tutor/memory/sqlite_store.py | 15 ++- cortex/tutor/tests/test_agentic_tools.py | 2 +- cortex/tutor/tests/test_cli.py | 10 +- cortex/tutor/tests/test_integration.py | 8 +- cortex/tutor/tests/test_progress_tracker.py | 16 +-- cortex/tutor/tests/test_tools.py | 2 +- cortex/tutor/tests/test_tutor_agent.py | 6 +- cortex/tutor/tests/test_validators.py | 26 ++--- cortex/tutor/tools/agentic/qa_handler.py | 11 +- .../tools/deterministic/progress_tracker.py | 9 +- .../tutor/tools/deterministic/validators.py | 8 +- tests/tutor/test_agentic_tools.py | 2 +- tests/tutor/test_cli.py | 10 +- tests/tutor/test_integration.py | 8 +- tests/tutor/test_progress_tracker.py | 16 +-- tests/tutor/test_tools.py | 2 +- tests/tutor/test_tutor_agent.py | 6 +- tests/tutor/test_validators.py | 26 ++--- 20 files changed, 153 insertions(+), 141 deletions(-) diff --git a/cortex/tutor/branding.py b/cortex/tutor/branding.py index e7bde21b..e2dc2f68 100644 --- a/cortex/tutor/branding.py +++ b/cortex/tutor/branding.py @@ -215,12 +215,12 @@ def get_user_input(prompt: str, default: str | None = None) -> str: return "" -def create_progress_bar(description: str = "Processing") -> Progress: +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. + _description: Description text for the progress bar (reserved for future use). Returns: Progress: Configured Rich Progress instance. diff --git a/cortex/tutor/cli.py b/cortex/tutor/cli.py index 641e00d1..1c1608a1 100644 --- a/cortex/tutor/cli.py +++ b/cortex/tutor/cli.py @@ -210,10 +210,13 @@ def cmd_question(package: str, question: str, verbose: bool = False) -> int: return 1 -def cmd_list_packages(verbose: bool = False) -> int: +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. """ @@ -240,71 +243,70 @@ def cmd_list_packages(verbose: bool = False) -> int: return 1 -def cmd_progress(package: str | None = None, verbose: bool = False) -> int: +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: - # Use SQLiteStore directly - no API key needed config = Config.from_env(require_api_key=False) store = SQLiteStore(config.get_db_path()) if package: - # Show progress for 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") + _show_package_progress(store, package) else: - # Show all progress - progress_list = store.get_all_progress() - - if not progress_list: - tutor_print("No learning progress yet.", "info") - return 0 - - # Group by package (progress_list contains Pydantic models) - by_package = {} - for p in progress_list: - pkg = p.package_name - if pkg not in by_package: - by_package[pkg] = [] - by_package[pkg].append(p) - - # Display table - 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", - ) + _show_all_progress(store) return 0 @@ -313,12 +315,13 @@ def cmd_progress(package: str | None = None, verbose: bool = False) -> int: return 1 -def cmd_reset(package: str | None = None, verbose: bool = False) -> int: +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. diff --git a/cortex/tutor/memory/sqlite_store.py b/cortex/tutor/memory/sqlite_store.py index d0ba619c..9e7bfa0e 100644 --- a/cortex/tutor/memory/sqlite_store.py +++ b/cortex/tutor/memory/sqlite_store.py @@ -102,6 +102,9 @@ class SQLiteStore: 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. @@ -354,7 +357,7 @@ def get_student_profile(self) -> StudentProfile: StudentProfile record. """ with self._get_connection() as conn: - cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + cursor = conn.execute(self._SELECT_PROFILE) row = cursor.fetchone() if row: return StudentProfile( @@ -385,7 +388,7 @@ def _create_default_profile(self) -> StudentProfile: ) conn.commit() # Re-fetch to return actual profile (in case another thread created it) - cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + cursor = conn.execute(self._SELECT_PROFILE) row = cursor.fetchone() if row: return StudentProfile( @@ -434,11 +437,11 @@ def add_mastered_concept(self, concept: str) -> None: now = datetime.now(timezone.utc).isoformat() with self._get_connection() as conn: # Atomic read-modify-write within single connection - cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + cursor = conn.execute(self._SELECT_PROFILE) row = cursor.fetchone() if not row: self._create_default_profile() - cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + cursor = conn.execute(self._SELECT_PROFILE) row = cursor.fetchone() mastered = json.loads(row["mastered_concepts"]) @@ -470,11 +473,11 @@ def add_weak_concept(self, concept: str) -> None: now = datetime.now(timezone.utc).isoformat() with self._get_connection() as conn: # Atomic read-modify-write within single connection - cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + cursor = conn.execute(self._SELECT_PROFILE) row = cursor.fetchone() if not row: self._create_default_profile() - cursor = conn.execute("SELECT * FROM student_profile LIMIT 1") + cursor = conn.execute(self._SELECT_PROFILE) row = cursor.fetchone() mastered = json.loads(row["mastered_concepts"]) diff --git a/cortex/tutor/tests/test_agentic_tools.py b/cortex/tutor/tests/test_agentic_tools.py index 9c500191..c8789109 100644 --- a/cortex/tutor/tests/test_agentic_tools.py +++ b/cortex/tutor/tests/test_agentic_tools.py @@ -44,7 +44,7 @@ def test_structure_response_full(self, mock_llm_class, mock_config): assert result["package_name"] == "docker" assert result["summary"] == "Docker is a platform." assert len(result["use_cases"]) == 2 - assert result["confidence"] == 0.9 + assert result["confidence"] == pytest.approx(0.9) @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") diff --git a/cortex/tutor/tests/test_cli.py b/cortex/tutor/tests/test_cli.py index aa86dd4b..d5f00180 100644 --- a/cortex/tutor/tests/test_cli.py +++ b/cortex/tutor/tests/test_cli.py @@ -420,28 +420,28 @@ def test_no_args_shows_help(self): def test_list_command(self, mock_list): """Test list command.""" mock_list.return_value = 0 - result = main(["--list"]) + _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"]) + _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"]) + _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?"]) + _result = main(["docker", "-q", "What is Docker?"]) mock_question.assert_called_once() @patch("cortex.tutor.cli.cmd_teach") @@ -449,6 +449,6 @@ def test_question_command(self, mock_question): def test_teach_command(self, mock_banner, mock_teach): """Test teach command.""" mock_teach.return_value = 0 - result = main(["docker"]) + _result = main(["docker"]) mock_teach.assert_called_once() mock_banner.assert_called_once() diff --git a/cortex/tutor/tests/test_integration.py b/cortex/tutor/tests/test_integration.py index 437be378..79e37357 100644 --- a/cortex/tutor/tests/test_integration.py +++ b/cortex/tutor/tests/test_integration.py @@ -96,7 +96,7 @@ def test_lesson_context_creation(self): ) assert lesson.package_name == "docker" - assert lesson.confidence == 0.9 + assert lesson.confidence == pytest.approx(0.9) assert len(lesson.use_cases) == 2 def test_lesson_context_with_examples(self): @@ -134,7 +134,7 @@ def test_lesson_context_serialization(self): restored = LessonContext.from_json(json_str) assert restored.package_name == "docker" - assert restored.confidence == 0.85 + assert restored.confidence == pytest.approx(0.85) def test_lesson_context_display_dict(self): """Test to_display_dict method.""" @@ -208,13 +208,13 @@ class TestBranding: def test_tutor_print_success(self, capsys): """Test tutor_print with success status.""" tutor_print("Test message", "success") - captured = capsys.readouterr() + _captured = capsys.readouterr() # Rich console output is complex, just ensure no errors def test_tutor_print_error(self, capsys): """Test tutor_print with error status.""" tutor_print("Error message", "error") - captured = capsys.readouterr() + _captured = capsys.readouterr() def test_console_exists(self): """Test console is properly initialized.""" diff --git a/cortex/tutor/tests/test_progress_tracker.py b/cortex/tutor/tests/test_progress_tracker.py index bb148a19..e9ed0202 100644 --- a/cortex/tutor/tests/test_progress_tracker.py +++ b/cortex/tutor/tests/test_progress_tracker.py @@ -49,7 +49,7 @@ class TestSQLiteStore: def test_init_creates_database(self, temp_db): """Test database is created on init.""" - store = SQLiteStore(temp_db) + _store = SQLiteStore(temp_db) assert temp_db.exists() def test_upsert_and_get_progress(self, store): @@ -66,7 +66,7 @@ def test_upsert_and_get_progress(self, store): assert result is not None assert result.package_name == "docker" assert result.completed is True - assert result.score == 0.9 + assert result.score == pytest.approx(0.9) def test_upsert_updates_existing(self, store): """Test upsert updates existing record.""" @@ -90,7 +90,7 @@ def test_upsert_updates_existing(self, store): result = store.get_progress("docker", "basics") assert result.completed is True - assert result.score == 0.9 + assert result.score == pytest.approx(0.9) def test_get_all_progress(self, store): """Test getting all progress records.""" @@ -114,7 +114,7 @@ def test_mark_topic_completed(self, store): result = store.get_progress("docker", "tutorial") assert result.completed is True - assert result.score == 0.85 + assert result.score == pytest.approx(0.85) def test_get_completion_stats(self, store): """Test getting completion statistics.""" @@ -128,7 +128,7 @@ def test_get_completion_stats(self, store): stats = store.get_completion_stats("docker") assert stats["total"] == 2 assert stats["completed"] == 1 - assert stats["avg_score"] == 0.7 + assert stats["avg_score"] == pytest.approx(0.7) def test_quiz_results(self, store): """Test adding and retrieving quiz results.""" @@ -238,7 +238,7 @@ def test_mark_completed_action(self, tracker): score=0.85, ) assert result["success"] - assert result["score"] == 0.85 + assert result["score"] == pytest.approx(0.85) def test_get_stats_action(self, tracker): """Test get_stats action.""" @@ -309,7 +309,7 @@ def test_get_learning_progress(self, temp_db): progress = get_learning_progress("docker", "basics") assert progress is not None assert progress["completed"] is True - assert progress["score"] == 0.85 + assert progress["score"] == pytest.approx(0.85) def test_mark_topic_completed(self, temp_db): """Test mark_topic_completed function.""" @@ -347,4 +347,4 @@ def test_get_package_stats(self, temp_db): stats = get_package_stats("nginx") assert stats["total"] == 2 assert stats["completed"] == 2 - assert stats["avg_score"] == 0.8 # (0.9 + 0.7) / 2 + assert stats["avg_score"] == pytest.approx(0.8) # (0.9 + 0.7) / 2 diff --git a/cortex/tutor/tests/test_tools.py b/cortex/tutor/tests/test_tools.py index 567bc4b9..a03bf73e 100644 --- a/cortex/tutor/tests/test_tools.py +++ b/cortex/tutor/tests/test_tools.py @@ -178,7 +178,7 @@ def test_generate_lesson_structure(self, mock_config, mock_llm_class): assert "summary" in result assert "explanation" in result assert len(result["code_examples"]) == 1 - assert result["confidence"] == 0.9 + assert result["confidence"] == pytest.approx(0.9) def test_structure_response_handles_missing_fields(self): """Test structure_response handles missing fields gracefully.""" diff --git a/cortex/tutor/tests/test_tutor_agent.py b/cortex/tutor/tests/test_tutor_agent.py index eb3c7066..ec60e77a 100644 --- a/cortex/tutor/tests/test_tutor_agent.py +++ b/cortex/tutor/tests/test_tutor_agent.py @@ -44,7 +44,7 @@ def test_create_initial_state(self): assert state["input"]["session_type"] == "lesson" assert state["force_fresh"] is False assert state["errors"] == [] - assert state["cost_gbp"] == 0.0 + assert state["cost_gbp"] == pytest.approx(0.0) def test_create_initial_state_qa_mode(self): """Test creating initial state for Q&A.""" @@ -82,7 +82,7 @@ def test_add_cost(self): add_cost(state, 0.02) add_cost(state, 0.01) - assert state["cost_gbp"] == 0.03 + assert state["cost_gbp"] == pytest.approx(0.03) def test_has_critical_error_false(self): """Test has_critical_error returns False when no critical errors.""" @@ -207,7 +207,7 @@ def test_reflect_node_success(self): result = reflect_node(state) assert result["output"]["validation_passed"] is True - assert result["output"]["cost_gbp"] == 0.02 + assert result["output"]["cost_gbp"] == pytest.approx(0.02) def test_reflect_node_failure(self): """Test reflect_node with missing results.""" diff --git a/cortex/tutor/tests/test_validators.py b/cortex/tutor/tests/test_validators.py index 176cabf5..65404035 100644 --- a/cortex/tutor/tests/test_validators.py +++ b/cortex/tutor/tests/test_validators.py @@ -52,7 +52,7 @@ def test_empty_package_name(self): def test_whitespace_only(self): """Test whitespace-only input fails.""" - is_valid, error = validate_package_name(" ") + is_valid, _ = validate_package_name(" ") assert not is_valid def test_too_long_package_name(self): @@ -85,7 +85,7 @@ def test_invalid_characters(self): "has#hash", ] for name in invalid_names: - is_valid, error = validate_package_name(name) + is_valid, _ = validate_package_name(name) assert not is_valid, f"Expected {name} to be invalid" @@ -100,12 +100,12 @@ def test_valid_input(self): def test_empty_input_not_allowed(self): """Test empty input fails by default.""" - is_valid, error = validate_input("") + is_valid, _ = validate_input("") assert not is_valid def test_empty_input_allowed(self): """Test empty input passes when allowed.""" - is_valid, error = validate_input("", allow_empty=True) + is_valid, _ = validate_input("", allow_empty=True) assert is_valid def test_max_length(self): @@ -117,7 +117,7 @@ def test_max_length(self): def test_custom_max_length(self): """Test custom max length works.""" - is_valid, error = validate_input("hello", max_length=3) + is_valid, _ = validate_input("hello", max_length=3) assert not is_valid def test_blocked_patterns_in_input(self): @@ -132,12 +132,12 @@ class TestValidateQuestion: def test_valid_question(self): """Test valid questions pass.""" - is_valid, error = validate_question("What is the difference between Docker and VMs?") + 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, error = validate_question("") + is_valid, _ = validate_question("") assert not is_valid @@ -153,12 +153,12 @@ def test_valid_topic(self): "best-practices", ] for topic in valid_topics: - is_valid, error = validate_topic(topic) + 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, error = validate_topic("") + is_valid, _ = validate_topic("") assert not is_valid @@ -169,14 +169,14 @@ 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, error = validate_score(score) + 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, error = validate_score(score) + is_valid, _ = validate_score(score) assert not is_valid @@ -187,12 +187,12 @@ def test_valid_styles(self): """Test valid learning styles pass.""" valid_styles = ["visual", "reading", "hands-on"] for style in valid_styles: - is_valid, error = validate_learning_style(style) + is_valid, _ = validate_learning_style(style) assert is_valid def test_invalid_style(self): """Test invalid styles fail.""" - is_valid, error = validate_learning_style("invalid") + is_valid, _ = validate_learning_style("invalid") assert not is_valid diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index efc54449..d91100b7 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -36,6 +36,9 @@ class QAHandlerTool(BaseTool): llm: ChatAnthropic | None = Field(default=None, exclude=True) model_name: str = Field(default="claude-sonnet-4-20250514") + # Constants for default values + _NONE_SPECIFIED: str = "none specified" + class Config: arbitrary_types_allowed = True @@ -94,8 +97,8 @@ def _run( "package_name": package_name, "question": question, "learning_style": learning_style, - "mastered_concepts": ", ".join(mastered_concepts or []) or "none specified", - "weak_concepts": ", ".join(weak_concepts or []) or "none specified", + "mastered_concepts": ", ".join(mastered_concepts or []) or self._NONE_SPECIFIED, + "weak_concepts": ", ".join(weak_concepts or []) or self._NONE_SPECIFIED, "lesson_context": lesson_context or "starting fresh", } ) @@ -140,8 +143,8 @@ async def _arun( "package_name": package_name, "question": question, "learning_style": learning_style, - "mastered_concepts": ", ".join(mastered_concepts or []) or "none specified", - "weak_concepts": ", ".join(weak_concepts or []) or "none specified", + "mastered_concepts": ", ".join(mastered_concepts or []) or self._NONE_SPECIFIED, + "weak_concepts": ", ".join(weak_concepts or []) or self._NONE_SPECIFIED, "lesson_context": lesson_context or "starting fresh", } ) diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py index 61550713..b7f932e8 100644 --- a/cortex/tutor/tools/deterministic/progress_tracker.py +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -42,6 +42,9 @@ class ProgressTrackerTool(BaseTool): store: SQLiteStore | None = Field(default=None, exclude=True) + # Error message constants + _ERR_PKG_TOPIC_REQUIRED: str = "package_name and topic required" + class Config: arbitrary_types_allowed = True @@ -124,7 +127,7 @@ def _get_progress( ) -> dict[str, Any]: """Get progress for a specific package/topic.""" if not package_name or not topic: - return {"success": False, "error": "package_name and topic required"} + return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} progress = self.store.get_progress(package_name, topic) if progress: @@ -172,7 +175,7 @@ def _mark_completed( ) -> dict[str, Any]: """Mark a topic as completed.""" if not package_name or not topic: - return {"success": False, "error": "package_name and topic required"} + return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} self.store.mark_topic_completed(package_name, topic, score or 1.0) return { @@ -192,7 +195,7 @@ def _update_progress( ) -> dict[str, Any]: """Update progress for a topic.""" if not package_name or not topic: - return {"success": False, "error": "package_name and topic required"} + return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} # Get existing progress to preserve values existing = self.store.get_progress(package_name, topic) diff --git a/cortex/tutor/tools/deterministic/validators.py b/cortex/tutor/tools/deterministic/validators.py index b6191ad7..75975bd1 100644 --- a/cortex/tutor/tools/deterministic/validators.py +++ b/cortex/tutor/tools/deterministic/validators.py @@ -210,12 +210,12 @@ def validate_learning_style(style: str) -> tuple[bool, str | None]: return True, None -def sanitize_input(input_text: str) -> str: +def sanitize_input(input_text: str | None) -> str: """ Sanitize user input by removing potentially dangerous content. Args: - input_text: The input to sanitize. + input_text: The input to sanitize (can be None). Returns: Sanitized input string. @@ -236,12 +236,12 @@ def sanitize_input(input_text: str) -> str: return sanitized -def extract_package_name(input_text: str) -> str | None: +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. + input_text: User input that may contain a package name (can be None). Returns: Extracted package name or None. diff --git a/tests/tutor/test_agentic_tools.py b/tests/tutor/test_agentic_tools.py index 9c500191..c8789109 100644 --- a/tests/tutor/test_agentic_tools.py +++ b/tests/tutor/test_agentic_tools.py @@ -44,7 +44,7 @@ def test_structure_response_full(self, mock_llm_class, mock_config): assert result["package_name"] == "docker" assert result["summary"] == "Docker is a platform." assert len(result["use_cases"]) == 2 - assert result["confidence"] == 0.9 + assert result["confidence"] == pytest.approx(0.9) @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") diff --git a/tests/tutor/test_cli.py b/tests/tutor/test_cli.py index aa86dd4b..d5f00180 100644 --- a/tests/tutor/test_cli.py +++ b/tests/tutor/test_cli.py @@ -420,28 +420,28 @@ def test_no_args_shows_help(self): def test_list_command(self, mock_list): """Test list command.""" mock_list.return_value = 0 - result = main(["--list"]) + _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"]) + _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"]) + _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?"]) + _result = main(["docker", "-q", "What is Docker?"]) mock_question.assert_called_once() @patch("cortex.tutor.cli.cmd_teach") @@ -449,6 +449,6 @@ def test_question_command(self, mock_question): def test_teach_command(self, mock_banner, mock_teach): """Test teach command.""" mock_teach.return_value = 0 - result = main(["docker"]) + _result = main(["docker"]) mock_teach.assert_called_once() mock_banner.assert_called_once() diff --git a/tests/tutor/test_integration.py b/tests/tutor/test_integration.py index 437be378..79e37357 100644 --- a/tests/tutor/test_integration.py +++ b/tests/tutor/test_integration.py @@ -96,7 +96,7 @@ def test_lesson_context_creation(self): ) assert lesson.package_name == "docker" - assert lesson.confidence == 0.9 + assert lesson.confidence == pytest.approx(0.9) assert len(lesson.use_cases) == 2 def test_lesson_context_with_examples(self): @@ -134,7 +134,7 @@ def test_lesson_context_serialization(self): restored = LessonContext.from_json(json_str) assert restored.package_name == "docker" - assert restored.confidence == 0.85 + assert restored.confidence == pytest.approx(0.85) def test_lesson_context_display_dict(self): """Test to_display_dict method.""" @@ -208,13 +208,13 @@ class TestBranding: def test_tutor_print_success(self, capsys): """Test tutor_print with success status.""" tutor_print("Test message", "success") - captured = capsys.readouterr() + _captured = capsys.readouterr() # Rich console output is complex, just ensure no errors def test_tutor_print_error(self, capsys): """Test tutor_print with error status.""" tutor_print("Error message", "error") - captured = capsys.readouterr() + _captured = capsys.readouterr() def test_console_exists(self): """Test console is properly initialized.""" diff --git a/tests/tutor/test_progress_tracker.py b/tests/tutor/test_progress_tracker.py index bb148a19..e9ed0202 100644 --- a/tests/tutor/test_progress_tracker.py +++ b/tests/tutor/test_progress_tracker.py @@ -49,7 +49,7 @@ class TestSQLiteStore: def test_init_creates_database(self, temp_db): """Test database is created on init.""" - store = SQLiteStore(temp_db) + _store = SQLiteStore(temp_db) assert temp_db.exists() def test_upsert_and_get_progress(self, store): @@ -66,7 +66,7 @@ def test_upsert_and_get_progress(self, store): assert result is not None assert result.package_name == "docker" assert result.completed is True - assert result.score == 0.9 + assert result.score == pytest.approx(0.9) def test_upsert_updates_existing(self, store): """Test upsert updates existing record.""" @@ -90,7 +90,7 @@ def test_upsert_updates_existing(self, store): result = store.get_progress("docker", "basics") assert result.completed is True - assert result.score == 0.9 + assert result.score == pytest.approx(0.9) def test_get_all_progress(self, store): """Test getting all progress records.""" @@ -114,7 +114,7 @@ def test_mark_topic_completed(self, store): result = store.get_progress("docker", "tutorial") assert result.completed is True - assert result.score == 0.85 + assert result.score == pytest.approx(0.85) def test_get_completion_stats(self, store): """Test getting completion statistics.""" @@ -128,7 +128,7 @@ def test_get_completion_stats(self, store): stats = store.get_completion_stats("docker") assert stats["total"] == 2 assert stats["completed"] == 1 - assert stats["avg_score"] == 0.7 + assert stats["avg_score"] == pytest.approx(0.7) def test_quiz_results(self, store): """Test adding and retrieving quiz results.""" @@ -238,7 +238,7 @@ def test_mark_completed_action(self, tracker): score=0.85, ) assert result["success"] - assert result["score"] == 0.85 + assert result["score"] == pytest.approx(0.85) def test_get_stats_action(self, tracker): """Test get_stats action.""" @@ -309,7 +309,7 @@ def test_get_learning_progress(self, temp_db): progress = get_learning_progress("docker", "basics") assert progress is not None assert progress["completed"] is True - assert progress["score"] == 0.85 + assert progress["score"] == pytest.approx(0.85) def test_mark_topic_completed(self, temp_db): """Test mark_topic_completed function.""" @@ -347,4 +347,4 @@ def test_get_package_stats(self, temp_db): stats = get_package_stats("nginx") assert stats["total"] == 2 assert stats["completed"] == 2 - assert stats["avg_score"] == 0.8 # (0.9 + 0.7) / 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 index 567bc4b9..a03bf73e 100644 --- a/tests/tutor/test_tools.py +++ b/tests/tutor/test_tools.py @@ -178,7 +178,7 @@ def test_generate_lesson_structure(self, mock_config, mock_llm_class): assert "summary" in result assert "explanation" in result assert len(result["code_examples"]) == 1 - assert result["confidence"] == 0.9 + assert result["confidence"] == pytest.approx(0.9) def test_structure_response_handles_missing_fields(self): """Test structure_response handles missing fields gracefully.""" diff --git a/tests/tutor/test_tutor_agent.py b/tests/tutor/test_tutor_agent.py index eb3c7066..ec60e77a 100644 --- a/tests/tutor/test_tutor_agent.py +++ b/tests/tutor/test_tutor_agent.py @@ -44,7 +44,7 @@ def test_create_initial_state(self): assert state["input"]["session_type"] == "lesson" assert state["force_fresh"] is False assert state["errors"] == [] - assert state["cost_gbp"] == 0.0 + assert state["cost_gbp"] == pytest.approx(0.0) def test_create_initial_state_qa_mode(self): """Test creating initial state for Q&A.""" @@ -82,7 +82,7 @@ def test_add_cost(self): add_cost(state, 0.02) add_cost(state, 0.01) - assert state["cost_gbp"] == 0.03 + assert state["cost_gbp"] == pytest.approx(0.03) def test_has_critical_error_false(self): """Test has_critical_error returns False when no critical errors.""" @@ -207,7 +207,7 @@ def test_reflect_node_success(self): result = reflect_node(state) assert result["output"]["validation_passed"] is True - assert result["output"]["cost_gbp"] == 0.02 + assert result["output"]["cost_gbp"] == pytest.approx(0.02) def test_reflect_node_failure(self): """Test reflect_node with missing results.""" diff --git a/tests/tutor/test_validators.py b/tests/tutor/test_validators.py index 176cabf5..65404035 100644 --- a/tests/tutor/test_validators.py +++ b/tests/tutor/test_validators.py @@ -52,7 +52,7 @@ def test_empty_package_name(self): def test_whitespace_only(self): """Test whitespace-only input fails.""" - is_valid, error = validate_package_name(" ") + is_valid, _ = validate_package_name(" ") assert not is_valid def test_too_long_package_name(self): @@ -85,7 +85,7 @@ def test_invalid_characters(self): "has#hash", ] for name in invalid_names: - is_valid, error = validate_package_name(name) + is_valid, _ = validate_package_name(name) assert not is_valid, f"Expected {name} to be invalid" @@ -100,12 +100,12 @@ def test_valid_input(self): def test_empty_input_not_allowed(self): """Test empty input fails by default.""" - is_valid, error = validate_input("") + is_valid, _ = validate_input("") assert not is_valid def test_empty_input_allowed(self): """Test empty input passes when allowed.""" - is_valid, error = validate_input("", allow_empty=True) + is_valid, _ = validate_input("", allow_empty=True) assert is_valid def test_max_length(self): @@ -117,7 +117,7 @@ def test_max_length(self): def test_custom_max_length(self): """Test custom max length works.""" - is_valid, error = validate_input("hello", max_length=3) + is_valid, _ = validate_input("hello", max_length=3) assert not is_valid def test_blocked_patterns_in_input(self): @@ -132,12 +132,12 @@ class TestValidateQuestion: def test_valid_question(self): """Test valid questions pass.""" - is_valid, error = validate_question("What is the difference between Docker and VMs?") + 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, error = validate_question("") + is_valid, _ = validate_question("") assert not is_valid @@ -153,12 +153,12 @@ def test_valid_topic(self): "best-practices", ] for topic in valid_topics: - is_valid, error = validate_topic(topic) + 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, error = validate_topic("") + is_valid, _ = validate_topic("") assert not is_valid @@ -169,14 +169,14 @@ 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, error = validate_score(score) + 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, error = validate_score(score) + is_valid, _ = validate_score(score) assert not is_valid @@ -187,12 +187,12 @@ def test_valid_styles(self): """Test valid learning styles pass.""" valid_styles = ["visual", "reading", "hands-on"] for style in valid_styles: - is_valid, error = validate_learning_style(style) + is_valid, _ = validate_learning_style(style) assert is_valid def test_invalid_style(self): """Test invalid styles fail.""" - is_valid, error = validate_learning_style("invalid") + is_valid, _ = validate_learning_style("invalid") assert not is_valid From f4beddd6a451b1d84e8b19eacbbb0db1743df027 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 14:51:18 +0530 Subject: [PATCH 08/32] style: fix black formatting in cli.py --- cortex/tutor/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cortex/tutor/cli.py b/cortex/tutor/cli.py index 1c1608a1..edb112ae 100644 --- a/cortex/tutor/cli.py +++ b/cortex/tutor/cli.py @@ -253,9 +253,7 @@ def _show_package_progress(store: SQLiteStore, package: str) -> None: 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]" - ) + 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") From 29a7a8b6926a92d83dd959d2de7ac438c5f313d5 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 14:52:38 +0530 Subject: [PATCH 09/32] refactor: remove duplicate tests from cortex/tutor/tests Tests are now only in tests/tutor/ where CI runs them. This fixes SonarQube's ~99% duplicated lines warning. --- cortex/tutor/tests/test_agent_methods.py | 440 ----------------- cortex/tutor/tests/test_agentic_tools.py | 197 -------- cortex/tutor/tests/test_branding.py | 249 ---------- cortex/tutor/tests/test_cli.py | 454 ------------------ .../tutor/tests/test_deterministic_tools.py | 179 ------- cortex/tutor/tests/test_integration.py | 364 -------------- cortex/tutor/tests/test_interactive_tutor.py | 263 ---------- cortex/tutor/tests/test_progress_tracker.py | 350 -------------- cortex/tutor/tests/test_tools.py | 309 ------------ cortex/tutor/tests/test_tutor_agent.py | 320 ------------ cortex/tutor/tests/test_validators.py | 302 ------------ 11 files changed, 3427 deletions(-) delete mode 100644 cortex/tutor/tests/test_agent_methods.py delete mode 100644 cortex/tutor/tests/test_agentic_tools.py delete mode 100644 cortex/tutor/tests/test_branding.py delete mode 100644 cortex/tutor/tests/test_cli.py delete mode 100644 cortex/tutor/tests/test_deterministic_tools.py delete mode 100644 cortex/tutor/tests/test_integration.py delete mode 100644 cortex/tutor/tests/test_interactive_tutor.py delete mode 100644 cortex/tutor/tests/test_progress_tracker.py delete mode 100644 cortex/tutor/tests/test_tools.py delete mode 100644 cortex/tutor/tests/test_tutor_agent.py delete mode 100644 cortex/tutor/tests/test_validators.py diff --git a/cortex/tutor/tests/test_agent_methods.py b/cortex/tutor/tests/test_agent_methods.py deleted file mode 100644 index 9d1e6002..00000000 --- a/cortex/tutor/tests/test_agent_methods.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -Tests for TutorAgent methods and graph nodes. - -Comprehensive tests for agent functionality. -""" - -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cortex.tutor.agents.tutor_agent.graph import ( - create_tutor_graph, - fail_node, - generate_lesson_node, - get_tutor_graph, - load_cache_node, - plan_node, - qa_node, - reflect_node, - route_after_act, - route_after_plan, -) -from cortex.tutor.agents.tutor_agent.state import ( - TutorAgentState, - add_checkpoint, - add_cost, - add_error, - create_initial_state, - get_package_name, - get_session_type, - has_critical_error, -) - - -class TestTutorAgentMethods: - """Tests for TutorAgent class methods.""" - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_teach_success(self, mock_tracker_class, mock_graph): - """Test successful teach method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - mock_g = Mock() - mock_g.invoke.return_value = { - "output": { - "validation_passed": True, - "type": "lesson", - "content": {"summary": "Docker is..."}, - } - } - mock_graph.return_value = mock_g - - agent = TutorAgent(verbose=False) - result = agent.teach("docker") - - assert result["validation_passed"] is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_teach_verbose(self, mock_tracker_class, mock_graph): - """Test teach with verbose mode.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - mock_g = Mock() - mock_g.invoke.return_value = { - "output": { - "validation_passed": True, - "type": "lesson", - "source": "cache", - "cache_hit": True, - "cost_gbp": 0.0, - "cost_saved_gbp": 0.02, - "confidence": 0.9, - } - } - mock_graph.return_value = mock_g - - with patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print"): - with patch("cortex.tutor.agents.tutor_agent.tutor_agent.console"): - agent = TutorAgent(verbose=True) - result = agent.teach("docker") - - assert result["validation_passed"] is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_ask_success(self, mock_tracker_class, mock_graph): - """Test successful ask method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker_class.return_value = mock_tracker - - mock_g = Mock() - mock_g.invoke.return_value = { - "output": { - "validation_passed": True, - "type": "qa", - "content": {"answer": "Docker is a container platform."}, - } - } - mock_graph.return_value = mock_g - - agent = TutorAgent() - result = agent.ask("docker", "What is Docker?") - - assert result["validation_passed"] is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_get_profile(self, mock_tracker_class, mock_graph): - """Test get_profile method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = { - "success": True, - "profile": {"learning_style": "visual"}, - } - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.get_profile() - - assert result["success"] is True - assert "profile" in result - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_update_learning_style(self, mock_tracker_class, mock_graph): - """Test update_learning_style method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.update_learning_style("visual") - - assert result is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_mark_completed(self, mock_tracker_class, mock_graph): - """Test mark_completed method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.mark_completed("docker", "basics", 0.9) - - assert result is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_reset_progress(self, mock_tracker_class, mock_graph): - """Test reset_progress method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "count": 5} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.reset_progress() - - assert result == 5 - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_get_packages_studied(self, mock_tracker_class, mock_graph): - """Test get_packages_studied method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "packages": ["docker", "nginx"]} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.get_packages_studied() - - assert result == ["docker", "nginx"] - - -class TestGenerateLessonNode: - """Tests for generate_lesson_node.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - def test_generate_lesson_success(self, mock_generator_class, mock_loader_class): - """Test successful lesson generation.""" - mock_generator = Mock() - mock_generator._run.return_value = { - "success": True, - "lesson": { - "package_name": "docker", - "summary": "Docker is a container platform.", - "explanation": "Docker allows...", - }, - "cost_gbp": 0.02, - } - mock_generator_class.return_value = mock_generator - - mock_loader = Mock() - mock_loader.cache_lesson.return_value = True - mock_loader_class.return_value = mock_loader - - state = create_initial_state("docker") - state["student_profile"] = {"learning_style": "reading"} - - result = generate_lesson_node(state) - - assert result["results"]["type"] == "lesson" - assert result["results"]["source"] == "generated" - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - def test_generate_lesson_failure(self, mock_generator_class): - """Test lesson generation failure.""" - mock_generator = Mock() - mock_generator._run.return_value = { - "success": False, - "error": "API error", - } - mock_generator_class.return_value = mock_generator - - state = create_initial_state("docker") - state["student_profile"] = {} - - result = generate_lesson_node(state) - - assert len(result["errors"]) > 0 - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - def test_generate_lesson_exception(self, mock_generator_class): - """Test lesson generation with exception.""" - mock_generator_class.side_effect = Exception("Test exception") - - state = create_initial_state("docker") - state["student_profile"] = {} - - result = generate_lesson_node(state) - - assert len(result["errors"]) > 0 - - -class TestQANode: - """Tests for qa_node.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_success(self, mock_qa_class): - """Test successful Q&A.""" - mock_qa = Mock() - mock_qa._run.return_value = { - "success": True, - "answer": { - "answer": "Docker is a containerization platform.", - "explanation": "It allows...", - }, - "cost_gbp": 0.02, - } - mock_qa_class.return_value = mock_qa - - state = create_initial_state("docker", session_type="qa", question="What is Docker?") - state["student_profile"] = {} - - result = qa_node(state) - - assert result["results"]["type"] == "qa" - assert result["qa_result"] is not None - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_no_question(self, mock_qa_class): - """Test Q&A without question.""" - state = create_initial_state("docker", session_type="qa") - # No question provided - - result = qa_node(state) - - assert len(result["errors"]) > 0 - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_failure(self, mock_qa_class): - """Test Q&A failure.""" - mock_qa = Mock() - mock_qa._run.return_value = { - "success": False, - "error": "Could not answer", - } - mock_qa_class.return_value = mock_qa - - state = create_initial_state("docker", session_type="qa", question="What?") - state["student_profile"] = {} - - result = qa_node(state) - - assert len(result["errors"]) > 0 - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_exception(self, mock_qa_class): - """Test Q&A with exception.""" - mock_qa_class.side_effect = Exception("Test error") - - state = create_initial_state("docker", session_type="qa", question="What?") - state["student_profile"] = {} - - result = qa_node(state) - - assert len(result["errors"]) > 0 - - -class TestReflectNode: - """Tests for reflect_node.""" - - def test_reflect_with_errors(self): - """Test reflect with non-critical errors.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {"summary": "Test"}, "source": "cache"} - add_error(state, "test", "Minor error", recoverable=True) - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is True - assert result["output"]["confidence"] < 1.0 - - def test_reflect_with_critical_error(self): - """Test reflect with critical error.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {"summary": "Test"}} - add_error(state, "test", "Critical error", recoverable=False) - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is False - - -class TestFailNode: - """Tests for fail_node.""" - - def test_fail_node_with_errors(self): - """Test fail node with multiple errors.""" - state = create_initial_state("docker") - add_error(state, "test1", "Error 1") - add_error(state, "test2", "Error 2") - state["cost_gbp"] = 0.01 - - result = fail_node(state) - - assert result["output"]["type"] == "error" - assert result["output"]["validation_passed"] is False - assert len(result["output"]["validation_errors"]) == 2 - - -class TestRouting: - """Tests for routing functions.""" - - def test_route_after_plan_fail_on_error(self): - """Test routing to fail on critical error.""" - state = create_initial_state("docker") - add_error(state, "test", "Critical", recoverable=False) - - route = route_after_plan(state) - assert route == "fail" - - def test_route_after_act_fail_no_results(self): - """Test routing to fail when no results.""" - state = create_initial_state("docker") - state["results"] = {} - - route = route_after_act(state) - assert route == "fail" - - def test_route_after_act_success(self): - """Test routing to reflect on success.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {}} - - route = route_after_act(state) - assert route == "reflect" - - -class TestGraphCreation: - """Tests for graph creation.""" - - def test_create_tutor_graph(self): - """Test graph is created successfully.""" - graph = create_tutor_graph() - assert graph is not None - - def test_get_tutor_graph_singleton(self): - """Test get_tutor_graph returns singleton.""" - graph1 = get_tutor_graph() - graph2 = get_tutor_graph() - assert graph1 is graph2 - - -class TestStateHelpers: - """Tests for state helper functions.""" - - def test_add_checkpoint(self): - """Test add_checkpoint adds to list.""" - state = create_initial_state("docker") - add_checkpoint(state, "test", "ok", "Test checkpoint") - - assert len(state["checkpoints"]) == 1 - assert state["checkpoints"][0]["name"] == "test" - assert state["checkpoints"][0]["status"] == "ok" - - def test_add_cost(self): - """Test add_cost accumulates.""" - state = create_initial_state("docker") - add_cost(state, 0.01) - add_cost(state, 0.02) - add_cost(state, 0.005) - - assert abs(state["cost_gbp"] - 0.035) < 0.0001 - - def test_get_session_type_default(self): - """Test default session type.""" - state = create_initial_state("docker") - assert get_session_type(state) == "lesson" - - def test_get_package_name(self): - """Test getting package name.""" - state = create_initial_state("nginx") - assert get_package_name(state) == "nginx" diff --git a/cortex/tutor/tests/test_agentic_tools.py b/cortex/tutor/tests/test_agentic_tools.py deleted file mode 100644 index c8789109..00000000 --- a/cortex/tutor/tests/test_agentic_tools.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -Tests for agentic tools structure methods. - -Tests the _structure_response methods with mocked responses. -""" - -from unittest.mock import MagicMock, Mock, patch - -import pytest - - -class TestLessonGeneratorStructure: - """Tests for LessonGeneratorTool structure methods.""" - - @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") - @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") - def test_structure_response_full(self, mock_llm_class, mock_config): - """Test structure_response with full response.""" - from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - tool = LessonGeneratorTool() - - response = { - "package_name": "docker", - "summary": "Docker is a platform.", - "explanation": "Docker allows...", - "use_cases": ["Dev", "Prod"], - "best_practices": ["Use official images"], - "code_examples": [{"title": "Run", "code": "docker run", "language": "bash"}], - "tutorial_steps": [{"step_number": 1, "title": "Start", "content": "Begin"}], - "installation_command": "apt install docker", - "related_packages": ["podman"], - "confidence": 0.9, - } - - result = tool._structure_response(response, "docker") - - assert result["package_name"] == "docker" - assert result["summary"] == "Docker is a platform." - assert len(result["use_cases"]) == 2 - assert result["confidence"] == pytest.approx(0.9) - - @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") - @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") - def test_structure_response_minimal(self, mock_llm_class, mock_config): - """Test structure_response with minimal response.""" - from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - tool = LessonGeneratorTool() - - response = { - "package_name": "test", - "summary": "Test summary", - } - - result = tool._structure_response(response, "test") - - assert result["package_name"] == "test" - assert result["use_cases"] == [] - assert result["best_practices"] == [] - - -class TestExamplesProviderStructure: - """Tests for ExamplesProviderTool structure methods.""" - - @patch("cortex.tutor.tools.agentic.examples_provider.get_config") - @patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic") - def test_structure_response_full(self, mock_llm_class, mock_config): - """Test structure_response with full response.""" - from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - tool = ExamplesProviderTool() - - response = { - "package_name": "git", - "topic": "branching", - "examples": [{"title": "Create", "code": "git checkout -b", "language": "bash"}], - "tips": ["Use descriptive names"], - "common_mistakes": ["Forgetting to commit"], - "confidence": 0.95, - } - - result = tool._structure_response(response, "git", "branching") - - assert result["package_name"] == "git" - assert result["topic"] == "branching" - assert len(result["examples"]) == 1 - - -class TestQAHandlerStructure: - """Tests for QAHandlerTool structure methods.""" - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_structure_response_full(self, mock_llm_class, mock_config): - """Test structure_response with full response.""" - from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - tool = QAHandlerTool() - - response = { - "question_understood": "What is Docker?", - "answer": "Docker is a container platform.", - "explanation": "It allows packaging applications.", - "code_example": {"code": "docker run", "language": "bash"}, - "related_topics": ["containers", "images"], - "confidence": 0.9, - } - - result = tool._structure_response(response, "docker", "What is Docker?") - - assert result["answer"] == "Docker is a container platform." - assert result["code_example"] is not None - - -class TestConversationHandler: - """Tests for ConversationHandler.""" - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_build_context_empty(self, mock_llm_class, mock_config): - """Test context building with empty history.""" - from cortex.tutor.tools.agentic.qa_handler import ConversationHandler - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - handler = ConversationHandler("docker") - handler.history = [] - - context = handler._build_context() - assert "Starting fresh" in context - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_build_context_with_history(self, mock_llm_class, mock_config): - """Test context building with history.""" - from cortex.tutor.tools.agentic.qa_handler import ConversationHandler - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - handler = ConversationHandler("docker") - handler.history = [ - {"question": "What is Docker?", "answer": "A platform"}, - ] - - context = handler._build_context() - assert "What is Docker?" in context - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_clear_history(self, mock_llm_class, mock_config): - """Test clearing history.""" - from cortex.tutor.tools.agentic.qa_handler import ConversationHandler - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - handler = ConversationHandler("docker") - handler.history = [{"q": "test"}] - handler.clear_history() - - assert len(handler.history) == 0 diff --git a/cortex/tutor/tests/test_branding.py b/cortex/tutor/tests/test_branding.py deleted file mode 100644 index e8a81701..00000000 --- a/cortex/tutor/tests/test_branding.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -Tests for branding/UI utilities. - -Tests Rich console output functions. -""" - -from io import StringIO -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cortex.tutor.branding import ( - console, - get_user_input, - print_banner, - print_best_practice, - print_code_example, - print_error_panel, - print_lesson_header, - print_markdown, - print_menu, - print_progress_summary, - print_success_panel, - print_table, - print_tutorial_step, - tutor_print, -) - - -class TestConsole: - """Tests for console instance.""" - - def test_console_exists(self): - """Test console is initialized.""" - assert console is not None - - def test_console_is_rich(self): - """Test console is Rich Console.""" - from rich.console import Console - - assert isinstance(console, Console) - - -class TestTutorPrint: - """Tests for tutor_print function.""" - - def test_tutor_print_success(self, capsys): - """Test success status print.""" - tutor_print("Test message", "success") - # Rich output, just ensure no errors - - def test_tutor_print_error(self, capsys): - """Test error status print.""" - tutor_print("Error message", "error") - - def test_tutor_print_warning(self, capsys): - """Test warning status print.""" - tutor_print("Warning message", "warning") - - def test_tutor_print_info(self, capsys): - """Test info status print.""" - tutor_print("Info message", "info") - - def test_tutor_print_tutor(self, capsys): - """Test tutor status print.""" - tutor_print("Tutor message", "tutor") - - def test_tutor_print_default(self, capsys): - """Test default status print.""" - tutor_print("Default message") - - -class TestPrintBanner: - """Tests for print_banner function.""" - - def test_print_banner(self, capsys): - """Test banner prints without error.""" - print_banner() - # Just ensure no errors - - -class TestPrintLessonHeader: - """Tests for print_lesson_header function.""" - - def test_print_lesson_header(self, capsys): - """Test lesson header prints.""" - print_lesson_header("docker") - - def test_print_lesson_header_long_name(self, capsys): - """Test lesson header with long package name.""" - print_lesson_header("very-long-package-name-for-testing") - - -class TestPrintCodeExample: - """Tests for print_code_example function.""" - - def test_print_code_example_bash(self, capsys): - """Test code example with bash.""" - print_code_example("docker run nginx", "bash", "Run container") - - def test_print_code_example_python(self, capsys): - """Test code example with python.""" - print_code_example("print('hello')", "python", "Hello world") - - def test_print_code_example_no_title(self, capsys): - """Test code example without title.""" - print_code_example("echo hello", "bash") - - -class TestPrintMenu: - """Tests for print_menu function.""" - - def test_print_menu(self, capsys): - """Test menu prints.""" - options = ["Option 1", "Option 2", "Exit"] - print_menu(options) - - def test_print_menu_empty(self, capsys): - """Test empty menu.""" - print_menu([]) - - def test_print_menu_single(self, capsys): - """Test single option menu.""" - print_menu(["Only option"]) - - -class TestPrintTable: - """Tests for print_table function.""" - - def test_print_table(self, capsys): - """Test table prints.""" - headers = ["Name", "Value"] - rows = [["docker", "100"], ["nginx", "50"]] - print_table(headers, rows, "Test Table") - - def test_print_table_no_title(self, capsys): - """Test table without title.""" - headers = ["Col1", "Col2"] - rows = [["a", "b"]] - print_table(headers, rows) - - def test_print_table_empty_rows(self, capsys): - """Test table with empty rows.""" - headers = ["Header"] - print_table(headers, []) - - -class TestPrintProgressSummary: - """Tests for print_progress_summary function.""" - - def test_print_progress_summary(self, capsys): - """Test progress summary prints.""" - print_progress_summary(3, 5, "docker") - - def test_print_progress_summary_complete(self, capsys): - """Test progress summary when complete.""" - print_progress_summary(5, 5, "docker") - - def test_print_progress_summary_zero(self, capsys): - """Test progress summary with zero progress.""" - print_progress_summary(0, 5, "docker") - - -class TestPrintMarkdown: - """Tests for print_markdown function.""" - - def test_print_markdown(self, capsys): - """Test markdown prints.""" - print_markdown("# Header\n\nSome **bold** text.") - - def test_print_markdown_code(self, capsys): - """Test markdown with code block.""" - print_markdown("```bash\necho hello\n```") - - def test_print_markdown_list(self, capsys): - """Test markdown with list.""" - print_markdown("- Item 1\n- Item 2\n- Item 3") - - -class TestPrintBestPractice: - """Tests for print_best_practice function.""" - - def test_print_best_practice(self, capsys): - """Test best practice prints.""" - print_best_practice("Use official images", 1) - - def test_print_best_practice_long(self, capsys): - """Test best practice with long text.""" - long_text = "This is a very long best practice text " * 5 - print_best_practice(long_text, 10) - - -class TestPrintTutorialStep: - """Tests for print_tutorial_step function.""" - - def test_print_tutorial_step(self, capsys): - """Test tutorial step prints.""" - print_tutorial_step("Install Docker", 1, 5) - - def test_print_tutorial_step_last(self, capsys): - """Test last tutorial step.""" - print_tutorial_step("Finish setup", 5, 5) - - -class TestPrintErrorPanel: - """Tests for print_error_panel function.""" - - def test_print_error_panel(self, capsys): - """Test error panel prints.""" - print_error_panel("Something went wrong") - - def test_print_error_panel_long(self, capsys): - """Test error panel with long message.""" - print_error_panel("Error: " + "x" * 100) - - -class TestPrintSuccessPanel: - """Tests for print_success_panel function.""" - - def test_print_success_panel(self, capsys): - """Test success panel prints.""" - print_success_panel("Operation completed") - - def test_print_success_panel_long(self, capsys): - """Test success panel with long message.""" - print_success_panel("Success: " + "y" * 100) - - -class TestGetUserInput: - """Tests for get_user_input function.""" - - @patch("builtins.input", return_value="test input") - def test_get_user_input(self, mock_input): - """Test getting user input.""" - result = get_user_input("Enter value") - assert result == "test input" - - @patch("builtins.input", return_value="") - def test_get_user_input_empty(self, mock_input): - """Test empty user input.""" - result = get_user_input("Enter value") - assert result == "" - - @patch("builtins.input", return_value=" spaced ") - def test_get_user_input_strips(self, mock_input): - """Test input stripping is not done (raw input).""" - result = get_user_input("Enter value") - # Note: get_user_input should return raw input - assert "spaced" in result diff --git a/cortex/tutor/tests/test_cli.py b/cortex/tutor/tests/test_cli.py deleted file mode 100644 index d5f00180..00000000 --- a/cortex/tutor/tests/test_cli.py +++ /dev/null @@ -1,454 +0,0 @@ -""" -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, -) - - -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.""" - from cortex.tutor.config import reset_config - - 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.""" - from cortex.tutor.config import reset_config - - 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.""" - from cortex.tutor.config import reset_config - - 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.""" - from cortex.tutor.config import reset_config - - 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.""" - from cortex.tutor.config import reset_config - - 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.""" - from cortex.tutor.config import reset_config - - 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/cortex/tutor/tests/test_deterministic_tools.py b/cortex/tutor/tests/test_deterministic_tools.py deleted file mode 100644 index 15095fc0..00000000 --- a/cortex/tutor/tests/test_deterministic_tools.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -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/cortex/tutor/tests/test_integration.py b/cortex/tutor/tests/test_integration.py deleted file mode 100644 index 79e37357..00000000 --- a/cortex/tutor/tests/test_integration.py +++ /dev/null @@ -1,364 +0,0 @@ -""" -Integration tests for Intelligent Tutor. - -End-to-end tests for the complete tutoring workflow. -""" - -import os -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cortex.tutor.branding import console, print_banner, tutor_print -from cortex.tutor.config import Config, get_config, reset_config -from cortex.tutor.contracts.lesson_context import CodeExample, LessonContext, TutorialStep -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) - - def test_lesson_context_display_dict(self): - """Test to_display_dict method.""" - lesson = LessonContext( - package_name="docker", - summary="Summary", - explanation="Explanation", - use_cases=["Use 1", "Use 2"], - best_practices=["Practice 1"], - installation_command="apt install docker.io", - confidence=0.9, - ) - - display = lesson.to_display_dict() - - assert display["package"] == "docker" - assert display["confidence"] == "90%" - - -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 == 50.0 - assert package.average_score == 0.7 - assert not package.is_complete() - assert package.get_next_topic() == "advanced" - - def test_progress_context_recommendations(self): - """Test getting learning recommendations.""" - progress = ProgressContext( - weak_concepts=["networking", "volumes"], - packages=[ - PackageProgress( - package_name="docker", - topics=[TopicProgress(topic="basics", completed=False)], - ) - ], - ) - - recommendations = progress.get_recommendations() - - assert len(recommendations) >= 1 - assert any("networking" in r.lower() or "docker" in r.lower() for r in recommendations) - - -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") - _captured = capsys.readouterr() - # Rich console output is complex, just ensure no errors - - def test_tutor_print_error(self, capsys): - """Test tutor_print with error status.""" - tutor_print("Error message", "error") - _captured = capsys.readouterr() - - 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() - - # Test help doesn't raise - 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 - - def test_parse_progress_flag(self): - """Test parsing progress flag.""" - from cortex.tutor.cli import create_parser - - parser = create_parser() - args = parser.parse_args(["--progress"]) - - assert args.progress is True - - def test_parse_reset_flag(self): - """Test parsing reset flag.""" - from cortex.tutor.cli import create_parser - - parser = create_parser() - - # Reset all - args = parser.parse_args(["--reset"]) - assert args.reset == "__all__" - - # Reset specific package - args = parser.parse_args(["--reset", "docker"]) - assert args.reset == "docker" - - -class TestEndToEnd: - """End-to-end workflow tests.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - def test_full_lesson_workflow_with_cache( - self, mock_tracker_class, mock_loader_class, mock_generator_class - ): - """Test complete lesson workflow with cache hit.""" - # Set up mocks - mock_tracker = Mock() - mock_tracker._run.return_value = { - "success": True, - "profile": { - "learning_style": "reading", - "mastered_concepts": [], - "weak_concepts": [], - }, - } - mock_tracker_class.return_value = mock_tracker - - cached_lesson = { - "package_name": "docker", - "summary": "Docker is a containerization platform.", - "explanation": "Docker allows...", - "use_cases": ["Development"], - "best_practices": ["Use official images"], - "code_examples": [], - "tutorial_steps": [], - "installation_command": "apt install docker.io", - "confidence": 0.9, - } - - mock_loader = Mock() - mock_loader._run.return_value = { - "cache_hit": True, - "lesson": cached_lesson, - "cost_saved_gbp": 0.02, - } - mock_loader.cache_lesson.return_value = True - mock_loader_class.return_value = mock_loader - - # Run workflow - from cortex.tutor.agents.tutor_agent.graph import ( - load_cache_node, - plan_node, - reflect_node, - ) - from cortex.tutor.agents.tutor_agent.state import create_initial_state - - state = create_initial_state("docker") - - # Execute nodes - state = plan_node(state) - assert state["plan"]["strategy"] == "use_cache" - assert state["cache_hit"] is True - - state = load_cache_node(state) - assert state["results"]["type"] == "lesson" - - state = reflect_node(state) - assert state["output"]["validation_passed"] is True - assert state["output"]["cache_hit"] is True - - # Note: Real API test removed - use manual testing for API integration - # Run: python -m cortex.tutor.cli docker diff --git a/cortex/tutor/tests/test_interactive_tutor.py b/cortex/tutor/tests/test_interactive_tutor.py deleted file mode 100644 index a3fdfb6d..00000000 --- a/cortex/tutor/tests/test_interactive_tutor.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -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 - assert tutor.current_step == 0 - - -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/cortex/tutor/tests/test_progress_tracker.py b/cortex/tutor/tests/test_progress_tracker.py deleted file mode 100644 index e9ed0202..00000000 --- a/cortex/tutor/tests/test_progress_tracker.py +++ /dev/null @@ -1,350 +0,0 @@ -""" -Tests for progress tracker and SQLite store. - -Tests learning progress persistence and retrieval. -""" - -import os -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/cortex/tutor/tests/test_tools.py b/cortex/tutor/tests/test_tools.py deleted file mode 100644 index a03bf73e..00000000 --- a/cortex/tutor/tests/test_tools.py +++ /dev/null @@ -1,309 +0,0 @@ -""" -Tests for deterministic and agentic tools. - -Tests tool functionality with mocked LLM calls. -""" - -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool -from cortex.tutor.tools.agentic.qa_handler import ConversationHandler, QAHandlerTool -from cortex.tutor.tools.deterministic.lesson_loader import ( - FALLBACK_LESSONS, - 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) - - # Cache a lesson - loader.cache_lesson("docker", {"summary": "cached"}) - - # Force fresh should skip cache - 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.""" - # First cache a lesson - 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"] - - -class TestLessonGeneratorTool: - """Tests for LessonGeneratorTool with mocked LLM.""" - - @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") - @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") - def test_generate_lesson_structure(self, mock_config, mock_llm_class): - """Test lesson generation returns proper structure.""" - # Mock config - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - # Mock LLM response - mock_response = { - "package_name": "docker", - "summary": "Docker is a containerization platform.", - "explanation": "Docker allows you to...", - "use_cases": ["Development", "Deployment"], - "best_practices": ["Use official images"], - "code_examples": [ - { - "title": "Run container", - "code": "docker run nginx", - "language": "bash", - "description": "Runs nginx", - } - ], - "tutorial_steps": [ - { - "step_number": 1, - "title": "Install", - "content": "First, install Docker", - } - ], - "installation_command": "apt install docker.io", - "related_packages": ["podman"], - "confidence": 0.9, - } - - mock_chain = Mock() - mock_chain.invoke.return_value = mock_response - mock_llm = Mock() - mock_llm.__or__ = Mock(return_value=mock_chain) - mock_llm_class.return_value = mock_llm - - # Create tool and test - tool = LessonGeneratorTool() - tool.llm = mock_llm - - # Directly test structure method - result = tool._structure_response(mock_response, "docker") - - assert result["package_name"] == "docker" - assert "summary" in result - assert "explanation" in result - assert len(result["code_examples"]) == 1 - assert result["confidence"] == pytest.approx(0.9) - - def test_structure_response_handles_missing_fields(self): - """Test structure_response handles missing fields gracefully.""" - # Skip LLM initialization by mocking - with patch("cortex.tutor.tools.agentic.lesson_generator.get_config") as mock_config: - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - with patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic"): - tool = LessonGeneratorTool() - - incomplete_response = { - "package_name": "test", - "summary": "Test summary", - } - - result = tool._structure_response(incomplete_response, "test") - - assert result["package_name"] == "test" - assert result["summary"] == "Test summary" - assert result["use_cases"] == [] - assert result["best_practices"] == [] - - -class TestExamplesProviderTool: - """Tests for ExamplesProviderTool with mocked LLM.""" - - def test_structure_response(self): - """Test structure_response formats examples correctly.""" - with patch("cortex.tutor.tools.agentic.examples_provider.get_config") as mock_config: - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - with patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic"): - tool = ExamplesProviderTool() - - response = { - "package_name": "git", - "topic": "branching", - "examples": [ - { - "title": "Create branch", - "code": "git checkout -b feature", - "language": "bash", - "description": "Creates new branch", - } - ], - "tips": ["Use descriptive names"], - "common_mistakes": ["Forgetting to commit"], - "confidence": 0.95, - } - - result = tool._structure_response(response, "git", "branching") - - assert result["package_name"] == "git" - assert result["topic"] == "branching" - assert len(result["examples"]) == 1 - assert result["examples"][0]["title"] == "Create branch" - - -class TestQAHandlerTool: - """Tests for QAHandlerTool with mocked LLM.""" - - def test_structure_response(self): - """Test structure_response formats answers correctly.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config") as mock_config: - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - with patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic"): - tool = QAHandlerTool() - - response = { - "question_understood": "What is Docker?", - "answer": "Docker is a containerization platform.", - "explanation": "It allows you to package applications.", - "code_example": { - "code": "docker run hello-world", - "language": "bash", - "description": "Runs test container", - }, - "related_topics": ["containers", "images"], - "confidence": 0.9, - } - - result = tool._structure_response(response, "docker", "What is Docker?") - - assert result["answer"] == "Docker is a containerization platform." - assert result["code_example"] is not None - assert len(result["related_topics"]) == 2 - - -class TestConversationHandler: - """Tests for ConversationHandler.""" - - def test_build_context_empty(self): - """Test context building with empty history.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): - handler = ConversationHandler.__new__(ConversationHandler) - handler.history = [] - - context = handler._build_context() - assert "Starting fresh" in context - - def test_build_context_with_history(self): - """Test context building with history.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): - handler = ConversationHandler.__new__(ConversationHandler) - handler.history = [ - {"question": "What is Docker?", "answer": "A platform"}, - {"question": "How to install?", "answer": "Use apt"}, - ] - - context = handler._build_context() - assert "What is Docker?" in context - assert "Recent discussion" in context - - def test_clear_history(self): - """Test clearing conversation history.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): - handler = ConversationHandler.__new__(ConversationHandler) - handler.history = [{"question": "test", "answer": "test"}] - - handler.clear_history() - assert len(handler.history) == 0 diff --git a/cortex/tutor/tests/test_tutor_agent.py b/cortex/tutor/tests/test_tutor_agent.py deleted file mode 100644 index ec60e77a..00000000 --- a/cortex/tutor/tests/test_tutor_agent.py +++ /dev/null @@ -1,320 +0,0 @@ -""" -Tests for TutorAgent and LangGraph workflow. - -Tests the main agent orchestrator and state management. -""" - -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cortex.tutor.agents.tutor_agent.graph import ( - fail_node, - load_cache_node, - plan_node, - reflect_node, - route_after_act, - route_after_plan, -) -from cortex.tutor.agents.tutor_agent.state import ( - TutorAgentState, - add_checkpoint, - add_cost, - add_error, - create_initial_state, - get_package_name, - get_session_type, - has_critical_error, -) - - -class TestTutorAgentState: - """Tests for TutorAgentState and state utilities.""" - - def test_create_initial_state(self): - """Test creating initial state.""" - state = create_initial_state( - package_name="docker", - session_type="lesson", - ) - - assert state["input"]["package_name"] == "docker" - assert state["input"]["session_type"] == "lesson" - assert state["force_fresh"] is False - assert state["errors"] == [] - assert state["cost_gbp"] == pytest.approx(0.0) - - def test_create_initial_state_qa_mode(self): - """Test creating initial state for Q&A.""" - state = create_initial_state( - package_name="docker", - session_type="qa", - question="What is Docker?", - ) - - assert state["input"]["session_type"] == "qa" - assert state["input"]["question"] == "What is Docker?" - - def test_add_error(self): - """Test adding errors to state.""" - state = create_initial_state("docker") - add_error(state, "test_node", "Test error", recoverable=True) - - assert len(state["errors"]) == 1 - assert state["errors"][0]["node"] == "test_node" - assert state["errors"][0]["error"] == "Test error" - assert state["errors"][0]["recoverable"] is True - - def test_add_checkpoint(self): - """Test adding checkpoints to state.""" - state = create_initial_state("docker") - add_checkpoint(state, "plan_start", "ok", "Planning started") - - assert len(state["checkpoints"]) == 1 - assert state["checkpoints"][0]["name"] == "plan_start" - assert state["checkpoints"][0]["status"] == "ok" - - def test_add_cost(self): - """Test adding cost to state.""" - state = create_initial_state("docker") - add_cost(state, 0.02) - add_cost(state, 0.01) - - assert state["cost_gbp"] == pytest.approx(0.03) - - def test_has_critical_error_false(self): - """Test has_critical_error returns False when no critical errors.""" - state = create_initial_state("docker") - add_error(state, "test", "Recoverable error", recoverable=True) - - assert has_critical_error(state) is False - - def test_has_critical_error_true(self): - """Test has_critical_error returns True when critical error exists.""" - state = create_initial_state("docker") - add_error(state, "test", "Critical error", recoverable=False) - - assert has_critical_error(state) is True - - def test_get_session_type(self): - """Test get_session_type utility.""" - state = create_initial_state("docker", session_type="qa") - assert get_session_type(state) == "qa" - - def test_get_package_name(self): - """Test get_package_name utility.""" - state = create_initial_state("nginx") - assert get_package_name(state) == "nginx" - - -class TestGraphNodes: - """Tests for LangGraph node functions.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - def test_plan_node_cache_hit(self, mock_loader_class, mock_tracker_class): - """Test plan_node with cache hit.""" - # Mock tracker - mock_tracker = Mock() - mock_tracker._run.return_value = { - "success": True, - "profile": { - "learning_style": "reading", - "mastered_concepts": [], - "weak_concepts": [], - }, - } - mock_tracker_class.return_value = mock_tracker - - # Mock loader with cache hit - mock_loader = Mock() - mock_loader._run.return_value = { - "cache_hit": True, - "lesson": {"summary": "Cached lesson"}, - } - mock_loader_class.return_value = mock_loader - - state = create_initial_state("docker") - result = plan_node(state) - - assert result["plan"]["strategy"] == "use_cache" - assert result["cache_hit"] is True - - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - def test_plan_node_cache_miss(self, mock_loader_class, mock_tracker_class): - """Test plan_node with cache miss.""" - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "profile": {}} - mock_tracker_class.return_value = mock_tracker - - mock_loader = Mock() - mock_loader._run.return_value = {"cache_hit": False, "lesson": None} - mock_loader_class.return_value = mock_loader - - state = create_initial_state("docker") - result = plan_node(state) - - assert result["plan"]["strategy"] == "generate_full" - - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - def test_plan_node_qa_mode(self, mock_tracker_class): - """Test plan_node in Q&A mode.""" - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "profile": {}} - mock_tracker_class.return_value = mock_tracker - - state = create_initial_state("docker", session_type="qa", question="What?") - result = plan_node(state) - - assert result["plan"]["strategy"] == "qa_mode" - - def test_load_cache_node(self): - """Test load_cache_node with cached data.""" - state = create_initial_state("docker") - state["plan"] = { - "strategy": "use_cache", - "cached_data": {"summary": "Cached lesson", "explanation": "..."}, - } - - result = load_cache_node(state) - - assert result["lesson_content"]["summary"] == "Cached lesson" - assert result["results"]["source"] == "cache" - - def test_load_cache_node_missing_data(self): - """Test load_cache_node handles missing cache data.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "use_cache", "cached_data": None} - - result = load_cache_node(state) - - assert len(result["errors"]) > 0 - - def test_reflect_node_success(self): - """Test reflect_node with successful results.""" - state = create_initial_state("docker") - state["results"] = { - "type": "lesson", - "content": {"summary": "Test"}, - "source": "generated", - } - state["errors"] = [] - state["cost_gbp"] = 0.02 - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is True - assert result["output"]["cost_gbp"] == pytest.approx(0.02) - - def test_reflect_node_failure(self): - """Test reflect_node with missing results.""" - state = create_initial_state("docker") - state["results"] = {} - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is False - assert "No content" in str(result["output"]["validation_errors"]) - - def test_fail_node(self): - """Test fail_node creates proper error output.""" - state = create_initial_state("docker") - add_error(state, "test", "Test error") - state["cost_gbp"] = 0.01 - - result = fail_node(state) - - assert result["output"]["type"] == "error" - assert result["output"]["validation_passed"] is False - assert "Test error" in result["output"]["validation_errors"] - - -class TestRouting: - """Tests for routing functions.""" - - def test_route_after_plan_use_cache(self): - """Test routing to cache path.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "use_cache"} - - route = route_after_plan(state) - assert route == "load_cache" - - def test_route_after_plan_generate(self): - """Test routing to generation path.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "generate_full"} - - route = route_after_plan(state) - assert route == "generate_lesson" - - def test_route_after_plan_qa(self): - """Test routing to Q&A path.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "qa_mode"} - - route = route_after_plan(state) - assert route == "qa" - - def test_route_after_plan_critical_error(self): - """Test routing to fail on critical error.""" - state = create_initial_state("docker") - add_error(state, "test", "Critical", recoverable=False) - - route = route_after_plan(state) - assert route == "fail" - - def test_route_after_act_success(self): - """Test routing after successful act phase.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {}} - - route = route_after_act(state) - assert route == "reflect" - - def test_route_after_act_no_results(self): - """Test routing to fail when no results.""" - state = create_initial_state("docker") - state["results"] = {} - - route = route_after_act(state) - assert route == "fail" - - -class TestTutorAgentIntegration: - """Integration tests for TutorAgent.""" - - @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - def test_teach_validation(self, mock_graph): - """Test teach validates package name.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - from cortex.tutor.config import reset_config - - reset_config() - - with pytest.raises(ValueError) as exc_info: - agent = TutorAgent() - agent.teach("") - - assert "Invalid package name" in str(exc_info.value) - - @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - def test_ask_validation(self, mock_graph): - """Test ask validates inputs.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - from cortex.tutor.config import reset_config - - reset_config() - - agent = TutorAgent() - - with pytest.raises(ValueError): - agent.ask("", "question") - - with pytest.raises(ValueError): - agent.ask("docker", "") diff --git a/cortex/tutor/tests/test_validators.py b/cortex/tutor/tests/test_validators.py deleted file mode 100644 index 65404035..00000000 --- a/cortex/tutor/tests/test_validators.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -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 From 4ff4eb6941055e791b3fdef09e97714fd596d6ec Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 15:00:40 +0530 Subject: [PATCH 10/32] fix: remaining floating point comparisons in test_integration.py --- tests/tutor/test_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tutor/test_integration.py b/tests/tutor/test_integration.py index 79e37357..ec460cf2 100644 --- a/tests/tutor/test_integration.py +++ b/tests/tutor/test_integration.py @@ -179,8 +179,8 @@ def test_package_progress_completion(self): topics=topics, ) - assert package.completion_percentage == 50.0 - assert package.average_score == 0.7 + 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" From 2fab6a511bd4f47b319ba59eef0f7b6ee114e2db Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 15:22:08 +0530 Subject: [PATCH 11/32] fix: address reviewer feedback and CodeRabbit suggestions - Make student_level dynamic in graph.py based on mastered concepts - Add validation for total >= 1 in QuizContext.from_results - Move reset_config import to top of test_cli.py --- cortex/tutor/agents/tutor_agent/graph.py | 17 ++++++++++++++++- cortex/tutor/contracts/progress_context.py | 4 +++- tests/tutor/test_cli.py | 13 +------------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/cortex/tutor/agents/tutor_agent/graph.py b/cortex/tutor/agents/tutor_agent/graph.py index 6b9b39d1..9a1c8662 100644 --- a/cortex/tutor/agents/tutor_agent/graph.py +++ b/cortex/tutor/agents/tutor_agent/graph.py @@ -107,6 +107,18 @@ def load_cache_node(state: TutorAgentState) -> TutorAgentState: return state +def _infer_student_level(profile: dict) -> str: + """Infer student level from profile based on mastered concepts.""" + mastered = profile.get("mastered_concepts", []) + mastered_count = len(mastered) + + if mastered_count >= 10: + return "advanced" + elif mastered_count >= 5: + return "intermediate" + return "beginner" + + def generate_lesson_node(state: TutorAgentState) -> TutorAgentState: """ ACT Phase - Generation Path: Generate new lesson content. @@ -118,11 +130,14 @@ def generate_lesson_node(state: TutorAgentState) -> TutorAgentState: add_checkpoint(state, "generate_start", "ok", f"Generating lesson for {package_name}") + # Determine student level dynamically from profile + student_level = profile.get("student_level") or _infer_student_level(profile) + try: generator = LessonGeneratorTool() result = generator._run( package_name=package_name, - student_level="beginner", # Could be dynamic based on profile + student_level=student_level, learning_style=profile.get("learning_style", "reading"), skip_areas=profile.get("mastered_concepts", []), ) diff --git a/cortex/tutor/contracts/progress_context.py b/cortex/tutor/contracts/progress_context.py index 591df930..6189dab6 100644 --- a/cortex/tutor/contracts/progress_context.py +++ b/cortex/tutor/contracts/progress_context.py @@ -169,7 +169,9 @@ def from_results( feedback: str = "", ) -> "QuizContext": """Create QuizContext from raw results.""" - score = (correct / total * 100) if total > 0 else 0 + if total < 1: + raise ValueError("total must be at least 1") + score = (correct / total) * 100 return cls( package_name=package_name, questions_total=total, diff --git a/tests/tutor/test_cli.py b/tests/tutor/test_cli.py index d5f00180..39e194d3 100644 --- a/tests/tutor/test_cli.py +++ b/tests/tutor/test_cli.py @@ -21,6 +21,7 @@ create_parser, main, ) +from cortex.tutor.config import reset_config class TestCreateParser: @@ -107,8 +108,6 @@ def test_blocked_package_name(self): @patch("cortex.tutor.agents.tutor_agent.InteractiveTutor") def test_successful_teach(self, mock_tutor_class): """Test successful teach session.""" - from cortex.tutor.config import reset_config - reset_config() # Reset config singleton mock_tutor = Mock() @@ -122,8 +121,6 @@ def test_successful_teach(self, mock_tutor_class): @patch("cortex.tutor.agents.tutor_agent.InteractiveTutor") def test_teach_with_value_error(self, mock_tutor_class): """Test teach handles ValueError.""" - from cortex.tutor.config import reset_config - reset_config() mock_tutor_class.side_effect = ValueError("Test error") @@ -136,8 +133,6 @@ def test_teach_with_value_error(self, mock_tutor_class): @patch("cortex.tutor.agents.tutor_agent.InteractiveTutor") def test_teach_with_keyboard_interrupt(self, mock_tutor_class): """Test teach handles KeyboardInterrupt.""" - from cortex.tutor.config import reset_config - reset_config() mock_tutor = Mock() @@ -163,8 +158,6 @@ def test_invalid_package(self): @patch("cortex.tutor.agents.tutor_agent.TutorAgent") def test_successful_question(self, mock_agent_class): """Test successful question.""" - from cortex.tutor.config import reset_config - reset_config() mock_agent = Mock() @@ -186,8 +179,6 @@ def test_successful_question(self, mock_agent_class): @patch("cortex.tutor.agents.tutor_agent.TutorAgent") def test_question_with_code_example(self, mock_agent_class): """Test question with code example in response.""" - from cortex.tutor.config import reset_config - reset_config() mock_agent = Mock() @@ -213,8 +204,6 @@ def test_question_with_code_example(self, mock_agent_class): @patch("cortex.tutor.agents.tutor_agent.TutorAgent") def test_question_validation_failed(self, mock_agent_class): """Test question when validation fails.""" - from cortex.tutor.config import reset_config - reset_config() mock_agent = Mock() From 79bc9302aa681f3a0ade180b7745eec9780f86c0 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 15:27:47 +0530 Subject: [PATCH 12/32] chore: remove unused imports (CodeRabbit nitpicks) --- cortex/tutor/tools/agentic/qa_handler.py | 1 - tests/tutor/test_agentic_tools.py | 2 +- tests/tutor/test_integration.py | 3 +-- tests/tutor/test_progress_tracker.py | 1 - tests/tutor/test_tutor_agent.py | 2 +- 5 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index d91100b7..d983b1fa 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -4,7 +4,6 @@ This tool uses LLM (Claude via LangChain) to answer questions about packages. """ -from pathlib import Path from typing import Any from langchain.tools import BaseTool diff --git a/tests/tutor/test_agentic_tools.py b/tests/tutor/test_agentic_tools.py index c8789109..4d344077 100644 --- a/tests/tutor/test_agentic_tools.py +++ b/tests/tutor/test_agentic_tools.py @@ -4,7 +4,7 @@ Tests the _structure_response methods with mocked responses. """ -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest diff --git a/tests/tutor/test_integration.py b/tests/tutor/test_integration.py index ec460cf2..b4100dd0 100644 --- a/tests/tutor/test_integration.py +++ b/tests/tutor/test_integration.py @@ -4,10 +4,9 @@ End-to-end tests for the complete tutoring workflow. """ -import os import tempfile from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest diff --git a/tests/tutor/test_progress_tracker.py b/tests/tutor/test_progress_tracker.py index e9ed0202..52273da0 100644 --- a/tests/tutor/test_progress_tracker.py +++ b/tests/tutor/test_progress_tracker.py @@ -4,7 +4,6 @@ Tests learning progress persistence and retrieval. """ -import os import tempfile from pathlib import Path diff --git a/tests/tutor/test_tutor_agent.py b/tests/tutor/test_tutor_agent.py index ec60e77a..68c807d8 100644 --- a/tests/tutor/test_tutor_agent.py +++ b/tests/tutor/test_tutor_agent.py @@ -6,7 +6,7 @@ import tempfile from pathlib import Path -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest From 92df93b229271bde48ea002cd7072827ef608b5c Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 15:33:36 +0530 Subject: [PATCH 13/32] fix: correct return types and add input validation (CodeRabbit) --- cortex/tutor/agents/tutor_agent/graph.py | 7 ++++--- cortex/tutor/contracts/progress_context.py | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/cortex/tutor/agents/tutor_agent/graph.py b/cortex/tutor/agents/tutor_agent/graph.py index 9a1c8662..c30f60be 100644 --- a/cortex/tutor/agents/tutor_agent/graph.py +++ b/cortex/tutor/agents/tutor_agent/graph.py @@ -7,6 +7,7 @@ from typing import Literal from langgraph.graph import END, StateGraph +from langgraph.graph.state import CompiledStateGraph from cortex.tutor.agents.tutor_agent.state import ( TutorAgentState, @@ -327,7 +328,7 @@ def route_after_act(state: TutorAgentState) -> Literal["reflect", "fail"]: # ==================== Graph Builder ==================== -def create_tutor_graph() -> StateGraph: +def create_tutor_graph() -> CompiledStateGraph: """ Create the LangGraph workflow for the Tutor Agent. @@ -387,10 +388,10 @@ def create_tutor_graph() -> StateGraph: # Create singleton graph instance -_graph = None +_graph: CompiledStateGraph | None = None -def get_tutor_graph() -> StateGraph: +def get_tutor_graph() -> CompiledStateGraph: """ Get the singleton Tutor Agent graph. diff --git a/cortex/tutor/contracts/progress_context.py b/cortex/tutor/contracts/progress_context.py index 6189dab6..855da9c3 100644 --- a/cortex/tutor/contracts/progress_context.py +++ b/cortex/tutor/contracts/progress_context.py @@ -171,6 +171,10 @@ def from_results( """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, From 24bd578dfda513f72f34ea951ec6c0ccf2bddfb2 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 15:46:46 +0530 Subject: [PATCH 14/32] fix: refactor QAHandlerTool with lazy init and input validation - Use Pydantic v2 model_post_init pattern instead of __init__ - Implement lazy LLM initialization with _get_llm() - Add input validation using validate_package_name and validate_question - Extract common logic into _build_chain and _build_invoke_params - Harden _structure_response to handle non-conforming LLM JSON types - Lazy-init QAHandlerTool in ConversationHandler - Bound conversation history to prevent unbounded growth - Update tests to reflect new lazy initialization pattern --- cortex/tutor/tools/agentic/qa_handler.py | 211 ++++++++++++++++------- tests/tutor/test_agentic_tools.py | 121 +++++++++++-- tests/tutor/test_tools.py | 38 ++-- 3 files changed, 276 insertions(+), 94 deletions(-) diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index d983b1fa..455c2f32 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -4,15 +4,21 @@ This tool uses LLM (Claude via LangChain) to answer questions about packages. """ -from typing import Any +from typing import TYPE_CHECKING, Any from langchain.tools import BaseTool -from langchain_anthropic import ChatAnthropic from langchain_core.output_parsers import JsonOutputParser from langchain_core.prompts import ChatPromptTemplate from pydantic import Field from cortex.tutor.config import get_config +from cortex.tutor.tools.deterministic.validators import ( + validate_package_name, + validate_question, +) + +if TYPE_CHECKING: + from langchain_anthropic import ChatAnthropic class QAHandlerTool(BaseTool): @@ -22,7 +28,7 @@ class QAHandlerTool(BaseTool): Answers user questions about packages in an educational context, building on their existing knowledge. - Cost: ~$0.02 per question + Cost: ~£0.02 per question """ name: str = "qa_handler" @@ -32,7 +38,7 @@ class QAHandlerTool(BaseTool): "Provides contextual answers based on student profile." ) - llm: ChatAnthropic | None = Field(default=None, exclude=True) + llm: "ChatAnthropic | None" = Field(default=None, exclude=True) model_name: str = Field(default="claude-sonnet-4-20250514") # Constants for default values @@ -41,22 +47,69 @@ class QAHandlerTool(BaseTool): class Config: arbitrary_types_allowed = True - def __init__(self, model_name: str | None = None) -> None: - """ - Initialize the Q&A handler tool. - - Args: - model_name: LLM model to use. - """ - super().__init__() + def model_post_init(self, __context: Any) -> None: + """Post-init hook (Pydantic v2 pattern).""" config = get_config() - self.model_name = model_name or config.model - self.llm = ChatAnthropic( - model=self.model_name, - api_key=config.anthropic_api_key, - temperature=0.1, # Slight creativity for natural responses - max_tokens=2048, + if self.model_name == "claude-sonnet-4-20250514": + self.model_name = config.model + + def _get_llm(self) -> "ChatAnthropic": + """Lazily initialize and return the LLM.""" + if self.llm is None: + from langchain_anthropic import ChatAnthropic + + config = get_config() + self.llm = ChatAnthropic( + model=self.model_name, + api_key=config.anthropic_api_key, + temperature=0.1, + max_tokens=2048, + ) + return self.llm + + def _validate_inputs( + self, package_name: str, question: str + ) -> tuple[bool, str | None]: + """Validate package name and question inputs.""" + is_valid, error = validate_package_name(package_name) + if not is_valid: + return False, f"Invalid package name: {error}" + + is_valid, error = validate_question(question) + if not is_valid: + return False, f"Invalid question: {error}" + + return True, None + + def _build_chain(self) -> Any: + """Build the QA chain (shared by sync and async).""" + prompt = ChatPromptTemplate.from_messages( + [ + ("system", self._get_system_prompt()), + ("human", self._get_qa_prompt()), + ] ) + return prompt | self._get_llm() | JsonOutputParser() + + def _build_invoke_params( + self, + package_name: str, + question: str, + learning_style: str, + mastered_concepts: list[str] | None, + weak_concepts: list[str] | None, + lesson_context: str | None, + ) -> dict[str, str]: + """Build invocation parameters.""" + return { + "package_name": package_name, + "question": question, + "learning_style": learning_style, + "mastered_concepts": ", ".join(mastered_concepts or []) + or self._NONE_SPECIFIED, + "weak_concepts": ", ".join(weak_concepts or []) or self._NONE_SPECIFIED, + "lesson_context": lesson_context or "starting fresh", + } def _run( self, @@ -81,27 +134,23 @@ def _run( Returns: Dict containing the answer and related info. """ - try: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", self._get_system_prompt()), - ("human", self._get_qa_prompt()), - ] - ) - - chain = prompt | self.llm | JsonOutputParser() + # Validate inputs + is_valid, error = self._validate_inputs(package_name, question) + if not is_valid: + return {"success": False, "error": error, "answer": None} - result = chain.invoke( - { - "package_name": package_name, - "question": question, - "learning_style": learning_style, - "mastered_concepts": ", ".join(mastered_concepts or []) or self._NONE_SPECIFIED, - "weak_concepts": ", ".join(weak_concepts or []) or self._NONE_SPECIFIED, - "lesson_context": lesson_context or "starting fresh", - } + try: + chain = self._build_chain() + params = self._build_invoke_params( + package_name, + question, + learning_style, + mastered_concepts, + weak_concepts, + lesson_context, ) + result = chain.invoke(params) answer = self._structure_response(result, package_name, question) return { @@ -127,27 +176,23 @@ async def _arun( lesson_context: str | None = None, ) -> dict[str, Any]: """Async version of Q&A handling.""" - try: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", self._get_system_prompt()), - ("human", self._get_qa_prompt()), - ] - ) + # Validate inputs + is_valid, error = self._validate_inputs(package_name, question) + if not is_valid: + return {"success": False, "error": error, "answer": None} - chain = prompt | self.llm | JsonOutputParser() - - result = await chain.ainvoke( - { - "package_name": package_name, - "question": question, - "learning_style": learning_style, - "mastered_concepts": ", ".join(mastered_concepts or []) or self._NONE_SPECIFIED, - "weak_concepts": ", ".join(weak_concepts or []) or self._NONE_SPECIFIED, - "lesson_context": lesson_context or "starting fresh", - } + try: + chain = self._build_chain() + params = self._build_invoke_params( + package_name, + question, + learning_style, + mastered_concepts, + weak_concepts, + lesson_context, ) + result = await chain.ainvoke(params) answer = self._structure_response(result, package_name, question) return { @@ -218,6 +263,31 @@ def _structure_response( self, response: dict[str, Any], package_name: str, question: str ) -> dict[str, Any]: """Structure and validate the LLM response.""" + # Ensure response is a dict + if not isinstance(response, dict): + response = {} + + # Safely extract and validate related_topics + related_topics_raw = response.get("related_topics", []) + if isinstance(related_topics_raw, list): + related_topics = [str(t) for t in related_topics_raw][:5] + else: + related_topics = [] + + # Safely extract and validate follow_up_suggestions + follow_ups_raw = response.get("follow_up_suggestions", []) + if isinstance(follow_ups_raw, list): + follow_ups = [str(s) for s in follow_ups_raw][:3] + else: + follow_ups = [] + + # Safely extract and validate confidence + try: + confidence = float(response.get("confidence", 0.7)) + except (TypeError, ValueError): + confidence = 0.7 + confidence = min(max(confidence, 0.0), 1.0) + structured = { "package_name": package_name, "original_question": question, @@ -225,19 +295,26 @@ def _structure_response( "answer": response.get("answer", "I couldn't generate an answer."), "explanation": response.get("explanation"), "code_example": None, - "related_topics": response.get("related_topics", [])[:5], - "follow_up_suggestions": response.get("follow_up_suggestions", [])[:3], - "confidence": min(max(response.get("confidence", 0.7), 0.0), 1.0), + "related_topics": related_topics, + "follow_up_suggestions": follow_ups, + "confidence": confidence, "verification_note": response.get("verification_note"), } - # Structure code example if present + # Structure code example if present - with type validation code_ex = response.get("code_example") if isinstance(code_ex, dict) and code_ex.get("code"): structured["code_example"] = { - "code": code_ex.get("code", ""), - "language": code_ex.get("language", "bash"), - "description": code_ex.get("description", ""), + "code": str(code_ex.get("code", "")), + "language": str(code_ex.get("language", "bash")), + "description": str(code_ex.get("description", "")), + } + elif isinstance(code_ex, str) and code_ex: + # Handle case where code_example is just a string + structured["code_example"] = { + "code": code_ex, + "language": "bash", + "description": "", } return structured @@ -267,6 +344,10 @@ def answer_question( ) +# Maximum conversation history entries to prevent unbounded growth +_MAX_HISTORY_SIZE = 50 + + class ConversationHandler: """ Handles multi-turn Q&A conversations with context. @@ -283,7 +364,7 @@ def __init__(self, package_name: str) -> None: """ self.package_name = package_name self.history: list[dict[str, str]] = [] - self.qa_tool = QAHandlerTool() + self.qa_tool: QAHandlerTool | None = None # Lazy initialization def ask( self, @@ -304,6 +385,10 @@ def ask( Returns: Answer with context. """ + # Lazy-init QA tool on first use + if self.qa_tool is None: + self.qa_tool = QAHandlerTool() + # Build context from history context = self._build_context() @@ -325,6 +410,8 @@ def ask( "answer": result["answer"].get("answer", ""), } ) + # Bound history to prevent unbounded growth + self.history = self.history[-_MAX_HISTORY_SIZE:] return result diff --git a/tests/tutor/test_agentic_tools.py b/tests/tutor/test_agentic_tools.py index 4d344077..8ff5d89e 100644 --- a/tests/tutor/test_agentic_tools.py +++ b/tests/tutor/test_agentic_tools.py @@ -109,8 +109,7 @@ class TestQAHandlerStructure: """Tests for QAHandlerTool structure methods.""" @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_structure_response_full(self, mock_llm_class, mock_config): + def test_structure_response_full(self, mock_config): """Test structure_response with full response.""" from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool @@ -118,8 +117,9 @@ def test_structure_response_full(self, mock_llm_class, mock_config): anthropic_api_key="test_key", model="claude-sonnet-4-20250514", ) - mock_llm_class.return_value = Mock() + # QAHandlerTool now uses lazy LLM init, so we don't need to mock ChatAnthropic + # for _structure_response tests (it's not called during instantiation) tool = QAHandlerTool() response = { @@ -136,13 +136,97 @@ def test_structure_response_full(self, mock_llm_class, mock_config): assert result["answer"] == "Docker is a container platform." assert result["code_example"] is not None + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + def test_structure_response_handles_non_dict(self, mock_config): + """Test structure_response handles non-dict input.""" + from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + + tool = QAHandlerTool() + + # Test with non-dict response + result = tool._structure_response(None, "docker", "What is Docker?") # type: ignore + + assert result["answer"] == "I couldn't generate an answer." + assert result["package_name"] == "docker" + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + def test_structure_response_handles_invalid_confidence(self, mock_config): + """Test structure_response handles invalid confidence value.""" + from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + + tool = QAHandlerTool() + + response = { + "answer": "Test answer", + "confidence": "not a number", # Invalid type + } + + result = tool._structure_response(response, "docker", "What?") + + # Should default to 0.7 + assert result["confidence"] == pytest.approx(0.7) + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + def test_structure_response_clamps_confidence(self, mock_config): + """Test structure_response clamps confidence to 0-1 range.""" + from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + + tool = QAHandlerTool() + + # Test confidence > 1 + response = {"answer": "Test", "confidence": 1.5} + result = tool._structure_response(response, "docker", "What?") + assert result["confidence"] == pytest.approx(1.0) + + # Test confidence < 0 + response = {"answer": "Test", "confidence": -0.5} + result = tool._structure_response(response, "docker", "What?") + assert result["confidence"] == pytest.approx(0.0) + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + def test_structure_response_handles_string_code_example(self, mock_config): + """Test structure_response handles code_example as string.""" + from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + + tool = QAHandlerTool() + + response = { + "answer": "Test answer", + "code_example": "docker run nginx", # String instead of dict + } + + result = tool._structure_response(response, "docker", "How to run?") + + assert result["code_example"] is not None + assert result["code_example"]["code"] == "docker run nginx" + assert result["code_example"]["language"] == "bash" + class TestConversationHandler: """Tests for ConversationHandler.""" @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_build_context_empty(self, mock_llm_class, mock_config): + def test_build_context_empty(self, mock_config): """Test context building with empty history.""" from cortex.tutor.tools.agentic.qa_handler import ConversationHandler @@ -150,8 +234,8 @@ def test_build_context_empty(self, mock_llm_class, mock_config): anthropic_api_key="test_key", model="claude-sonnet-4-20250514", ) - mock_llm_class.return_value = Mock() + # ConversationHandler now uses lazy init, no LLM created on __init__ handler = ConversationHandler("docker") handler.history = [] @@ -159,8 +243,7 @@ def test_build_context_empty(self, mock_llm_class, mock_config): assert "Starting fresh" in context @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_build_context_with_history(self, mock_llm_class, mock_config): + def test_build_context_with_history(self, mock_config): """Test context building with history.""" from cortex.tutor.tools.agentic.qa_handler import ConversationHandler @@ -168,7 +251,6 @@ def test_build_context_with_history(self, mock_llm_class, mock_config): anthropic_api_key="test_key", model="claude-sonnet-4-20250514", ) - mock_llm_class.return_value = Mock() handler = ConversationHandler("docker") handler.history = [ @@ -179,8 +261,7 @@ def test_build_context_with_history(self, mock_llm_class, mock_config): assert "What is Docker?" in context @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - @patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic") - def test_clear_history(self, mock_llm_class, mock_config): + def test_clear_history(self, mock_config): """Test clearing history.""" from cortex.tutor.tools.agentic.qa_handler import ConversationHandler @@ -188,10 +269,24 @@ def test_clear_history(self, mock_llm_class, mock_config): anthropic_api_key="test_key", model="claude-sonnet-4-20250514", ) - mock_llm_class.return_value = Mock() handler = ConversationHandler("docker") - handler.history = [{"q": "test"}] + handler.history = [{"question": "test", "answer": "response"}] handler.clear_history() assert len(handler.history) == 0 + + @patch("cortex.tutor.tools.agentic.qa_handler.get_config") + def test_lazy_qa_tool_init(self, mock_config): + """Test QA tool is lazily initialized.""" + from cortex.tutor.tools.agentic.qa_handler import ConversationHandler + + mock_config.return_value = Mock( + anthropic_api_key="test_key", + model="claude-sonnet-4-20250514", + ) + + handler = ConversationHandler("docker") + + # qa_tool should be None before first ask() + assert handler.qa_tool is None diff --git a/tests/tutor/test_tools.py b/tests/tutor/test_tools.py index a03bf73e..03fc1e8b 100644 --- a/tests/tutor/test_tools.py +++ b/tests/tutor/test_tools.py @@ -251,27 +251,27 @@ def test_structure_response(self): anthropic_api_key="test_key", model="claude-sonnet-4-20250514", ) - with patch("cortex.tutor.tools.agentic.qa_handler.ChatAnthropic"): - tool = QAHandlerTool() - - response = { - "question_understood": "What is Docker?", - "answer": "Docker is a containerization platform.", - "explanation": "It allows you to package applications.", - "code_example": { - "code": "docker run hello-world", - "language": "bash", - "description": "Runs test container", - }, - "related_topics": ["containers", "images"], - "confidence": 0.9, - } + # QAHandlerTool uses lazy LLM init, no need to mock ChatAnthropic + tool = QAHandlerTool() + + response = { + "question_understood": "What is Docker?", + "answer": "Docker is a containerization platform.", + "explanation": "It allows you to package applications.", + "code_example": { + "code": "docker run hello-world", + "language": "bash", + "description": "Runs test container", + }, + "related_topics": ["containers", "images"], + "confidence": 0.9, + } - result = tool._structure_response(response, "docker", "What is Docker?") + result = tool._structure_response(response, "docker", "What is Docker?") - assert result["answer"] == "Docker is a containerization platform." - assert result["code_example"] is not None - assert len(result["related_topics"]) == 2 + assert result["answer"] == "Docker is a containerization platform." + assert result["code_example"] is not None + assert len(result["related_topics"]) == 2 class TestConversationHandler: From 5fe8a893b7f72277f082994b93d33992fd061040 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 15:50:10 +0530 Subject: [PATCH 15/32] style: fix black formatting in qa_handler.py --- cortex/tutor/tools/agentic/qa_handler.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index 455c2f32..aaff2dad 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -67,9 +67,7 @@ def _get_llm(self) -> "ChatAnthropic": ) return self.llm - def _validate_inputs( - self, package_name: str, question: str - ) -> tuple[bool, str | None]: + def _validate_inputs(self, package_name: str, question: str) -> tuple[bool, str | None]: """Validate package name and question inputs.""" is_valid, error = validate_package_name(package_name) if not is_valid: @@ -105,8 +103,7 @@ def _build_invoke_params( "package_name": package_name, "question": question, "learning_style": learning_style, - "mastered_concepts": ", ".join(mastered_concepts or []) - or self._NONE_SPECIFIED, + "mastered_concepts": ", ".join(mastered_concepts or []) or self._NONE_SPECIFIED, "weak_concepts": ", ".join(weak_concepts or []) or self._NONE_SPECIFIED, "lesson_context": lesson_context or "starting fresh", } From 0d0b49591960ae1c094c3d4343e2918166c2e8c2 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 16:10:29 +0530 Subject: [PATCH 16/32] fix: resolve Pydantic forward reference and type annotation issues - Change llm field type from "ChatAnthropic | None" to Any to fix Pydantic model_rebuild() error when ChatAnthropic is not imported - Update _structure_response type hint to accept Any for response param to match actual runtime behavior (handles None gracefully) - Remove unnecessary type: ignore comment from test - Add docstring with Args/Returns to _structure_response method --- cortex/tutor/tools/agentic/qa_handler.py | 17 ++++++++++++++--- tests/tutor/test_agentic_tools.py | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index aaff2dad..3dfefccf 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -38,7 +38,9 @@ class QAHandlerTool(BaseTool): "Provides contextual answers based on student profile." ) - llm: "ChatAnthropic | None" = Field(default=None, exclude=True) + # Use Any type for llm field to avoid Pydantic forward reference issues + # The actual type is ChatAnthropic, but it's lazily initialized + llm: Any = Field(default=None, exclude=True) model_name: str = Field(default="claude-sonnet-4-20250514") # Constants for default values @@ -257,9 +259,18 @@ def _get_qa_prompt(self) -> str: If the question is unclear, ask for clarification in the answer field.""" def _structure_response( - self, response: dict[str, Any], package_name: str, question: str + self, response: dict[str, Any] | Any, package_name: str, question: str ) -> dict[str, Any]: - """Structure and validate the LLM response.""" + """Structure and validate the LLM response. + + Args: + response: The LLM response, expected to be a dict but handles any type gracefully. + package_name: The package being queried. + question: The original user question. + + Returns: + Structured response dictionary. + """ # Ensure response is a dict if not isinstance(response, dict): response = {} diff --git a/tests/tutor/test_agentic_tools.py b/tests/tutor/test_agentic_tools.py index 8ff5d89e..4e86d896 100644 --- a/tests/tutor/test_agentic_tools.py +++ b/tests/tutor/test_agentic_tools.py @@ -149,7 +149,7 @@ def test_structure_response_handles_non_dict(self, mock_config): tool = QAHandlerTool() # Test with non-dict response - result = tool._structure_response(None, "docker", "What is Docker?") # type: ignore + result = tool._structure_response(None, "docker", "What is Docker?") assert result["answer"] == "I couldn't generate an answer." assert result["package_name"] == "docker" From 814018b871b92e2dec41f737f60e1d08620c45ae Mon Sep 17 00:00:00 2001 From: Vamsi Date: Mon, 12 Jan 2026 16:21:08 +0530 Subject: [PATCH 17/32] fix: add safe access pattern for result["answer"] in ConversationHandler Guard against potential KeyError/TypeError when accessing answer data by checking isinstance before calling .get() method. --- cortex/tutor/tools/agentic/qa_handler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index 3dfefccf..982860e4 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -412,10 +412,12 @@ def ask( # Update history if result.get("success"): + answer_obj = result.get("answer") + answer_text = answer_obj.get("answer", "") if isinstance(answer_obj, dict) else "" self.history.append( { "question": question, - "answer": result["answer"].get("answer", ""), + "answer": answer_text, } ) # Bound history to prevent unbounded growth From 49830cb5d023b06efd72f4e77b378e60db30e5c3 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 10:40:57 +0530 Subject: [PATCH 18/32] refactor: cleanup code quality issues from review - Remove test_branding.py (low-value smoke tests with no assertions) - Remove empty cortex/tutor/tests/ directory - Centralize model name in config.py (remove hardcoded defaults from tools) - Centralize DEFAULT_TUTOR_TOPICS in config.py (was duplicated in 2 files) - Update agentic tools to use config.model via Field default=None All 234 tutor tests pass. --- .../tutor/agents/tutor_agent/tutor_agent.py | 4 +- cortex/tutor/cli.py | 5 +- cortex/tutor/config.py | 3 + cortex/tutor/tests/__init__.py | 5 - .../tutor/tools/agentic/examples_provider.py | 2 +- .../tutor/tools/agentic/lesson_generator.py | 2 +- cortex/tutor/tools/agentic/qa_handler.py | 6 +- tests/tutor/test_branding.py | 249 ------------------ 8 files changed, 10 insertions(+), 266 deletions(-) delete mode 100644 cortex/tutor/tests/__init__.py delete mode 100644 tests/tutor/test_branding.py diff --git a/cortex/tutor/agents/tutor_agent/tutor_agent.py b/cortex/tutor/agents/tutor_agent/tutor_agent.py index a53d4d99..e029f67b 100644 --- a/cortex/tutor/agents/tutor_agent/tutor_agent.py +++ b/cortex/tutor/agents/tutor_agent/tutor_agent.py @@ -9,6 +9,7 @@ from cortex.tutor.agents.tutor_agent.graph import get_tutor_graph from cortex.tutor.agents.tutor_agent.state import TutorAgentState, create_initial_state from cortex.tutor.branding import console, tutor_print +from cortex.tutor.config import DEFAULT_TUTOR_TOPICS from cortex.tutor.contracts.lesson_context import LessonContext from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool from cortex.tutor.tools.deterministic.validators import ( @@ -16,9 +17,6 @@ validate_question, ) -# Default number of topics per package for progress tracking -DEFAULT_TUTOR_TOPICS = 5 - class TutorAgent: """ diff --git a/cortex/tutor/cli.py b/cortex/tutor/cli.py index edb112ae..1308f580 100644 --- a/cortex/tutor/cli.py +++ b/cortex/tutor/cli.py @@ -24,13 +24,10 @@ print_table, tutor_print, ) -from cortex.tutor.config import Config +from cortex.tutor.config import Config, DEFAULT_TUTOR_TOPICS from cortex.tutor.memory.sqlite_store import SQLiteStore from cortex.tutor.tools.deterministic.validators import validate_package_name -# Default number of topics per package for progress tracking -DEFAULT_TUTOR_TOPICS = 5 - def create_parser() -> argparse.ArgumentParser: """ diff --git a/cortex/tutor/config.py b/cortex/tutor/config.py index 3597169f..4dd87104 100644 --- a/cortex/tutor/config.py +++ b/cortex/tutor/config.py @@ -14,6 +14,9 @@ # Load environment variables from .env file load_dotenv() +# Default number of topics for progress tracking +DEFAULT_TUTOR_TOPICS = 5 + class Config(BaseModel): """ diff --git a/cortex/tutor/tests/__init__.py b/cortex/tutor/tests/__init__.py deleted file mode 100644 index 3d1f683a..00000000 --- a/cortex/tutor/tests/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Tests for Intelligent Tutor. - -Provides unit and integration tests with >80% coverage target. -""" diff --git a/cortex/tutor/tools/agentic/examples_provider.py b/cortex/tutor/tools/agentic/examples_provider.py index 108b935c..f404e67d 100644 --- a/cortex/tutor/tools/agentic/examples_provider.py +++ b/cortex/tutor/tools/agentic/examples_provider.py @@ -34,7 +34,7 @@ class ExamplesProviderTool(BaseTool): ) llm: ChatAnthropic | None = Field(default=None, exclude=True) - model_name: str = Field(default="claude-sonnet-4-20250514") + model_name: str | None = Field(default=None, description="Model name, defaults to config.model") class Config: arbitrary_types_allowed = True diff --git a/cortex/tutor/tools/agentic/lesson_generator.py b/cortex/tutor/tools/agentic/lesson_generator.py index 1d7b7f59..b8eb7af5 100644 --- a/cortex/tutor/tools/agentic/lesson_generator.py +++ b/cortex/tutor/tools/agentic/lesson_generator.py @@ -57,7 +57,7 @@ class LessonGeneratorTool(BaseTool): ) llm: ChatAnthropic | None = Field(default=None, exclude=True) - model_name: str = Field(default="claude-sonnet-4-20250514") + model_name: str | None = Field(default=None, description="Model name, defaults to config.model") class Config: arbitrary_types_allowed = True diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py index 982860e4..aa98d51f 100644 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ b/cortex/tutor/tools/agentic/qa_handler.py @@ -41,7 +41,7 @@ class QAHandlerTool(BaseTool): # Use Any type for llm field to avoid Pydantic forward reference issues # The actual type is ChatAnthropic, but it's lazily initialized llm: Any = Field(default=None, exclude=True) - model_name: str = Field(default="claude-sonnet-4-20250514") + model_name: str | None = Field(default=None, description="Model name, defaults to config.model") # Constants for default values _NONE_SPECIFIED: str = "none specified" @@ -51,8 +51,8 @@ class Config: def model_post_init(self, __context: Any) -> None: """Post-init hook (Pydantic v2 pattern).""" - config = get_config() - if self.model_name == "claude-sonnet-4-20250514": + if self.model_name is None: + config = get_config() self.model_name = config.model def _get_llm(self) -> "ChatAnthropic": diff --git a/tests/tutor/test_branding.py b/tests/tutor/test_branding.py deleted file mode 100644 index e8a81701..00000000 --- a/tests/tutor/test_branding.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -Tests for branding/UI utilities. - -Tests Rich console output functions. -""" - -from io import StringIO -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cortex.tutor.branding import ( - console, - get_user_input, - print_banner, - print_best_practice, - print_code_example, - print_error_panel, - print_lesson_header, - print_markdown, - print_menu, - print_progress_summary, - print_success_panel, - print_table, - print_tutorial_step, - tutor_print, -) - - -class TestConsole: - """Tests for console instance.""" - - def test_console_exists(self): - """Test console is initialized.""" - assert console is not None - - def test_console_is_rich(self): - """Test console is Rich Console.""" - from rich.console import Console - - assert isinstance(console, Console) - - -class TestTutorPrint: - """Tests for tutor_print function.""" - - def test_tutor_print_success(self, capsys): - """Test success status print.""" - tutor_print("Test message", "success") - # Rich output, just ensure no errors - - def test_tutor_print_error(self, capsys): - """Test error status print.""" - tutor_print("Error message", "error") - - def test_tutor_print_warning(self, capsys): - """Test warning status print.""" - tutor_print("Warning message", "warning") - - def test_tutor_print_info(self, capsys): - """Test info status print.""" - tutor_print("Info message", "info") - - def test_tutor_print_tutor(self, capsys): - """Test tutor status print.""" - tutor_print("Tutor message", "tutor") - - def test_tutor_print_default(self, capsys): - """Test default status print.""" - tutor_print("Default message") - - -class TestPrintBanner: - """Tests for print_banner function.""" - - def test_print_banner(self, capsys): - """Test banner prints without error.""" - print_banner() - # Just ensure no errors - - -class TestPrintLessonHeader: - """Tests for print_lesson_header function.""" - - def test_print_lesson_header(self, capsys): - """Test lesson header prints.""" - print_lesson_header("docker") - - def test_print_lesson_header_long_name(self, capsys): - """Test lesson header with long package name.""" - print_lesson_header("very-long-package-name-for-testing") - - -class TestPrintCodeExample: - """Tests for print_code_example function.""" - - def test_print_code_example_bash(self, capsys): - """Test code example with bash.""" - print_code_example("docker run nginx", "bash", "Run container") - - def test_print_code_example_python(self, capsys): - """Test code example with python.""" - print_code_example("print('hello')", "python", "Hello world") - - def test_print_code_example_no_title(self, capsys): - """Test code example without title.""" - print_code_example("echo hello", "bash") - - -class TestPrintMenu: - """Tests for print_menu function.""" - - def test_print_menu(self, capsys): - """Test menu prints.""" - options = ["Option 1", "Option 2", "Exit"] - print_menu(options) - - def test_print_menu_empty(self, capsys): - """Test empty menu.""" - print_menu([]) - - def test_print_menu_single(self, capsys): - """Test single option menu.""" - print_menu(["Only option"]) - - -class TestPrintTable: - """Tests for print_table function.""" - - def test_print_table(self, capsys): - """Test table prints.""" - headers = ["Name", "Value"] - rows = [["docker", "100"], ["nginx", "50"]] - print_table(headers, rows, "Test Table") - - def test_print_table_no_title(self, capsys): - """Test table without title.""" - headers = ["Col1", "Col2"] - rows = [["a", "b"]] - print_table(headers, rows) - - def test_print_table_empty_rows(self, capsys): - """Test table with empty rows.""" - headers = ["Header"] - print_table(headers, []) - - -class TestPrintProgressSummary: - """Tests for print_progress_summary function.""" - - def test_print_progress_summary(self, capsys): - """Test progress summary prints.""" - print_progress_summary(3, 5, "docker") - - def test_print_progress_summary_complete(self, capsys): - """Test progress summary when complete.""" - print_progress_summary(5, 5, "docker") - - def test_print_progress_summary_zero(self, capsys): - """Test progress summary with zero progress.""" - print_progress_summary(0, 5, "docker") - - -class TestPrintMarkdown: - """Tests for print_markdown function.""" - - def test_print_markdown(self, capsys): - """Test markdown prints.""" - print_markdown("# Header\n\nSome **bold** text.") - - def test_print_markdown_code(self, capsys): - """Test markdown with code block.""" - print_markdown("```bash\necho hello\n```") - - def test_print_markdown_list(self, capsys): - """Test markdown with list.""" - print_markdown("- Item 1\n- Item 2\n- Item 3") - - -class TestPrintBestPractice: - """Tests for print_best_practice function.""" - - def test_print_best_practice(self, capsys): - """Test best practice prints.""" - print_best_practice("Use official images", 1) - - def test_print_best_practice_long(self, capsys): - """Test best practice with long text.""" - long_text = "This is a very long best practice text " * 5 - print_best_practice(long_text, 10) - - -class TestPrintTutorialStep: - """Tests for print_tutorial_step function.""" - - def test_print_tutorial_step(self, capsys): - """Test tutorial step prints.""" - print_tutorial_step("Install Docker", 1, 5) - - def test_print_tutorial_step_last(self, capsys): - """Test last tutorial step.""" - print_tutorial_step("Finish setup", 5, 5) - - -class TestPrintErrorPanel: - """Tests for print_error_panel function.""" - - def test_print_error_panel(self, capsys): - """Test error panel prints.""" - print_error_panel("Something went wrong") - - def test_print_error_panel_long(self, capsys): - """Test error panel with long message.""" - print_error_panel("Error: " + "x" * 100) - - -class TestPrintSuccessPanel: - """Tests for print_success_panel function.""" - - def test_print_success_panel(self, capsys): - """Test success panel prints.""" - print_success_panel("Operation completed") - - def test_print_success_panel_long(self, capsys): - """Test success panel with long message.""" - print_success_panel("Success: " + "y" * 100) - - -class TestGetUserInput: - """Tests for get_user_input function.""" - - @patch("builtins.input", return_value="test input") - def test_get_user_input(self, mock_input): - """Test getting user input.""" - result = get_user_input("Enter value") - assert result == "test input" - - @patch("builtins.input", return_value="") - def test_get_user_input_empty(self, mock_input): - """Test empty user input.""" - result = get_user_input("Enter value") - assert result == "" - - @patch("builtins.input", return_value=" spaced ") - def test_get_user_input_strips(self, mock_input): - """Test input stripping is not done (raw input).""" - result = get_user_input("Enter value") - # Note: get_user_input should return raw input - assert "spaced" in result From 2bf80bb4d19de6d6d5fcfabf2ee34c570ecac53a Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 11:11:38 +0530 Subject: [PATCH 19/32] style: fix import sorting in cli.py (ruff I001) --- cortex/tutor/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortex/tutor/cli.py b/cortex/tutor/cli.py index 1308f580..40dc63d1 100644 --- a/cortex/tutor/cli.py +++ b/cortex/tutor/cli.py @@ -24,7 +24,7 @@ print_table, tutor_print, ) -from cortex.tutor.config import Config, DEFAULT_TUTOR_TOPICS +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 From b481abd84f05d8dc04e687341a95da71955def7c Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 11:53:47 +0530 Subject: [PATCH 20/32] fix: address CodeRabbit review feedback - Add validation for learning_style parameter (must be visual/reading/hands-on) - Add validation for score bounds (0.0-1.0) in mark_completed - Remove standalone tutor entry point (use cortex tutor subcommand) - Remove unused imports (Path, LessonContext, CodeExample, TutorialStep) --- cortex/tutor/agents/tutor_agent/tutor_agent.py | 6 +++++- cortex/tutor/tools/agentic/examples_provider.py | 1 - cortex/tutor/tools/agentic/lesson_generator.py | 1 - pyproject.toml | 1 - 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cortex/tutor/agents/tutor_agent/tutor_agent.py b/cortex/tutor/agents/tutor_agent/tutor_agent.py index e029f67b..04ec9438 100644 --- a/cortex/tutor/agents/tutor_agent/tutor_agent.py +++ b/cortex/tutor/agents/tutor_agent/tutor_agent.py @@ -10,7 +10,6 @@ from cortex.tutor.agents.tutor_agent.state import TutorAgentState, create_initial_state from cortex.tutor.branding import console, tutor_print from cortex.tutor.config import DEFAULT_TUTOR_TOPICS -from cortex.tutor.contracts.lesson_context import LessonContext from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool from cortex.tutor.tools.deterministic.validators import ( validate_package_name, @@ -172,6 +171,9 @@ def update_learning_style(self, style: str) -> bool: Returns: True if successful. """ + 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) @@ -187,6 +189,8 @@ def mark_completed(self, package_name: str, topic: str, score: float = 1.0) -> b Returns: True if successful. """ + if not 0.0 <= score <= 1.0: + return False result = self.progress_tool._run( "mark_completed", package_name=package_name, diff --git a/cortex/tutor/tools/agentic/examples_provider.py b/cortex/tutor/tools/agentic/examples_provider.py index f404e67d..03eea7ab 100644 --- a/cortex/tutor/tools/agentic/examples_provider.py +++ b/cortex/tutor/tools/agentic/examples_provider.py @@ -4,7 +4,6 @@ This tool uses LLM (Claude via LangChain) to generate contextual code examples. """ -from pathlib import Path from typing import Any from langchain.tools import BaseTool diff --git a/cortex/tutor/tools/agentic/lesson_generator.py b/cortex/tutor/tools/agentic/lesson_generator.py index b8eb7af5..10086da7 100644 --- a/cortex/tutor/tools/agentic/lesson_generator.py +++ b/cortex/tutor/tools/agentic/lesson_generator.py @@ -15,7 +15,6 @@ from pydantic import Field from cortex.tutor.config import get_config -from cortex.tutor.contracts.lesson_context import CodeExample, LessonContext, TutorialStep # Load prompt template diff --git a/pyproject.toml b/pyproject.toml index 044a4933..a2b4c69c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,6 @@ all = [ [project.scripts] cortex = "cortex.cli:main" -tutor = "cortex.tutor.cli:main" [project.urls] Homepage = "https://github.com/cortexlinux/cortex" From 8ce6277b9a60b89e9e8cee121680f810775a7017 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 16:35:01 +0530 Subject: [PATCH 21/32] refactor: remove LangChain/LangGraph, use existing llm_router - Remove langchain, langchain-anthropic, langgraph dependencies - Delete agentic tools (graph.py, state.py, lesson_generator.py, examples_provider.py, qa_handler.py) - Add cortex/tutor/llm.py using existing cortex.llm_router - Simplify TutorAgent to call llm functions directly - Remove corresponding test files, update remaining tests - Suppress verbose llm_router INFO logs Reduces codebase significantly while maintaining demo functionality. All 158 tests pass, coverage at 74%. --- cortex/tutor/__init__.py | 3 +- cortex/tutor/agents/tutor_agent/__init__.py | 7 +- cortex/tutor/agents/tutor_agent/graph.py | 404 ---------------- cortex/tutor/agents/tutor_agent/state.py | 244 ---------- .../tutor/agents/tutor_agent/tutor_agent.py | 245 ++++------ cortex/tutor/llm.py | 136 ++++++ cortex/tutor/tools/__init__.py | 8 +- cortex/tutor/tools/agentic/__init__.py | 16 - .../tutor/tools/agentic/examples_provider.py | 259 ---------- .../tutor/tools/agentic/lesson_generator.py | 326 ------------- cortex/tutor/tools/agentic/qa_handler.py | 443 ------------------ pyproject.toml | 7 +- tests/tutor/test_agent_methods.py | 440 ----------------- tests/tutor/test_agentic_tools.py | 292 ------------ tests/tutor/test_integration.py | 140 +----- tests/tutor/test_interactive_tutor.py | 1 - tests/tutor/test_tools.py | 200 +------- tests/tutor/test_tutor_agent.py | 320 ------------- 18 files changed, 243 insertions(+), 3248 deletions(-) delete mode 100644 cortex/tutor/agents/tutor_agent/graph.py delete mode 100644 cortex/tutor/agents/tutor_agent/state.py create mode 100644 cortex/tutor/llm.py delete mode 100644 cortex/tutor/tools/agentic/__init__.py delete mode 100644 cortex/tutor/tools/agentic/examples_provider.py delete mode 100644 cortex/tutor/tools/agentic/lesson_generator.py delete mode 100644 cortex/tutor/tools/agentic/qa_handler.py delete mode 100644 tests/tutor/test_agent_methods.py delete mode 100644 tests/tutor/test_agentic_tools.py delete mode 100644 tests/tutor/test_tutor_agent.py diff --git a/cortex/tutor/__init__.py b/cortex/tutor/__init__.py index e6757375..b6e2a521 100644 --- a/cortex/tutor/__init__.py +++ b/cortex/tutor/__init__.py @@ -1,8 +1,7 @@ """ Intelligent Tutor - AI-Powered Installation Tutor for Cortex Linux. -An interactive AI tutor that teaches users about packages and best practices -using LangChain, LangGraph, and Claude API. +An interactive AI tutor that teaches users about packages and best practices. """ __version__ = "0.1.0" diff --git a/cortex/tutor/agents/tutor_agent/__init__.py b/cortex/tutor/agents/tutor_agent/__init__.py index 2e42e858..c3c42fda 100644 --- a/cortex/tutor/agents/tutor_agent/__init__.py +++ b/cortex/tutor/agents/tutor_agent/__init__.py @@ -1,10 +1,7 @@ """ -Tutor Agent - Main LangGraph workflow for interactive tutoring. - -Implements Plan→Act→Reflect pattern for package education. +Tutor Agent - Main orchestrator for interactive tutoring. """ -from cortex.tutor.agents.tutor_agent.state import TutorAgentState from cortex.tutor.agents.tutor_agent.tutor_agent import InteractiveTutor, TutorAgent -__all__ = ["TutorAgent", "TutorAgentState", "InteractiveTutor"] +__all__ = ["TutorAgent", "InteractiveTutor"] diff --git a/cortex/tutor/agents/tutor_agent/graph.py b/cortex/tutor/agents/tutor_agent/graph.py deleted file mode 100644 index c30f60be..00000000 --- a/cortex/tutor/agents/tutor_agent/graph.py +++ /dev/null @@ -1,404 +0,0 @@ -""" -Tutor Agent Graph - LangGraph workflow definition. - -Implements the Plan→Act→Reflect pattern for interactive tutoring. -""" - -from typing import Literal - -from langgraph.graph import END, StateGraph -from langgraph.graph.state import CompiledStateGraph - -from cortex.tutor.agents.tutor_agent.state import ( - TutorAgentState, - add_checkpoint, - add_cost, - add_error, - get_package_name, - get_session_type, - has_critical_error, -) -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool -from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool -from cortex.tutor.tools.deterministic.lesson_loader import LessonLoaderTool -from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool - -# ==================== Node Functions ==================== - - -def plan_node(state: TutorAgentState) -> TutorAgentState: - """ - PLAN Phase: Decide on strategy for handling the request. - - Implements hybrid approach: - 1. Check cache first (deterministic, free) - 2. Use rules for simple requests - 3. Use LLM planner for complex decisions - """ - package_name = get_package_name(state) - session_type = get_session_type(state) - force_fresh = state.get("force_fresh", False) - - add_checkpoint(state, "plan_start", "ok", f"Planning for {package_name}") - - # Load student profile (deterministic) - progress_tool = ProgressTrackerTool() - profile_result = progress_tool._run("get_profile") - if profile_result.get("success"): - state["student_profile"] = profile_result["profile"] - - # Q&A mode - skip cache, go directly to Q&A - if session_type == "qa": - state["plan"] = { - "strategy": "qa_mode", - "cached_data": None, - "estimated_cost": 0.02, - "reasoning": "Q&A session requested, using qa_handler", - } - add_checkpoint(state, "plan_complete", "ok", "Strategy: qa_mode") - return state - - # Check cache (deterministic, free) - if not force_fresh: - loader = LessonLoaderTool() - cache_result = loader._run(package_name) - - if cache_result.get("cache_hit"): - state["plan"] = { - "strategy": "use_cache", - "cached_data": cache_result["lesson"], - "estimated_cost": 0.0, - "reasoning": "Valid cache found, reusing existing lesson", - } - state["cache_hit"] = True - state["cost_saved_gbp"] = 0.02 - add_checkpoint(state, "plan_complete", "ok", "Strategy: use_cache") - return state - - # No cache - need to generate - state["plan"] = { - "strategy": "generate_full", - "cached_data": None, - "estimated_cost": 0.02, - "reasoning": "No valid cache, generating fresh lesson", - } - add_checkpoint(state, "plan_complete", "ok", "Strategy: generate_full") - return state - - -def load_cache_node(state: TutorAgentState) -> TutorAgentState: - """ - ACT Phase - Cache Path: Load lesson from cache. - - This node is reached when plan.strategy == "use_cache". - """ - cached_data = state.get("plan", {}).get("cached_data", {}) - - if cached_data: - state["lesson_content"] = cached_data - state["results"] = { - "type": "lesson", - "content": cached_data, - "source": "cache", - } - add_checkpoint(state, "cache_load", "ok", "Loaded lesson from cache") - else: - add_error(state, "load_cache", "Cache data missing", recoverable=True) - - return state - - -def _infer_student_level(profile: dict) -> str: - """Infer student level from profile based on mastered concepts.""" - mastered = profile.get("mastered_concepts", []) - mastered_count = len(mastered) - - if mastered_count >= 10: - return "advanced" - elif mastered_count >= 5: - return "intermediate" - return "beginner" - - -def generate_lesson_node(state: TutorAgentState) -> TutorAgentState: - """ - ACT Phase - Generation Path: Generate new lesson content. - - Uses LessonGeneratorTool to create comprehensive lesson. - """ - package_name = get_package_name(state) - profile = state.get("student_profile", {}) - - add_checkpoint(state, "generate_start", "ok", f"Generating lesson for {package_name}") - - # Determine student level dynamically from profile - student_level = profile.get("student_level") or _infer_student_level(profile) - - try: - generator = LessonGeneratorTool() - result = generator._run( - package_name=package_name, - student_level=student_level, - learning_style=profile.get("learning_style", "reading"), - skip_areas=profile.get("mastered_concepts", []), - ) - - if result.get("success"): - state["lesson_content"] = result["lesson"] - state["results"] = { - "type": "lesson", - "content": result["lesson"], - "source": "generated", - } - add_cost(state, result.get("cost_gbp", 0.02)) - - # Cache the generated lesson - loader = LessonLoaderTool() - loader.cache_lesson(package_name, result["lesson"]) - - add_checkpoint(state, "generate_complete", "ok", "Lesson generated and cached") - else: - add_error(state, "generate_lesson", result.get("error", "Unknown error")) - add_checkpoint(state, "generate_complete", "error", "Generation failed") - - except Exception as e: - add_error(state, "generate_lesson", str(e)) - add_checkpoint(state, "generate_complete", "error", str(e)) - - return state - - -def qa_node(state: TutorAgentState) -> TutorAgentState: - """ - ACT Phase - Q&A Path: Handle user questions. - - Uses QAHandlerTool for free-form questions. - """ - input_data = state.get("input", {}) - question = input_data.get("question", "") - package_name = get_package_name(state) - profile = state.get("student_profile", {}) - - if not question: - add_error(state, "qa", "No question provided", recoverable=False) - return state - - add_checkpoint(state, "qa_start", "ok", f"Answering question about {package_name}") - - try: - qa_handler = QAHandlerTool() - result = qa_handler._run( - package_name=package_name, - question=question, - learning_style=profile.get("learning_style", "reading"), - mastered_concepts=profile.get("mastered_concepts", []), - weak_concepts=profile.get("weak_concepts", []), - ) - - if result.get("success"): - state["qa_result"] = result["answer"] - state["results"] = { - "type": "qa", - "content": result["answer"], - "source": "generated", - } - add_cost(state, result.get("cost_gbp", 0.02)) - add_checkpoint(state, "qa_complete", "ok", "Question answered") - else: - add_error(state, "qa", result.get("error", "Unknown error")) - - except Exception as e: - add_error(state, "qa", str(e)) - - return state - - -def reflect_node(state: TutorAgentState) -> TutorAgentState: - """ - REFLECT Phase: Validate results and prepare output. - - 1. Deterministic validation (free) - 2. Prepare final output - """ - add_checkpoint(state, "reflect_start", "ok", "Validating results") - - results = state.get("results", {}) - errors = state.get("errors", []) - - # Deterministic validation - validation_errors = [] - - # Check for content - if not results.get("content"): - validation_errors.append("No content generated") - - # Check for critical errors - if has_critical_error(state): - validation_errors.append("Critical errors occurred during processing") - - # Calculate confidence - confidence = 1.0 - if errors: - confidence -= 0.1 * len(errors) - if state.get("cache_hit"): - confidence = min(confidence, 0.95) # Cached content might be stale - - # Prepare output - content = results.get("content", {}) - output = { - "type": results.get("type", "unknown"), - "package_name": get_package_name(state), - "content": content, - "source": results.get("source", "unknown"), - "confidence": max(confidence, 0.0), - "cost_gbp": state.get("cost_gbp", 0.0), - "cost_saved_gbp": state.get("cost_saved_gbp", 0.0), - "cache_hit": state.get("cache_hit", False), - "validation_passed": len(validation_errors) == 0, - "validation_errors": validation_errors, - "checkpoints": state.get("checkpoints", []), - } - - state["output"] = output - add_checkpoint(state, "reflect_complete", "ok", f"Validation: {len(validation_errors)} errors") - - return state - - -def fail_node(state: TutorAgentState) -> TutorAgentState: - """ - Failure node: Handle unrecoverable errors. - """ - errors = state.get("errors", []) - error_messages = [e.get("error", "Unknown") for e in errors] - - state["output"] = { - "type": "error", - "package_name": get_package_name(state), - "content": None, - "source": "failed", - "confidence": 0.0, - "cost_gbp": state.get("cost_gbp", 0.0), - "cost_saved_gbp": 0.0, - "cache_hit": False, - "validation_passed": False, - "validation_errors": error_messages, - "checkpoints": state.get("checkpoints", []), - } - - return state - - -# ==================== Routing Functions ==================== - - -def route_after_plan( - state: TutorAgentState, -) -> Literal["load_cache", "generate_lesson", "qa", "fail"]: - """ - Route after PLAN phase based on strategy. - """ - if has_critical_error(state): - return "fail" - - strategy = state.get("plan", {}).get("strategy", "generate_full") - - if strategy == "use_cache": - return "load_cache" - elif strategy == "qa_mode": - return "qa" - else: - return "generate_lesson" - - -def route_after_act(state: TutorAgentState) -> Literal["reflect", "fail"]: - """ - Route after ACT phase. - """ - if has_critical_error(state): - return "fail" - - # Check if we have results - if not state.get("results"): - return "fail" - - return "reflect" - - -# ==================== Graph Builder ==================== - - -def create_tutor_graph() -> CompiledStateGraph: - """ - Create the LangGraph workflow for the Tutor Agent. - - Returns: - Compiled StateGraph ready for execution. - """ - # Create graph with state schema - graph = StateGraph(TutorAgentState) - - # Add nodes - graph.add_node("plan", plan_node) - graph.add_node("load_cache", load_cache_node) - graph.add_node("generate_lesson", generate_lesson_node) - graph.add_node("qa", qa_node) - graph.add_node("reflect", reflect_node) - graph.add_node("fail", fail_node) - - # Set entry point - graph.set_entry_point("plan") - - # Add conditional edges after PLAN - graph.add_conditional_edges( - "plan", - route_after_plan, - { - "load_cache": "load_cache", - "generate_lesson": "generate_lesson", - "qa": "qa", - "fail": "fail", - }, - ) - - # Add edges from ACT nodes to REFLECT - graph.add_conditional_edges( - "load_cache", - route_after_act, - {"reflect": "reflect", "fail": "fail"}, - ) - - graph.add_conditional_edges( - "generate_lesson", - route_after_act, - {"reflect": "reflect", "fail": "fail"}, - ) - - graph.add_conditional_edges( - "qa", - route_after_act, - {"reflect": "reflect", "fail": "fail"}, - ) - - # End edges - graph.add_edge("reflect", END) - graph.add_edge("fail", END) - - return graph.compile() - - -# Create singleton graph instance -_graph: CompiledStateGraph | None = None - - -def get_tutor_graph() -> CompiledStateGraph: - """ - Get the singleton Tutor Agent graph. - - Returns: - Compiled StateGraph. - """ - global _graph - if _graph is None: - _graph = create_tutor_graph() - return _graph diff --git a/cortex/tutor/agents/tutor_agent/state.py b/cortex/tutor/agents/tutor_agent/state.py deleted file mode 100644 index b437d1be..00000000 --- a/cortex/tutor/agents/tutor_agent/state.py +++ /dev/null @@ -1,244 +0,0 @@ -""" -Tutor Agent State - TypedDict for LangGraph workflow state. - -Defines the state schema that flows through the Plan→Act→Reflect workflow. -""" - -from typing import Any, TypedDict - - -class StudentProfileState(TypedDict, total=False): - """Student profile state within the agent.""" - - learning_style: str - mastered_concepts: list[str] - weak_concepts: list[str] - last_session: str | None - - -class LessonContentState(TypedDict, total=False): - """Lesson content state.""" - - package_name: str - summary: str - explanation: str - use_cases: list[str] - best_practices: list[str] - code_examples: list[dict[str, Any]] - tutorial_steps: list[dict[str, Any]] - installation_command: str - confidence: float - - -class PlanState(TypedDict, total=False): - """Plan phase output state.""" - - strategy: str # "use_cache", "generate_full", "generate_quick", "qa_mode" - cached_data: dict[str, Any] | None - estimated_cost: float - reasoning: str - - -class ErrorState(TypedDict): - """Error entry in state.""" - - node: str - error: str - recoverable: bool - - -class TutorAgentState(TypedDict, total=False): - """ - Complete state for the Tutor Agent workflow. - - This state flows through all nodes in the LangGraph: - Plan → Act → Reflect → Output - - Attributes: - input: User input and request parameters - force_fresh: Skip cache and generate fresh content - plan: Output from the PLAN phase - student_profile: Student's learning profile - lesson_content: Generated or cached lesson content - qa_result: Result from Q&A if in qa_mode - results: Combined results from ACT phase - errors: List of errors encountered - checkpoints: Monitoring checkpoints - cost_gbp: Total cost accumulated - cache_hit: Whether cache was used - replan_count: Number of replanning attempts - output: Final output to return - """ - - # Input - input: dict[str, Any] - force_fresh: bool - - # PLAN phase - plan: PlanState - - # Context - student_profile: StudentProfileState - - # ACT phase outputs - lesson_content: LessonContentState - qa_result: dict[str, Any] | None - examples_result: dict[str, Any] | None - - # Combined results - results: dict[str, Any] - - # Errors and monitoring - errors: list[ErrorState] - checkpoints: list[dict[str, Any]] - - # Costs - cost_gbp: float - cost_saved_gbp: float - - # Flags - cache_hit: bool - replan_count: int - - # Final output - output: dict[str, Any] | None - - -def create_initial_state( - package_name: str, - session_type: str = "lesson", - question: str | None = None, - force_fresh: bool = False, -) -> TutorAgentState: - """ - Create initial state for a tutor session. - - Args: - package_name: Package to teach. - session_type: Type of session (lesson, qa, tutorial, quiz). - question: User question for Q&A mode. - force_fresh: Skip cache. - - Returns: - Initial TutorAgentState. - """ - return TutorAgentState( - input={ - "package_name": package_name, - "session_type": session_type, - "question": question, - }, - force_fresh=force_fresh, - plan={}, - student_profile={ - "learning_style": "reading", - "mastered_concepts": [], - "weak_concepts": [], - "last_session": None, - }, - lesson_content={}, - qa_result=None, - examples_result=None, - results={}, - errors=[], - checkpoints=[], - cost_gbp=0.0, - cost_saved_gbp=0.0, - cache_hit=False, - replan_count=0, - output=None, - ) - - -def add_error(state: TutorAgentState, node: str, error: str, recoverable: bool = True) -> None: - """ - Add an error to the state. - - Args: - state: Current state. - node: Node where error occurred. - error: Error message. - recoverable: Whether error is recoverable. - """ - if "errors" not in state: - state["errors"] = [] - state["errors"].append( - { - "node": node, - "error": error, - "recoverable": recoverable, - } - ) - - -def add_checkpoint(state: TutorAgentState, name: str, status: str, details: str = "") -> None: - """ - Add a monitoring checkpoint to the state. - - Args: - state: Current state. - name: Checkpoint name. - status: Status (ok, warning, error). - details: Additional details. - """ - if "checkpoints" not in state: - state["checkpoints"] = [] - state["checkpoints"].append( - { - "name": name, - "status": status, - "details": details, - } - ) - - -def add_cost(state: TutorAgentState, cost: float) -> None: - """ - Add cost to the state. - - Args: - state: Current state. - cost: Cost in GBP to add. - """ - current = state.get("cost_gbp", 0.0) - state["cost_gbp"] = current + cost - - -def has_critical_error(state: TutorAgentState) -> bool: - """ - Check if state has any non-recoverable errors. - - Args: - state: Current state. - - Returns: - True if there are critical errors. - """ - errors = state.get("errors", []) - return any(not e.get("recoverable", True) for e in errors) - - -def get_session_type(state: TutorAgentState) -> str: - """ - Get the session type from state. - - Args: - state: Current state. - - Returns: - Session type string. - """ - return state.get("input", {}).get("session_type", "lesson") - - -def get_package_name(state: TutorAgentState) -> str: - """ - Get the package name from state. - - Args: - state: Current state. - - Returns: - Package name string. - """ - return state.get("input", {}).get("package_name", "") diff --git a/cortex/tutor/agents/tutor_agent/tutor_agent.py b/cortex/tutor/agents/tutor_agent/tutor_agent.py index 04ec9438..a985d939 100644 --- a/cortex/tutor/agents/tutor_agent/tutor_agent.py +++ b/cortex/tutor/agents/tutor_agent/tutor_agent.py @@ -1,15 +1,15 @@ """ Tutor Agent - Main orchestrator for interactive tutoring. -Provides high-level interface for the Plan→Act→Reflect workflow. +Simplified implementation using cortex.llm_router directly. """ from typing import Any -from cortex.tutor.agents.tutor_agent.graph import get_tutor_graph -from cortex.tutor.agents.tutor_agent.state import TutorAgentState, create_initial_state 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, @@ -19,30 +19,19 @@ class TutorAgent: """ - Main Tutor Agent class for interactive package education. - - Implements the Plan→Act→Reflect pattern using LangGraph for - comprehensive, adaptive tutoring sessions. + Main Tutor Agent for interactive package education. Example: >>> agent = TutorAgent() >>> result = agent.teach("docker") - >>> print(result.summary) - - >>> answer = agent.ask("docker", "What's the difference between images and containers?") - >>> print(answer["answer"]) + >>> print(result.get("summary")) """ def __init__(self, verbose: bool = False) -> None: - """ - Initialize the Tutor Agent. - - Args: - verbose: Enable verbose output for debugging. - """ + """Initialize the Tutor Agent.""" self.verbose = verbose - self.graph = get_tutor_graph() self.progress_tool = ProgressTrackerTool() + self.loader = LessonLoaderTool() def teach( self, @@ -58,11 +47,7 @@ def teach( Returns: Dict containing lesson content and metadata. - - Raises: - ValueError: If package name is invalid. """ - # Validate input is_valid, error = validate_package_name(package_name) if not is_valid: raise ValueError(f"Invalid package name: {error}") @@ -70,28 +55,63 @@ def teach( if self.verbose: tutor_print(f"Starting lesson for {package_name}...", "tutor") - # Create initial state - state = create_initial_state( + # 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, - session_type="lesson", - force_fresh=force_fresh, + student_level=profile.get("student_level", "beginner"), + learning_style=profile.get("learning_style", "reading"), + skip_areas=profile.get("mastered_concepts", []), ) - # Execute workflow - result = self.graph.invoke(state) + if not result.get("success"): + return { + "type": "error", + "content": None, + "source": "failed", + "validation_passed": False, + "validation_errors": [result.get("error", "Unknown error")], + } - # Update progress - if result.get("output", {}).get("validation_passed"): - self.progress_tool._run( - "update_progress", - package_name=package_name, - topic="overview", - ) + # Cache the lesson + lesson = result["lesson"] + self.loader.cache_lesson(package_name, lesson) - if self.verbose: - self._print_execution_summary(result) + # Update progress + self.progress_tool._run( + "update_progress", + package_name=package_name, + topic="overview", + ) - return result.get("output", {}) + return { + "type": "lesson", + "content": lesson, + "source": "generated", + "cache_hit": False, + "cost_usd": result.get("cost_usd", 0.0), + "validation_passed": True, + } def ask( self, @@ -107,11 +127,7 @@ def ask( Returns: Dict containing the answer and related info. - - Raises: - ValueError: If inputs are invalid. """ - # Validate inputs is_valid, error = validate_package_name(package_name) if not is_valid: raise ValueError(f"Invalid package name: {error}") @@ -123,54 +139,41 @@ def ask( if self.verbose: tutor_print(f"Answering question about {package_name}...", "tutor") - # Create initial state for Q&A - state = create_initial_state( - package_name=package_name, - session_type="qa", - question=question, - ) - - # Execute workflow - result = self.graph.invoke(state) - - if self.verbose: - self._print_execution_summary(result) - - return result.get("output", {}) + 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. - - Args: - package_name: Optional package to filter by. - - Returns: - Dict containing progress data. - """ + """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. - - Returns: - Dict containing student profile data. - """ + """Get student profile.""" return self.progress_tool._run("get_profile") def update_learning_style(self, style: str) -> bool: - """ - Update preferred learning style. - - Args: - style: Learning style (visual, reading, hands-on). - - Returns: - True if successful. - """ + """Update preferred learning style.""" valid_styles = {"visual", "reading", "hands-on"} if style not in valid_styles: return False @@ -178,17 +181,7 @@ def update_learning_style(self, style: str) -> bool: return result.get("success", False) def mark_completed(self, package_name: str, topic: str, score: float = 1.0) -> bool: - """ - Mark a topic as completed. - - Args: - package_name: Package name. - topic: Topic that was completed. - score: Score achieved (0.0 to 1.0). - - Returns: - True if successful. - """ + """Mark a topic as completed.""" if not 0.0 <= score <= 1.0: return False result = self.progress_tool._run( @@ -200,69 +193,25 @@ def mark_completed(self, package_name: str, topic: str, score: float = 1.0) -> b return result.get("success", False) def reset_progress(self, package_name: str | None = None) -> int: - """ - Reset learning progress. - - Args: - package_name: Optional package to reset. If None, resets all. - - Returns: - Number of records reset. - """ + """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. - - Returns: - List of package names. - """ + """Get list of packages that have been studied.""" result = self.progress_tool._run("get_packages") return result.get("packages", []) if result.get("success") else [] - def _print_execution_summary(self, result: dict[str, Any]) -> None: - """Print execution summary for verbose mode.""" - output = result.get("output", {}) - - console.print("\n[dim]--- Execution Summary ---[/dim]") - console.print(f"[dim]Type: {output.get('type', 'unknown')}[/dim]") - console.print(f"[dim]Source: {output.get('source', 'unknown')}[/dim]") - console.print(f"[dim]Cache hit: {output.get('cache_hit', False)}[/dim]") - console.print(f"[dim]Cost: \u00a3{output.get('cost_gbp', 0):.4f}[/dim]") - console.print(f"[dim]Saved: \u00a3{output.get('cost_saved_gbp', 0):.4f}[/dim]") - console.print(f"[dim]Confidence: {output.get('confidence', 0):.0%}[/dim]") - console.print( - f"[dim]Validation: {'passed' if output.get('validation_passed') else 'failed'}[/dim]" - ) - - if output.get("validation_errors"): - console.print("[dim]Errors:[/dim]") - for err in output["validation_errors"]: - console.print(f"[dim] - {err}[/dim]") - class InteractiveTutor: - """ - Interactive tutoring session manager. - - Provides a menu-driven interface for learning packages. - """ + """Interactive tutoring session manager.""" def __init__(self, package_name: str, force_fresh: bool = False) -> None: - """ - Initialize interactive tutor for a package. - - Args: - package_name: Package to learn. - force_fresh: Skip cache and generate fresh content. - """ + """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 - self.current_step = 0 def start(self) -> None: """Start the interactive tutoring session.""" @@ -273,10 +222,10 @@ def start(self) -> None: print_lesson_header, print_markdown, print_menu, + print_progress_summary, print_tutorial_step, ) - # Load lesson tutor_print(f"Loading lesson for {self.package_name}...", "tutor") result = self.agent.teach(self.package_name, force_fresh=self.force_fresh) @@ -286,11 +235,8 @@ def start(self) -> None: self.lesson = result.get("content", {}) print_lesson_header(self.package_name) - - # Print summary console.print(f"\n{self.lesson.get('summary', '')}\n") - # Main menu loop while True: print_menu( [ @@ -333,14 +279,12 @@ def start(self) -> None: tutor_print("Invalid option", "warning") def _show_concepts(self) -> None: - """Show basic concepts/explanation.""" + """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}") - - # Mark as viewed self.agent.mark_completed(self.package_name, "concepts", 0.5) def _show_examples(self) -> None: @@ -387,9 +331,6 @@ def _run_tutorial(self) -> None: if step.get("code"): console.print(f"\n[cyan]Code:[/cyan] {step['code']}") - if step.get("expected_output"): - console.print(f"[dim]Expected: {step['expected_output']}[/dim]") - response = get_user_input("Press Enter to continue (or 'q' to quit)") if response.lower() == "q": break @@ -423,7 +364,11 @@ def _ask_question(self) -> None: return tutor_print("Thinking...", "info") - result = self.agent.ask(self.package_name, question) + 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", {}) 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/tools/__init__.py b/cortex/tutor/tools/__init__.py index 6a62ff27..16dccc07 100644 --- a/cortex/tutor/tools/__init__.py +++ b/cortex/tutor/tools/__init__.py @@ -1,12 +1,9 @@ """ Tools for Intelligent Tutor. -Provides deterministic and agentic tools for the tutoring workflow. +Provides deterministic tools for the tutoring workflow. """ -from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool -from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool from cortex.tutor.tools.deterministic.progress_tracker import ProgressTrackerTool from cortex.tutor.tools.deterministic.validators import validate_input, validate_package_name @@ -14,7 +11,4 @@ "ProgressTrackerTool", "validate_package_name", "validate_input", - "LessonGeneratorTool", - "ExamplesProviderTool", - "QAHandlerTool", ] diff --git a/cortex/tutor/tools/agentic/__init__.py b/cortex/tutor/tools/agentic/__init__.py deleted file mode 100644 index 3d02b819..00000000 --- a/cortex/tutor/tools/agentic/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Agentic tools for Intelligent Tutor. - -These tools use LLM calls for tasks requiring judgment and creativity. -Used for: lesson generation, code examples, Q&A handling. -""" - -from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool -from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - -__all__ = [ - "LessonGeneratorTool", - "ExamplesProviderTool", - "QAHandlerTool", -] diff --git a/cortex/tutor/tools/agentic/examples_provider.py b/cortex/tutor/tools/agentic/examples_provider.py deleted file mode 100644 index 03eea7ab..00000000 --- a/cortex/tutor/tools/agentic/examples_provider.py +++ /dev/null @@ -1,259 +0,0 @@ -""" -Examples Provider Tool - Agentic tool for generating code examples. - -This tool uses LLM (Claude via LangChain) to generate contextual code examples. -""" - -from typing import Any - -from langchain.tools import BaseTool -from langchain_anthropic import ChatAnthropic -from langchain_core.output_parsers import JsonOutputParser -from langchain_core.prompts import ChatPromptTemplate -from pydantic import Field - -from cortex.tutor.config import get_config - - -class ExamplesProviderTool(BaseTool): - """ - Agentic tool for generating code examples using LLM. - - Generates contextual, educational code examples for specific - package features and topics. - - Cost: ~$0.01 per generation - """ - - name: str = "examples_provider" - description: str = ( - "Generate contextual code examples for a package topic. " - "Use this when the user wants to see practical code demonstrations. " - "Returns examples with progressive complexity." - ) - - llm: ChatAnthropic | None = Field(default=None, exclude=True) - model_name: str | None = Field(default=None, description="Model name, defaults to config.model") - - class Config: - arbitrary_types_allowed = True - - def __init__(self, model_name: str | None = None) -> None: - """ - Initialize the examples provider tool. - - Args: - model_name: LLM model to use. - """ - super().__init__() - config = get_config() - self.model_name = model_name or config.model - self.llm = ChatAnthropic( - model=self.model_name, - api_key=config.anthropic_api_key, - temperature=0, - max_tokens=2048, - ) - - def _run( - self, - package_name: str, - topic: str, - difficulty: str = "beginner", - learning_style: str = "hands-on", - existing_knowledge: list[str] | None = None, - ) -> dict[str, Any]: - """ - Generate code examples for a package topic. - - Args: - package_name: Name of the package. - topic: Specific topic or feature to demonstrate. - difficulty: Example difficulty level. - learning_style: User's learning style. - existing_knowledge: Concepts user already knows. - - Returns: - Dict containing generated examples. - """ - try: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", self._get_system_prompt()), - ("human", self._get_generation_prompt()), - ] - ) - - chain = prompt | self.llm | JsonOutputParser() - - result = chain.invoke( - { - "package_name": package_name, - "topic": topic, - "difficulty": difficulty, - "learning_style": learning_style, - "existing_knowledge": ", ".join(existing_knowledge or []) or "basics", - } - ) - - examples = self._structure_response(result, package_name, topic) - - return { - "success": True, - "examples": examples, - "cost_gbp": 0.01, - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "examples": None, - } - - async def _arun( - self, - package_name: str, - topic: str, - difficulty: str = "beginner", - learning_style: str = "hands-on", - existing_knowledge: list[str] | None = None, - ) -> dict[str, Any]: - """Async version of example generation.""" - try: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", self._get_system_prompt()), - ("human", self._get_generation_prompt()), - ] - ) - - chain = prompt | self.llm | JsonOutputParser() - - result = await chain.ainvoke( - { - "package_name": package_name, - "topic": topic, - "difficulty": difficulty, - "learning_style": learning_style, - "existing_knowledge": ", ".join(existing_knowledge or []) or "basics", - } - ) - - examples = self._structure_response(result, package_name, topic) - - return { - "success": True, - "examples": examples, - "cost_gbp": 0.01, - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "examples": None, - } - - def _get_system_prompt(self) -> str: - """Get the system prompt for example generation.""" - return """You are an expert code example generator for educational purposes. - -CRITICAL RULES: -1. NEVER invent command flags that don't exist -2. NEVER generate fake output - use realistic but generic examples -3. NEVER include real credentials - use placeholders like 'your_api_key' -4. Flag potentially dangerous commands with warnings -5. Keep examples focused, practical, and safe to run - -Your examples should: -- Progress from simple to complex -- Include clear explanations -- Be safe and non-destructive -- Match the specified difficulty level""" - - def _get_generation_prompt(self) -> str: - """Get the generation prompt template.""" - return """Generate code examples for: {package_name} -Topic: {topic} -Difficulty: {difficulty} -Learning style: {learning_style} -User already knows: {existing_knowledge} - -Return a JSON object with this structure: -{{ - "package_name": "{package_name}", - "topic": "{topic}", - "examples": [ - {{ - "title": "Example Title", - "difficulty": "beginner", - "code": "actual code here", - "language": "bash", - "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.9 -}} - -Generate 2-4 examples with progressive complexity. -Ensure all examples are safe and educational.""" - - def _structure_response( - self, response: dict[str, Any], package_name: str, topic: str - ) -> dict[str, Any]: - """Structure and validate the LLM response.""" - structured = { - "package_name": response.get("package_name", package_name), - "topic": response.get("topic", topic), - "examples": [], - "tips": response.get("tips", [])[:5], - "common_mistakes": response.get("common_mistakes", [])[:5], - "confidence": min(max(response.get("confidence", 0.8), 0.0), 1.0), - } - - for ex in response.get("examples", [])[:4]: - if isinstance(ex, dict) and ex.get("code"): - structured["examples"].append( - { - "title": ex.get("title", "Example"), - "difficulty": ex.get("difficulty", "beginner"), - "code": ex.get("code", ""), - "language": ex.get("language", "bash"), - "description": ex.get("description", ""), - "expected_output": ex.get("expected_output"), - "warnings": ex.get("warnings", []), - "prerequisites": ex.get("prerequisites", []), - } - ) - - return structured - - -def generate_examples( - package_name: str, - topic: str, - difficulty: str = "beginner", -) -> dict[str, Any]: - """ - Convenience function to generate code examples. - - Args: - package_name: Package name. - topic: Topic to demonstrate. - difficulty: Example difficulty. - - Returns: - Generated examples dictionary. - """ - tool = ExamplesProviderTool() - return tool._run( - package_name=package_name, - topic=topic, - difficulty=difficulty, - ) diff --git a/cortex/tutor/tools/agentic/lesson_generator.py b/cortex/tutor/tools/agentic/lesson_generator.py deleted file mode 100644 index 10086da7..00000000 --- a/cortex/tutor/tools/agentic/lesson_generator.py +++ /dev/null @@ -1,326 +0,0 @@ -""" -Lesson Generator Tool - Agentic tool for generating educational content. - -This tool uses LLM (Claude via LangChain) to generate comprehensive lessons. -It is used when no cached lesson is available. -""" - -from pathlib import Path -from typing import Any - -from langchain.tools import BaseTool -from langchain_anthropic import ChatAnthropic -from langchain_core.output_parsers import JsonOutputParser -from langchain_core.prompts import ChatPromptTemplate -from pydantic import Field - -from cortex.tutor.config import get_config - - -# Load prompt template -def _load_prompt_template() -> str: - """Load the lesson generator prompt from file.""" - prompt_path = Path(__file__).parent.parent.parent / "prompts" / "tools" / "lesson_generator.md" - if prompt_path.exists(): - return prompt_path.read_text() - # Fallback inline prompt - return """You are a lesson content generator. Generate comprehensive educational content - for the package: {package_name} - - Student level: {student_level} - Learning style: {learning_style} - Focus areas: {focus_areas} - - Return a JSON object with: summary, explanation, use_cases, best_practices, - code_examples, tutorial_steps, installation_command, confidence.""" - - -class LessonGeneratorTool(BaseTool): - """ - Agentic tool for generating lesson content using LLM. - - This tool generates comprehensive lessons including: - - Package explanations - - Best practices - - Code examples - - Step-by-step tutorials - - Cost: ~$0.02 per generation - """ - - name: str = "lesson_generator" - description: str = ( - "Generate comprehensive lesson content for a package using AI. " - "Use this when no cached lesson exists. " - "Returns structured lesson with explanations, examples, and tutorials." - ) - - llm: ChatAnthropic | None = Field(default=None, exclude=True) - model_name: str | None = Field(default=None, description="Model name, defaults to config.model") - - class Config: - arbitrary_types_allowed = True - - def __init__(self, model_name: str | None = None) -> None: - """ - Initialize the lesson generator tool. - - Args: - model_name: LLM model to use. Uses config default if not provided. - """ - super().__init__() - config = get_config() - self.model_name = model_name or config.model - self.llm = ChatAnthropic( - model=self.model_name, - api_key=config.anthropic_api_key, - temperature=0, - max_tokens=4096, - ) - - def _run( - self, - package_name: str, - student_level: str = "beginner", - learning_style: str = "reading", - focus_areas: list[str] | None = None, - skip_areas: list[str] | None = None, - ) -> dict[str, Any]: - """ - Generate lesson content for a package. - - Args: - package_name: Name of the package to generate lesson for. - student_level: Student level (beginner, intermediate, advanced). - learning_style: Learning style (visual, reading, hands-on). - focus_areas: Specific topics to emphasize. - skip_areas: Topics already mastered to skip. - - Returns: - Dict containing generated lesson content. - """ - try: - # Build the prompt - prompt = ChatPromptTemplate.from_messages( - [ - ("system", self._get_system_prompt()), - ("human", self._get_generation_prompt()), - ] - ) - - # Create the chain - chain = prompt | self.llm | JsonOutputParser() - - # Generate lesson - result = chain.invoke( - { - "package_name": package_name, - "student_level": student_level, - "learning_style": learning_style, - "focus_areas": ", ".join(focus_areas or []) or "all topics", - "skip_areas": ", ".join(skip_areas or []) or "none", - } - ) - - # Validate and structure the response - lesson = self._structure_response(result, package_name) - - return { - "success": True, - "lesson": lesson, - "cost_gbp": 0.02, # Estimated cost - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "lesson": None, - } - - async def _arun( - self, - package_name: str, - student_level: str = "beginner", - learning_style: str = "reading", - focus_areas: list[str] | None = None, - skip_areas: list[str] | None = None, - ) -> dict[str, Any]: - """Async version of lesson generation.""" - try: - prompt = ChatPromptTemplate.from_messages( - [ - ("system", self._get_system_prompt()), - ("human", self._get_generation_prompt()), - ] - ) - - chain = prompt | self.llm | JsonOutputParser() - - result = await chain.ainvoke( - { - "package_name": package_name, - "student_level": student_level, - "learning_style": learning_style, - "focus_areas": ", ".join(focus_areas or []) or "all topics", - "skip_areas": ", ".join(skip_areas or []) or "none", - } - ) - - lesson = self._structure_response(result, package_name) - - return { - "success": True, - "lesson": lesson, - "cost_gbp": 0.02, - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "lesson": None, - } - - def _get_system_prompt(self) -> str: - """Get the system prompt for lesson generation.""" - return """You are an expert educational content creator specializing in software packages and tools. -Your role is to create comprehensive, accurate, and engaging lessons. - -CRITICAL RULES: -1. NEVER invent features that don't exist in the package -2. NEVER fabricate URLs - suggest "official documentation" instead -3. NEVER claim specific version features unless certain -4. Express confidence levels honestly -5. Focus on stable, well-documented functionality - -Your lessons should be: -- Clear and accessible to the specified student level -- Practical with real-world examples -- Progressive in complexity -- Safe to follow (no destructive commands without warnings)""" - - def _get_generation_prompt(self) -> str: - """Get the generation prompt template.""" - return """Generate a comprehensive lesson for: {package_name} - -Student Level: {student_level} -Learning Style: {learning_style} -Focus Areas: {focus_areas} -Skip Areas: {skip_areas} - -Return a JSON object with this exact structure: -{{ - "package_name": "{package_name}", - "summary": "1-2 sentence overview", - "explanation": "Detailed explanation of what the package does and why it's useful", - "use_cases": ["use case 1", "use case 2", "use case 3", "use case 4"], - "best_practices": ["practice 1", "practice 2", "practice 3", "practice 4", "practice 5"], - "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", - "expected_output": "optional expected output" - }} - ], - "installation_command": "apt install package or pip install package", - "related_packages": ["related1", "related2"], - "confidence": 0.9 -}} - -Ensure: -- Summary is concise (max 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 -- Confidence reflects your actual certainty (0.5-1.0)""" - - def _structure_response(self, response: dict[str, Any], package_name: str) -> dict[str, Any]: - """ - Structure and validate the LLM response. - - Args: - response: Raw LLM response. - package_name: Package name for validation. - - Returns: - Structured lesson dictionary. - """ - # Ensure required fields with defaults - structured = { - "package_name": response.get("package_name", package_name), - "summary": response.get("summary", f"A lesson about {package_name}"), - "explanation": response.get("explanation", ""), - "use_cases": response.get("use_cases", [])[:5], - "best_practices": response.get("best_practices", [])[:7], - "code_examples": [], - "tutorial_steps": [], - "installation_command": response.get( - "installation_command", f"apt install {package_name}" - ), - "related_packages": response.get("related_packages", [])[:5], - "confidence": min(max(response.get("confidence", 0.8), 0.0), 1.0), - } - - # Structure code examples - for ex in response.get("code_examples", [])[:5]: - if isinstance(ex, dict) and ex.get("code"): - structured["code_examples"].append( - { - "title": ex.get("title", "Example"), - "code": ex.get("code", ""), - "language": ex.get("language", "bash"), - "description": ex.get("description", ""), - } - ) - - # Structure tutorial steps - for i, step in enumerate(response.get("tutorial_steps", [])[:10], 1): - if isinstance(step, dict): - structured["tutorial_steps"].append( - { - "step_number": step.get("step_number", i), - "title": step.get("title", f"Step {i}"), - "content": step.get("content", ""), - "code": step.get("code"), - "expected_output": step.get("expected_output"), - } - ) - - return structured - - -def generate_lesson( - package_name: str, - student_level: str = "beginner", - learning_style: str = "reading", -) -> dict[str, Any]: - """ - Convenience function to generate a lesson. - - Args: - package_name: Package to generate lesson for. - student_level: Student level. - learning_style: Preferred learning style. - - Returns: - Generated lesson dictionary. - """ - tool = LessonGeneratorTool() - return tool._run( - package_name=package_name, - student_level=student_level, - learning_style=learning_style, - ) diff --git a/cortex/tutor/tools/agentic/qa_handler.py b/cortex/tutor/tools/agentic/qa_handler.py deleted file mode 100644 index aa98d51f..00000000 --- a/cortex/tutor/tools/agentic/qa_handler.py +++ /dev/null @@ -1,443 +0,0 @@ -""" -Q&A Handler Tool - Agentic tool for handling user questions. - -This tool uses LLM (Claude via LangChain) to answer questions about packages. -""" - -from typing import TYPE_CHECKING, Any - -from langchain.tools import BaseTool -from langchain_core.output_parsers import JsonOutputParser -from langchain_core.prompts import ChatPromptTemplate -from pydantic import Field - -from cortex.tutor.config import get_config -from cortex.tutor.tools.deterministic.validators import ( - validate_package_name, - validate_question, -) - -if TYPE_CHECKING: - from langchain_anthropic import ChatAnthropic - - -class QAHandlerTool(BaseTool): - """ - Agentic tool for handling Q&A using LLM. - - Answers user questions about packages in an educational context, - building on their existing knowledge. - - Cost: ~£0.02 per question - """ - - name: str = "qa_handler" - description: str = ( - "Answer user questions about a package. " - "Use this for free-form Q&A outside the structured lesson flow. " - "Provides contextual answers based on student profile." - ) - - # Use Any type for llm field to avoid Pydantic forward reference issues - # The actual type is ChatAnthropic, but it's lazily initialized - llm: Any = Field(default=None, exclude=True) - model_name: str | None = Field(default=None, description="Model name, defaults to config.model") - - # Constants for default values - _NONE_SPECIFIED: str = "none specified" - - class Config: - arbitrary_types_allowed = True - - def model_post_init(self, __context: Any) -> None: - """Post-init hook (Pydantic v2 pattern).""" - if self.model_name is None: - config = get_config() - self.model_name = config.model - - def _get_llm(self) -> "ChatAnthropic": - """Lazily initialize and return the LLM.""" - if self.llm is None: - from langchain_anthropic import ChatAnthropic - - config = get_config() - self.llm = ChatAnthropic( - model=self.model_name, - api_key=config.anthropic_api_key, - temperature=0.1, - max_tokens=2048, - ) - return self.llm - - def _validate_inputs(self, package_name: str, question: str) -> tuple[bool, str | None]: - """Validate package name and question inputs.""" - is_valid, error = validate_package_name(package_name) - if not is_valid: - return False, f"Invalid package name: {error}" - - is_valid, error = validate_question(question) - if not is_valid: - return False, f"Invalid question: {error}" - - return True, None - - def _build_chain(self) -> Any: - """Build the QA chain (shared by sync and async).""" - prompt = ChatPromptTemplate.from_messages( - [ - ("system", self._get_system_prompt()), - ("human", self._get_qa_prompt()), - ] - ) - return prompt | self._get_llm() | JsonOutputParser() - - def _build_invoke_params( - self, - package_name: str, - question: str, - learning_style: str, - mastered_concepts: list[str] | None, - weak_concepts: list[str] | None, - lesson_context: str | None, - ) -> dict[str, str]: - """Build invocation parameters.""" - return { - "package_name": package_name, - "question": question, - "learning_style": learning_style, - "mastered_concepts": ", ".join(mastered_concepts or []) or self._NONE_SPECIFIED, - "weak_concepts": ", ".join(weak_concepts or []) or self._NONE_SPECIFIED, - "lesson_context": lesson_context or "starting fresh", - } - - def _run( - self, - package_name: str, - question: str, - learning_style: str = "reading", - mastered_concepts: list[str] | None = None, - weak_concepts: list[str] | None = None, - lesson_context: str | None = None, - ) -> dict[str, Any]: - """ - Answer a user question about a package. - - Args: - package_name: Current package context. - question: The user's question. - learning_style: User's learning preference. - mastered_concepts: Concepts user has mastered. - weak_concepts: Concepts user struggles with. - lesson_context: What they've learned so far. - - Returns: - Dict containing the answer and related info. - """ - # Validate inputs - is_valid, error = self._validate_inputs(package_name, question) - if not is_valid: - return {"success": False, "error": error, "answer": None} - - try: - chain = self._build_chain() - params = self._build_invoke_params( - package_name, - question, - learning_style, - mastered_concepts, - weak_concepts, - lesson_context, - ) - - result = chain.invoke(params) - answer = self._structure_response(result, package_name, question) - - return { - "success": True, - "answer": answer, - "cost_gbp": 0.02, - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "answer": None, - } - - async def _arun( - self, - package_name: str, - question: str, - learning_style: str = "reading", - mastered_concepts: list[str] | None = None, - weak_concepts: list[str] | None = None, - lesson_context: str | None = None, - ) -> dict[str, Any]: - """Async version of Q&A handling.""" - # Validate inputs - is_valid, error = self._validate_inputs(package_name, question) - if not is_valid: - return {"success": False, "error": error, "answer": None} - - try: - chain = self._build_chain() - params = self._build_invoke_params( - package_name, - question, - learning_style, - mastered_concepts, - weak_concepts, - lesson_context, - ) - - result = await chain.ainvoke(params) - answer = self._structure_response(result, package_name, question) - - return { - "success": True, - "answer": answer, - "cost_gbp": 0.02, - } - - except Exception as e: - return { - "success": False, - "error": str(e), - "answer": None, - } - - def _get_system_prompt(self) -> str: - """Get the system prompt for Q&A.""" - return """You are a patient, knowledgeable tutor answering questions about software packages. - -CRITICAL RULES: -1. NEVER fabricate features - only describe functionality you're confident exists -2. NEVER invent comparison data or benchmarks -3. NEVER generate fake URLs -4. Express confidence levels: "I'm confident...", "I believe...", "You should verify..." -5. Admit knowledge limits honestly - -Your answers should: -- Be clear and educational -- Build on the student's existing knowledge -- Avoid re-explaining concepts they've mastered -- Provide extra detail for their weak areas -- Match their preferred learning style""" - - def _get_qa_prompt(self) -> str: - """Get the Q&A prompt template.""" - return """Package context: {package_name} -Question: {question} - -Student Profile: -- Learning style: {learning_style} -- Already mastered: {mastered_concepts} -- Struggles with: {weak_concepts} -- Current lesson context: {lesson_context} - -Answer the question considering their profile. Return 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.9, - "verification_note": "Optional note if user should verify something" -}} - -If you don't know the answer, be honest and suggest where they might find it. -If the question is unclear, ask for clarification in the answer field.""" - - def _structure_response( - self, response: dict[str, Any] | Any, package_name: str, question: str - ) -> dict[str, Any]: - """Structure and validate the LLM response. - - Args: - response: The LLM response, expected to be a dict but handles any type gracefully. - package_name: The package being queried. - question: The original user question. - - Returns: - Structured response dictionary. - """ - # Ensure response is a dict - if not isinstance(response, dict): - response = {} - - # Safely extract and validate related_topics - related_topics_raw = response.get("related_topics", []) - if isinstance(related_topics_raw, list): - related_topics = [str(t) for t in related_topics_raw][:5] - else: - related_topics = [] - - # Safely extract and validate follow_up_suggestions - follow_ups_raw = response.get("follow_up_suggestions", []) - if isinstance(follow_ups_raw, list): - follow_ups = [str(s) for s in follow_ups_raw][:3] - else: - follow_ups = [] - - # Safely extract and validate confidence - try: - confidence = float(response.get("confidence", 0.7)) - except (TypeError, ValueError): - confidence = 0.7 - confidence = min(max(confidence, 0.0), 1.0) - - structured = { - "package_name": package_name, - "original_question": question, - "question_understood": response.get("question_understood", question), - "answer": response.get("answer", "I couldn't generate an answer."), - "explanation": response.get("explanation"), - "code_example": None, - "related_topics": related_topics, - "follow_up_suggestions": follow_ups, - "confidence": confidence, - "verification_note": response.get("verification_note"), - } - - # Structure code example if present - with type validation - code_ex = response.get("code_example") - if isinstance(code_ex, dict) and code_ex.get("code"): - structured["code_example"] = { - "code": str(code_ex.get("code", "")), - "language": str(code_ex.get("language", "bash")), - "description": str(code_ex.get("description", "")), - } - elif isinstance(code_ex, str) and code_ex: - # Handle case where code_example is just a string - structured["code_example"] = { - "code": code_ex, - "language": "bash", - "description": "", - } - - return structured - - -def answer_question( - package_name: str, - question: str, - learning_style: str = "reading", -) -> dict[str, Any]: - """ - Convenience function to answer a question. - - Args: - package_name: Package context. - question: User's question. - learning_style: Learning preference. - - Returns: - Answer dictionary. - """ - tool = QAHandlerTool() - return tool._run( - package_name=package_name, - question=question, - learning_style=learning_style, - ) - - -# Maximum conversation history entries to prevent unbounded growth -_MAX_HISTORY_SIZE = 50 - - -class ConversationHandler: - """ - Handles multi-turn Q&A conversations with context. - - Maintains conversation history for more contextual responses. - """ - - def __init__(self, package_name: str) -> None: - """ - Initialize conversation handler. - - Args: - package_name: Package being discussed. - """ - self.package_name = package_name - self.history: list[dict[str, str]] = [] - self.qa_tool: QAHandlerTool | None = None # Lazy initialization - - def ask( - self, - question: str, - learning_style: str = "reading", - mastered_concepts: list[str] | None = None, - weak_concepts: list[str] | None = None, - ) -> dict[str, Any]: - """ - Ask a question with conversation history. - - Args: - question: The question to ask. - learning_style: Learning preference. - mastered_concepts: Mastered concepts. - weak_concepts: Weak concepts. - - Returns: - Answer with context. - """ - # Lazy-init QA tool on first use - if self.qa_tool is None: - self.qa_tool = QAHandlerTool() - - # Build context from history - context = self._build_context() - - # Get answer - result = self.qa_tool._run( - package_name=self.package_name, - question=question, - learning_style=learning_style, - mastered_concepts=mastered_concepts, - weak_concepts=weak_concepts, - lesson_context=context, - ) - - # Update history - if result.get("success"): - answer_obj = result.get("answer") - answer_text = answer_obj.get("answer", "") if isinstance(answer_obj, dict) else "" - self.history.append( - { - "question": question, - "answer": answer_text, - } - ) - # Bound history to prevent unbounded growth - self.history = self.history[-_MAX_HISTORY_SIZE:] - - return result - - def _build_context(self) -> str: - """Build context string from conversation history.""" - if not self.history: - return "Starting fresh conversation" - - recent = self.history[-3:] # Last 3 exchanges - context_parts = [] - for h in recent: - context_parts.append(f"Q: {h['question'][:100]}") - context_parts.append(f"A: {h['answer'][:100]}") - - return "Recent discussion: " + " | ".join(context_parts) - - def clear_history(self) -> None: - """Clear conversation history.""" - self.history = [] diff --git a/pyproject.toml b/pyproject.toml index a2b4c69c..a2b71afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,11 +57,6 @@ dependencies = [ "rich>=13.0.0", # Type hints for older Python versions "typing-extensions>=4.0.0", - # AI Tutor Dependencies (Issue #131) - "langchain>=0.3.0,<2.0.0", - "langchain-anthropic>=0.3.0,<2.0.0", - "langgraph>=0.2.0,<2.0.0", - "pydantic>=2.0.0,<3.0.0", ] [project.optional-dependencies] @@ -103,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", "cortex.tutor", "cortex.tutor.agents", "cortex.tutor.agents.tutor_agent", "cortex.tutor.contracts", "cortex.tutor.memory", "cortex.tutor.tools", "cortex.tutor.tools.agentic", "cortex.tutor.tools.deterministic"] +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] diff --git a/tests/tutor/test_agent_methods.py b/tests/tutor/test_agent_methods.py deleted file mode 100644 index 9d1e6002..00000000 --- a/tests/tutor/test_agent_methods.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -Tests for TutorAgent methods and graph nodes. - -Comprehensive tests for agent functionality. -""" - -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from cortex.tutor.agents.tutor_agent.graph import ( - create_tutor_graph, - fail_node, - generate_lesson_node, - get_tutor_graph, - load_cache_node, - plan_node, - qa_node, - reflect_node, - route_after_act, - route_after_plan, -) -from cortex.tutor.agents.tutor_agent.state import ( - TutorAgentState, - add_checkpoint, - add_cost, - add_error, - create_initial_state, - get_package_name, - get_session_type, - has_critical_error, -) - - -class TestTutorAgentMethods: - """Tests for TutorAgent class methods.""" - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_teach_success(self, mock_tracker_class, mock_graph): - """Test successful teach method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - mock_g = Mock() - mock_g.invoke.return_value = { - "output": { - "validation_passed": True, - "type": "lesson", - "content": {"summary": "Docker is..."}, - } - } - mock_graph.return_value = mock_g - - agent = TutorAgent(verbose=False) - result = agent.teach("docker") - - assert result["validation_passed"] is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_teach_verbose(self, mock_tracker_class, mock_graph): - """Test teach with verbose mode.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - mock_g = Mock() - mock_g.invoke.return_value = { - "output": { - "validation_passed": True, - "type": "lesson", - "source": "cache", - "cache_hit": True, - "cost_gbp": 0.0, - "cost_saved_gbp": 0.02, - "confidence": 0.9, - } - } - mock_graph.return_value = mock_g - - with patch("cortex.tutor.agents.tutor_agent.tutor_agent.tutor_print"): - with patch("cortex.tutor.agents.tutor_agent.tutor_agent.console"): - agent = TutorAgent(verbose=True) - result = agent.teach("docker") - - assert result["validation_passed"] is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_ask_success(self, mock_tracker_class, mock_graph): - """Test successful ask method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker_class.return_value = mock_tracker - - mock_g = Mock() - mock_g.invoke.return_value = { - "output": { - "validation_passed": True, - "type": "qa", - "content": {"answer": "Docker is a container platform."}, - } - } - mock_graph.return_value = mock_g - - agent = TutorAgent() - result = agent.ask("docker", "What is Docker?") - - assert result["validation_passed"] is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_get_profile(self, mock_tracker_class, mock_graph): - """Test get_profile method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = { - "success": True, - "profile": {"learning_style": "visual"}, - } - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.get_profile() - - assert result["success"] is True - assert "profile" in result - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_update_learning_style(self, mock_tracker_class, mock_graph): - """Test update_learning_style method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.update_learning_style("visual") - - assert result is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_mark_completed(self, mock_tracker_class, mock_graph): - """Test mark_completed method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.mark_completed("docker", "basics", 0.9) - - assert result is True - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_reset_progress(self, mock_tracker_class, mock_graph): - """Test reset_progress method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "count": 5} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.reset_progress() - - assert result == 5 - - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.ProgressTrackerTool") - def test_get_packages_studied(self, mock_tracker_class, mock_graph): - """Test get_packages_studied method.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "packages": ["docker", "nginx"]} - mock_tracker_class.return_value = mock_tracker - - agent = TutorAgent() - result = agent.get_packages_studied() - - assert result == ["docker", "nginx"] - - -class TestGenerateLessonNode: - """Tests for generate_lesson_node.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - def test_generate_lesson_success(self, mock_generator_class, mock_loader_class): - """Test successful lesson generation.""" - mock_generator = Mock() - mock_generator._run.return_value = { - "success": True, - "lesson": { - "package_name": "docker", - "summary": "Docker is a container platform.", - "explanation": "Docker allows...", - }, - "cost_gbp": 0.02, - } - mock_generator_class.return_value = mock_generator - - mock_loader = Mock() - mock_loader.cache_lesson.return_value = True - mock_loader_class.return_value = mock_loader - - state = create_initial_state("docker") - state["student_profile"] = {"learning_style": "reading"} - - result = generate_lesson_node(state) - - assert result["results"]["type"] == "lesson" - assert result["results"]["source"] == "generated" - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - def test_generate_lesson_failure(self, mock_generator_class): - """Test lesson generation failure.""" - mock_generator = Mock() - mock_generator._run.return_value = { - "success": False, - "error": "API error", - } - mock_generator_class.return_value = mock_generator - - state = create_initial_state("docker") - state["student_profile"] = {} - - result = generate_lesson_node(state) - - assert len(result["errors"]) > 0 - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - def test_generate_lesson_exception(self, mock_generator_class): - """Test lesson generation with exception.""" - mock_generator_class.side_effect = Exception("Test exception") - - state = create_initial_state("docker") - state["student_profile"] = {} - - result = generate_lesson_node(state) - - assert len(result["errors"]) > 0 - - -class TestQANode: - """Tests for qa_node.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_success(self, mock_qa_class): - """Test successful Q&A.""" - mock_qa = Mock() - mock_qa._run.return_value = { - "success": True, - "answer": { - "answer": "Docker is a containerization platform.", - "explanation": "It allows...", - }, - "cost_gbp": 0.02, - } - mock_qa_class.return_value = mock_qa - - state = create_initial_state("docker", session_type="qa", question="What is Docker?") - state["student_profile"] = {} - - result = qa_node(state) - - assert result["results"]["type"] == "qa" - assert result["qa_result"] is not None - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_no_question(self, mock_qa_class): - """Test Q&A without question.""" - state = create_initial_state("docker", session_type="qa") - # No question provided - - result = qa_node(state) - - assert len(result["errors"]) > 0 - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_failure(self, mock_qa_class): - """Test Q&A failure.""" - mock_qa = Mock() - mock_qa._run.return_value = { - "success": False, - "error": "Could not answer", - } - mock_qa_class.return_value = mock_qa - - state = create_initial_state("docker", session_type="qa", question="What?") - state["student_profile"] = {} - - result = qa_node(state) - - assert len(result["errors"]) > 0 - - @patch("cortex.tutor.agents.tutor_agent.graph.QAHandlerTool") - def test_qa_exception(self, mock_qa_class): - """Test Q&A with exception.""" - mock_qa_class.side_effect = Exception("Test error") - - state = create_initial_state("docker", session_type="qa", question="What?") - state["student_profile"] = {} - - result = qa_node(state) - - assert len(result["errors"]) > 0 - - -class TestReflectNode: - """Tests for reflect_node.""" - - def test_reflect_with_errors(self): - """Test reflect with non-critical errors.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {"summary": "Test"}, "source": "cache"} - add_error(state, "test", "Minor error", recoverable=True) - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is True - assert result["output"]["confidence"] < 1.0 - - def test_reflect_with_critical_error(self): - """Test reflect with critical error.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {"summary": "Test"}} - add_error(state, "test", "Critical error", recoverable=False) - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is False - - -class TestFailNode: - """Tests for fail_node.""" - - def test_fail_node_with_errors(self): - """Test fail node with multiple errors.""" - state = create_initial_state("docker") - add_error(state, "test1", "Error 1") - add_error(state, "test2", "Error 2") - state["cost_gbp"] = 0.01 - - result = fail_node(state) - - assert result["output"]["type"] == "error" - assert result["output"]["validation_passed"] is False - assert len(result["output"]["validation_errors"]) == 2 - - -class TestRouting: - """Tests for routing functions.""" - - def test_route_after_plan_fail_on_error(self): - """Test routing to fail on critical error.""" - state = create_initial_state("docker") - add_error(state, "test", "Critical", recoverable=False) - - route = route_after_plan(state) - assert route == "fail" - - def test_route_after_act_fail_no_results(self): - """Test routing to fail when no results.""" - state = create_initial_state("docker") - state["results"] = {} - - route = route_after_act(state) - assert route == "fail" - - def test_route_after_act_success(self): - """Test routing to reflect on success.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {}} - - route = route_after_act(state) - assert route == "reflect" - - -class TestGraphCreation: - """Tests for graph creation.""" - - def test_create_tutor_graph(self): - """Test graph is created successfully.""" - graph = create_tutor_graph() - assert graph is not None - - def test_get_tutor_graph_singleton(self): - """Test get_tutor_graph returns singleton.""" - graph1 = get_tutor_graph() - graph2 = get_tutor_graph() - assert graph1 is graph2 - - -class TestStateHelpers: - """Tests for state helper functions.""" - - def test_add_checkpoint(self): - """Test add_checkpoint adds to list.""" - state = create_initial_state("docker") - add_checkpoint(state, "test", "ok", "Test checkpoint") - - assert len(state["checkpoints"]) == 1 - assert state["checkpoints"][0]["name"] == "test" - assert state["checkpoints"][0]["status"] == "ok" - - def test_add_cost(self): - """Test add_cost accumulates.""" - state = create_initial_state("docker") - add_cost(state, 0.01) - add_cost(state, 0.02) - add_cost(state, 0.005) - - assert abs(state["cost_gbp"] - 0.035) < 0.0001 - - def test_get_session_type_default(self): - """Test default session type.""" - state = create_initial_state("docker") - assert get_session_type(state) == "lesson" - - def test_get_package_name(self): - """Test getting package name.""" - state = create_initial_state("nginx") - assert get_package_name(state) == "nginx" diff --git a/tests/tutor/test_agentic_tools.py b/tests/tutor/test_agentic_tools.py deleted file mode 100644 index 4e86d896..00000000 --- a/tests/tutor/test_agentic_tools.py +++ /dev/null @@ -1,292 +0,0 @@ -""" -Tests for agentic tools structure methods. - -Tests the _structure_response methods with mocked responses. -""" - -from unittest.mock import Mock, patch - -import pytest - - -class TestLessonGeneratorStructure: - """Tests for LessonGeneratorTool structure methods.""" - - @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") - @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") - def test_structure_response_full(self, mock_llm_class, mock_config): - """Test structure_response with full response.""" - from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - tool = LessonGeneratorTool() - - response = { - "package_name": "docker", - "summary": "Docker is a platform.", - "explanation": "Docker allows...", - "use_cases": ["Dev", "Prod"], - "best_practices": ["Use official images"], - "code_examples": [{"title": "Run", "code": "docker run", "language": "bash"}], - "tutorial_steps": [{"step_number": 1, "title": "Start", "content": "Begin"}], - "installation_command": "apt install docker", - "related_packages": ["podman"], - "confidence": 0.9, - } - - result = tool._structure_response(response, "docker") - - assert result["package_name"] == "docker" - assert result["summary"] == "Docker is a platform." - assert len(result["use_cases"]) == 2 - assert result["confidence"] == pytest.approx(0.9) - - @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") - @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") - def test_structure_response_minimal(self, mock_llm_class, mock_config): - """Test structure_response with minimal response.""" - from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - tool = LessonGeneratorTool() - - response = { - "package_name": "test", - "summary": "Test summary", - } - - result = tool._structure_response(response, "test") - - assert result["package_name"] == "test" - assert result["use_cases"] == [] - assert result["best_practices"] == [] - - -class TestExamplesProviderStructure: - """Tests for ExamplesProviderTool structure methods.""" - - @patch("cortex.tutor.tools.agentic.examples_provider.get_config") - @patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic") - def test_structure_response_full(self, mock_llm_class, mock_config): - """Test structure_response with full response.""" - from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - mock_llm_class.return_value = Mock() - - tool = ExamplesProviderTool() - - response = { - "package_name": "git", - "topic": "branching", - "examples": [{"title": "Create", "code": "git checkout -b", "language": "bash"}], - "tips": ["Use descriptive names"], - "common_mistakes": ["Forgetting to commit"], - "confidence": 0.95, - } - - result = tool._structure_response(response, "git", "branching") - - assert result["package_name"] == "git" - assert result["topic"] == "branching" - assert len(result["examples"]) == 1 - - -class TestQAHandlerStructure: - """Tests for QAHandlerTool structure methods.""" - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_structure_response_full(self, mock_config): - """Test structure_response with full response.""" - from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - # QAHandlerTool now uses lazy LLM init, so we don't need to mock ChatAnthropic - # for _structure_response tests (it's not called during instantiation) - tool = QAHandlerTool() - - response = { - "question_understood": "What is Docker?", - "answer": "Docker is a container platform.", - "explanation": "It allows packaging applications.", - "code_example": {"code": "docker run", "language": "bash"}, - "related_topics": ["containers", "images"], - "confidence": 0.9, - } - - result = tool._structure_response(response, "docker", "What is Docker?") - - assert result["answer"] == "Docker is a container platform." - assert result["code_example"] is not None - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_structure_response_handles_non_dict(self, mock_config): - """Test structure_response handles non-dict input.""" - from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - tool = QAHandlerTool() - - # Test with non-dict response - result = tool._structure_response(None, "docker", "What is Docker?") - - assert result["answer"] == "I couldn't generate an answer." - assert result["package_name"] == "docker" - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_structure_response_handles_invalid_confidence(self, mock_config): - """Test structure_response handles invalid confidence value.""" - from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - tool = QAHandlerTool() - - response = { - "answer": "Test answer", - "confidence": "not a number", # Invalid type - } - - result = tool._structure_response(response, "docker", "What?") - - # Should default to 0.7 - assert result["confidence"] == pytest.approx(0.7) - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_structure_response_clamps_confidence(self, mock_config): - """Test structure_response clamps confidence to 0-1 range.""" - from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - tool = QAHandlerTool() - - # Test confidence > 1 - response = {"answer": "Test", "confidence": 1.5} - result = tool._structure_response(response, "docker", "What?") - assert result["confidence"] == pytest.approx(1.0) - - # Test confidence < 0 - response = {"answer": "Test", "confidence": -0.5} - result = tool._structure_response(response, "docker", "What?") - assert result["confidence"] == pytest.approx(0.0) - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_structure_response_handles_string_code_example(self, mock_config): - """Test structure_response handles code_example as string.""" - from cortex.tutor.tools.agentic.qa_handler import QAHandlerTool - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - tool = QAHandlerTool() - - response = { - "answer": "Test answer", - "code_example": "docker run nginx", # String instead of dict - } - - result = tool._structure_response(response, "docker", "How to run?") - - assert result["code_example"] is not None - assert result["code_example"]["code"] == "docker run nginx" - assert result["code_example"]["language"] == "bash" - - -class TestConversationHandler: - """Tests for ConversationHandler.""" - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_build_context_empty(self, mock_config): - """Test context building with empty history.""" - from cortex.tutor.tools.agentic.qa_handler import ConversationHandler - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - # ConversationHandler now uses lazy init, no LLM created on __init__ - handler = ConversationHandler("docker") - handler.history = [] - - context = handler._build_context() - assert "Starting fresh" in context - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_build_context_with_history(self, mock_config): - """Test context building with history.""" - from cortex.tutor.tools.agentic.qa_handler import ConversationHandler - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - handler = ConversationHandler("docker") - handler.history = [ - {"question": "What is Docker?", "answer": "A platform"}, - ] - - context = handler._build_context() - assert "What is Docker?" in context - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_clear_history(self, mock_config): - """Test clearing history.""" - from cortex.tutor.tools.agentic.qa_handler import ConversationHandler - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - handler = ConversationHandler("docker") - handler.history = [{"question": "test", "answer": "response"}] - handler.clear_history() - - assert len(handler.history) == 0 - - @patch("cortex.tutor.tools.agentic.qa_handler.get_config") - def test_lazy_qa_tool_init(self, mock_config): - """Test QA tool is lazily initialized.""" - from cortex.tutor.tools.agentic.qa_handler import ConversationHandler - - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - handler = ConversationHandler("docker") - - # qa_tool should be None before first ask() - assert handler.qa_tool is None diff --git a/tests/tutor/test_integration.py b/tests/tutor/test_integration.py index b4100dd0..b3e6752f 100644 --- a/tests/tutor/test_integration.py +++ b/tests/tutor/test_integration.py @@ -1,18 +1,18 @@ """ Integration tests for Intelligent Tutor. -End-to-end tests for the complete tutoring workflow. +Tests for configuration, contracts, branding, and CLI. """ import tempfile from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest -from cortex.tutor.branding import console, print_banner, tutor_print -from cortex.tutor.config import Config, get_config, reset_config -from cortex.tutor.contracts.lesson_context import CodeExample, LessonContext, TutorialStep +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, @@ -135,23 +135,6 @@ def test_lesson_context_serialization(self): assert restored.package_name == "docker" assert restored.confidence == pytest.approx(0.85) - def test_lesson_context_display_dict(self): - """Test to_display_dict method.""" - lesson = LessonContext( - package_name="docker", - summary="Summary", - explanation="Explanation", - use_cases=["Use 1", "Use 2"], - best_practices=["Practice 1"], - installation_command="apt install docker.io", - confidence=0.9, - ) - - display = lesson.to_display_dict() - - assert display["package"] == "docker" - assert display["confidence"] == "90%" - class TestProgressContext: """Tests for ProgressContext contract.""" @@ -183,23 +166,6 @@ def test_package_progress_completion(self): assert not package.is_complete() assert package.get_next_topic() == "advanced" - def test_progress_context_recommendations(self): - """Test getting learning recommendations.""" - progress = ProgressContext( - weak_concepts=["networking", "volumes"], - packages=[ - PackageProgress( - package_name="docker", - topics=[TopicProgress(topic="basics", completed=False)], - ) - ], - ) - - recommendations = progress.get_recommendations() - - assert len(recommendations) >= 1 - assert any("networking" in r.lower() or "docker" in r.lower() for r in recommendations) - class TestBranding: """Tests for branding/UI utilities.""" @@ -207,13 +173,10 @@ class TestBranding: def test_tutor_print_success(self, capsys): """Test tutor_print with success status.""" tutor_print("Test message", "success") - _captured = capsys.readouterr() - # Rich console output is complex, just ensure no errors def test_tutor_print_error(self, capsys): """Test tutor_print with error status.""" tutor_print("Error message", "error") - _captured = capsys.readouterr() def test_console_exists(self): """Test console is properly initialized.""" @@ -229,7 +192,6 @@ def test_create_parser(self): parser = create_parser() - # Test help doesn't raise with pytest.raises(SystemExit): parser.parse_args(["--help"]) @@ -269,95 +231,3 @@ def test_parse_list_flag(self): args = parser.parse_args(["--list"]) assert args.list is True - - def test_parse_progress_flag(self): - """Test parsing progress flag.""" - from cortex.tutor.cli import create_parser - - parser = create_parser() - args = parser.parse_args(["--progress"]) - - assert args.progress is True - - def test_parse_reset_flag(self): - """Test parsing reset flag.""" - from cortex.tutor.cli import create_parser - - parser = create_parser() - - # Reset all - args = parser.parse_args(["--reset"]) - assert args.reset == "__all__" - - # Reset specific package - args = parser.parse_args(["--reset", "docker"]) - assert args.reset == "docker" - - -class TestEndToEnd: - """End-to-end workflow tests.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.LessonGeneratorTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - def test_full_lesson_workflow_with_cache( - self, mock_tracker_class, mock_loader_class, mock_generator_class - ): - """Test complete lesson workflow with cache hit.""" - # Set up mocks - mock_tracker = Mock() - mock_tracker._run.return_value = { - "success": True, - "profile": { - "learning_style": "reading", - "mastered_concepts": [], - "weak_concepts": [], - }, - } - mock_tracker_class.return_value = mock_tracker - - cached_lesson = { - "package_name": "docker", - "summary": "Docker is a containerization platform.", - "explanation": "Docker allows...", - "use_cases": ["Development"], - "best_practices": ["Use official images"], - "code_examples": [], - "tutorial_steps": [], - "installation_command": "apt install docker.io", - "confidence": 0.9, - } - - mock_loader = Mock() - mock_loader._run.return_value = { - "cache_hit": True, - "lesson": cached_lesson, - "cost_saved_gbp": 0.02, - } - mock_loader.cache_lesson.return_value = True - mock_loader_class.return_value = mock_loader - - # Run workflow - from cortex.tutor.agents.tutor_agent.graph import ( - load_cache_node, - plan_node, - reflect_node, - ) - from cortex.tutor.agents.tutor_agent.state import create_initial_state - - state = create_initial_state("docker") - - # Execute nodes - state = plan_node(state) - assert state["plan"]["strategy"] == "use_cache" - assert state["cache_hit"] is True - - state = load_cache_node(state) - assert state["results"]["type"] == "lesson" - - state = reflect_node(state) - assert state["output"]["validation_passed"] is True - assert state["output"]["cache_hit"] is True - - # Note: Real API test removed - use manual testing for API integration - # Run: python -m cortex.tutor.cli docker diff --git a/tests/tutor/test_interactive_tutor.py b/tests/tutor/test_interactive_tutor.py index a3fdfb6d..20e45451 100644 --- a/tests/tutor/test_interactive_tutor.py +++ b/tests/tutor/test_interactive_tutor.py @@ -24,7 +24,6 @@ def test_init(self, mock_agent_class): assert tutor.package_name == "docker" assert tutor.lesson is None - assert tutor.current_step == 0 class TestInteractiveTutorStart: diff --git a/tests/tutor/test_tools.py b/tests/tutor/test_tools.py index 03fc1e8b..90d3bdad 100644 --- a/tests/tutor/test_tools.py +++ b/tests/tutor/test_tools.py @@ -1,20 +1,15 @@ """ -Tests for deterministic and agentic tools. +Tests for deterministic tools. -Tests tool functionality with mocked LLM calls. +Tests lesson loader and fallback functionality. """ import tempfile from pathlib import Path -from unittest.mock import MagicMock, Mock, patch import pytest -from cortex.tutor.tools.agentic.examples_provider import ExamplesProviderTool -from cortex.tutor.tools.agentic.lesson_generator import LessonGeneratorTool -from cortex.tutor.tools.agentic.qa_handler import ConversationHandler, QAHandlerTool from cortex.tutor.tools.deterministic.lesson_loader import ( - FALLBACK_LESSONS, LessonLoaderTool, get_fallback_lesson, load_lesson_with_fallback, @@ -44,10 +39,8 @@ def test_force_fresh(self, temp_db): """Test force_fresh skips cache.""" loader = LessonLoaderTool(temp_db) - # Cache a lesson loader.cache_lesson("docker", {"summary": "cached"}) - # Force fresh should skip cache result = loader._run("docker", force_fresh=True) assert not result["cache_hit"] @@ -100,7 +93,6 @@ class TestLoadLessonWithFallback: def test_returns_cache_if_available(self, temp_db): """Test returns cached lesson if available.""" - # First cache a lesson from cortex.tutor.memory.sqlite_store import SQLiteStore store = SQLiteStore(temp_db) @@ -119,191 +111,3 @@ def test_returns_none_for_unknown(self, temp_db): result = load_lesson_with_fallback("totally_unknown", temp_db) assert result["source"] == "none" assert result["needs_generation"] - - -class TestLessonGeneratorTool: - """Tests for LessonGeneratorTool with mocked LLM.""" - - @patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic") - @patch("cortex.tutor.tools.agentic.lesson_generator.get_config") - def test_generate_lesson_structure(self, mock_config, mock_llm_class): - """Test lesson generation returns proper structure.""" - # Mock config - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - - # Mock LLM response - mock_response = { - "package_name": "docker", - "summary": "Docker is a containerization platform.", - "explanation": "Docker allows you to...", - "use_cases": ["Development", "Deployment"], - "best_practices": ["Use official images"], - "code_examples": [ - { - "title": "Run container", - "code": "docker run nginx", - "language": "bash", - "description": "Runs nginx", - } - ], - "tutorial_steps": [ - { - "step_number": 1, - "title": "Install", - "content": "First, install Docker", - } - ], - "installation_command": "apt install docker.io", - "related_packages": ["podman"], - "confidence": 0.9, - } - - mock_chain = Mock() - mock_chain.invoke.return_value = mock_response - mock_llm = Mock() - mock_llm.__or__ = Mock(return_value=mock_chain) - mock_llm_class.return_value = mock_llm - - # Create tool and test - tool = LessonGeneratorTool() - tool.llm = mock_llm - - # Directly test structure method - result = tool._structure_response(mock_response, "docker") - - assert result["package_name"] == "docker" - assert "summary" in result - assert "explanation" in result - assert len(result["code_examples"]) == 1 - assert result["confidence"] == pytest.approx(0.9) - - def test_structure_response_handles_missing_fields(self): - """Test structure_response handles missing fields gracefully.""" - # Skip LLM initialization by mocking - with patch("cortex.tutor.tools.agentic.lesson_generator.get_config") as mock_config: - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - with patch("cortex.tutor.tools.agentic.lesson_generator.ChatAnthropic"): - tool = LessonGeneratorTool() - - incomplete_response = { - "package_name": "test", - "summary": "Test summary", - } - - result = tool._structure_response(incomplete_response, "test") - - assert result["package_name"] == "test" - assert result["summary"] == "Test summary" - assert result["use_cases"] == [] - assert result["best_practices"] == [] - - -class TestExamplesProviderTool: - """Tests for ExamplesProviderTool with mocked LLM.""" - - def test_structure_response(self): - """Test structure_response formats examples correctly.""" - with patch("cortex.tutor.tools.agentic.examples_provider.get_config") as mock_config: - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - with patch("cortex.tutor.tools.agentic.examples_provider.ChatAnthropic"): - tool = ExamplesProviderTool() - - response = { - "package_name": "git", - "topic": "branching", - "examples": [ - { - "title": "Create branch", - "code": "git checkout -b feature", - "language": "bash", - "description": "Creates new branch", - } - ], - "tips": ["Use descriptive names"], - "common_mistakes": ["Forgetting to commit"], - "confidence": 0.95, - } - - result = tool._structure_response(response, "git", "branching") - - assert result["package_name"] == "git" - assert result["topic"] == "branching" - assert len(result["examples"]) == 1 - assert result["examples"][0]["title"] == "Create branch" - - -class TestQAHandlerTool: - """Tests for QAHandlerTool with mocked LLM.""" - - def test_structure_response(self): - """Test structure_response formats answers correctly.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config") as mock_config: - mock_config.return_value = Mock( - anthropic_api_key="test_key", - model="claude-sonnet-4-20250514", - ) - # QAHandlerTool uses lazy LLM init, no need to mock ChatAnthropic - tool = QAHandlerTool() - - response = { - "question_understood": "What is Docker?", - "answer": "Docker is a containerization platform.", - "explanation": "It allows you to package applications.", - "code_example": { - "code": "docker run hello-world", - "language": "bash", - "description": "Runs test container", - }, - "related_topics": ["containers", "images"], - "confidence": 0.9, - } - - result = tool._structure_response(response, "docker", "What is Docker?") - - assert result["answer"] == "Docker is a containerization platform." - assert result["code_example"] is not None - assert len(result["related_topics"]) == 2 - - -class TestConversationHandler: - """Tests for ConversationHandler.""" - - def test_build_context_empty(self): - """Test context building with empty history.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): - handler = ConversationHandler.__new__(ConversationHandler) - handler.history = [] - - context = handler._build_context() - assert "Starting fresh" in context - - def test_build_context_with_history(self): - """Test context building with history.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): - handler = ConversationHandler.__new__(ConversationHandler) - handler.history = [ - {"question": "What is Docker?", "answer": "A platform"}, - {"question": "How to install?", "answer": "Use apt"}, - ] - - context = handler._build_context() - assert "What is Docker?" in context - assert "Recent discussion" in context - - def test_clear_history(self): - """Test clearing conversation history.""" - with patch("cortex.tutor.tools.agentic.qa_handler.get_config"): - handler = ConversationHandler.__new__(ConversationHandler) - handler.history = [{"question": "test", "answer": "test"}] - - handler.clear_history() - assert len(handler.history) == 0 diff --git a/tests/tutor/test_tutor_agent.py b/tests/tutor/test_tutor_agent.py deleted file mode 100644 index 68c807d8..00000000 --- a/tests/tutor/test_tutor_agent.py +++ /dev/null @@ -1,320 +0,0 @@ -""" -Tests for TutorAgent and LangGraph workflow. - -Tests the main agent orchestrator and state management. -""" - -import tempfile -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest - -from cortex.tutor.agents.tutor_agent.graph import ( - fail_node, - load_cache_node, - plan_node, - reflect_node, - route_after_act, - route_after_plan, -) -from cortex.tutor.agents.tutor_agent.state import ( - TutorAgentState, - add_checkpoint, - add_cost, - add_error, - create_initial_state, - get_package_name, - get_session_type, - has_critical_error, -) - - -class TestTutorAgentState: - """Tests for TutorAgentState and state utilities.""" - - def test_create_initial_state(self): - """Test creating initial state.""" - state = create_initial_state( - package_name="docker", - session_type="lesson", - ) - - assert state["input"]["package_name"] == "docker" - assert state["input"]["session_type"] == "lesson" - assert state["force_fresh"] is False - assert state["errors"] == [] - assert state["cost_gbp"] == pytest.approx(0.0) - - def test_create_initial_state_qa_mode(self): - """Test creating initial state for Q&A.""" - state = create_initial_state( - package_name="docker", - session_type="qa", - question="What is Docker?", - ) - - assert state["input"]["session_type"] == "qa" - assert state["input"]["question"] == "What is Docker?" - - def test_add_error(self): - """Test adding errors to state.""" - state = create_initial_state("docker") - add_error(state, "test_node", "Test error", recoverable=True) - - assert len(state["errors"]) == 1 - assert state["errors"][0]["node"] == "test_node" - assert state["errors"][0]["error"] == "Test error" - assert state["errors"][0]["recoverable"] is True - - def test_add_checkpoint(self): - """Test adding checkpoints to state.""" - state = create_initial_state("docker") - add_checkpoint(state, "plan_start", "ok", "Planning started") - - assert len(state["checkpoints"]) == 1 - assert state["checkpoints"][0]["name"] == "plan_start" - assert state["checkpoints"][0]["status"] == "ok" - - def test_add_cost(self): - """Test adding cost to state.""" - state = create_initial_state("docker") - add_cost(state, 0.02) - add_cost(state, 0.01) - - assert state["cost_gbp"] == pytest.approx(0.03) - - def test_has_critical_error_false(self): - """Test has_critical_error returns False when no critical errors.""" - state = create_initial_state("docker") - add_error(state, "test", "Recoverable error", recoverable=True) - - assert has_critical_error(state) is False - - def test_has_critical_error_true(self): - """Test has_critical_error returns True when critical error exists.""" - state = create_initial_state("docker") - add_error(state, "test", "Critical error", recoverable=False) - - assert has_critical_error(state) is True - - def test_get_session_type(self): - """Test get_session_type utility.""" - state = create_initial_state("docker", session_type="qa") - assert get_session_type(state) == "qa" - - def test_get_package_name(self): - """Test get_package_name utility.""" - state = create_initial_state("nginx") - assert get_package_name(state) == "nginx" - - -class TestGraphNodes: - """Tests for LangGraph node functions.""" - - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - def test_plan_node_cache_hit(self, mock_loader_class, mock_tracker_class): - """Test plan_node with cache hit.""" - # Mock tracker - mock_tracker = Mock() - mock_tracker._run.return_value = { - "success": True, - "profile": { - "learning_style": "reading", - "mastered_concepts": [], - "weak_concepts": [], - }, - } - mock_tracker_class.return_value = mock_tracker - - # Mock loader with cache hit - mock_loader = Mock() - mock_loader._run.return_value = { - "cache_hit": True, - "lesson": {"summary": "Cached lesson"}, - } - mock_loader_class.return_value = mock_loader - - state = create_initial_state("docker") - result = plan_node(state) - - assert result["plan"]["strategy"] == "use_cache" - assert result["cache_hit"] is True - - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - @patch("cortex.tutor.agents.tutor_agent.graph.LessonLoaderTool") - def test_plan_node_cache_miss(self, mock_loader_class, mock_tracker_class): - """Test plan_node with cache miss.""" - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "profile": {}} - mock_tracker_class.return_value = mock_tracker - - mock_loader = Mock() - mock_loader._run.return_value = {"cache_hit": False, "lesson": None} - mock_loader_class.return_value = mock_loader - - state = create_initial_state("docker") - result = plan_node(state) - - assert result["plan"]["strategy"] == "generate_full" - - @patch("cortex.tutor.agents.tutor_agent.graph.ProgressTrackerTool") - def test_plan_node_qa_mode(self, mock_tracker_class): - """Test plan_node in Q&A mode.""" - mock_tracker = Mock() - mock_tracker._run.return_value = {"success": True, "profile": {}} - mock_tracker_class.return_value = mock_tracker - - state = create_initial_state("docker", session_type="qa", question="What?") - result = plan_node(state) - - assert result["plan"]["strategy"] == "qa_mode" - - def test_load_cache_node(self): - """Test load_cache_node with cached data.""" - state = create_initial_state("docker") - state["plan"] = { - "strategy": "use_cache", - "cached_data": {"summary": "Cached lesson", "explanation": "..."}, - } - - result = load_cache_node(state) - - assert result["lesson_content"]["summary"] == "Cached lesson" - assert result["results"]["source"] == "cache" - - def test_load_cache_node_missing_data(self): - """Test load_cache_node handles missing cache data.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "use_cache", "cached_data": None} - - result = load_cache_node(state) - - assert len(result["errors"]) > 0 - - def test_reflect_node_success(self): - """Test reflect_node with successful results.""" - state = create_initial_state("docker") - state["results"] = { - "type": "lesson", - "content": {"summary": "Test"}, - "source": "generated", - } - state["errors"] = [] - state["cost_gbp"] = 0.02 - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is True - assert result["output"]["cost_gbp"] == pytest.approx(0.02) - - def test_reflect_node_failure(self): - """Test reflect_node with missing results.""" - state = create_initial_state("docker") - state["results"] = {} - - result = reflect_node(state) - - assert result["output"]["validation_passed"] is False - assert "No content" in str(result["output"]["validation_errors"]) - - def test_fail_node(self): - """Test fail_node creates proper error output.""" - state = create_initial_state("docker") - add_error(state, "test", "Test error") - state["cost_gbp"] = 0.01 - - result = fail_node(state) - - assert result["output"]["type"] == "error" - assert result["output"]["validation_passed"] is False - assert "Test error" in result["output"]["validation_errors"] - - -class TestRouting: - """Tests for routing functions.""" - - def test_route_after_plan_use_cache(self): - """Test routing to cache path.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "use_cache"} - - route = route_after_plan(state) - assert route == "load_cache" - - def test_route_after_plan_generate(self): - """Test routing to generation path.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "generate_full"} - - route = route_after_plan(state) - assert route == "generate_lesson" - - def test_route_after_plan_qa(self): - """Test routing to Q&A path.""" - state = create_initial_state("docker") - state["plan"] = {"strategy": "qa_mode"} - - route = route_after_plan(state) - assert route == "qa" - - def test_route_after_plan_critical_error(self): - """Test routing to fail on critical error.""" - state = create_initial_state("docker") - add_error(state, "test", "Critical", recoverable=False) - - route = route_after_plan(state) - assert route == "fail" - - def test_route_after_act_success(self): - """Test routing after successful act phase.""" - state = create_initial_state("docker") - state["results"] = {"type": "lesson", "content": {}} - - route = route_after_act(state) - assert route == "reflect" - - def test_route_after_act_no_results(self): - """Test routing to fail when no results.""" - state = create_initial_state("docker") - state["results"] = {} - - route = route_after_act(state) - assert route == "fail" - - -class TestTutorAgentIntegration: - """Integration tests for TutorAgent.""" - - @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - def test_teach_validation(self, mock_graph): - """Test teach validates package name.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - from cortex.tutor.config import reset_config - - reset_config() - - with pytest.raises(ValueError) as exc_info: - agent = TutorAgent() - agent.teach("") - - assert "Invalid package name" in str(exc_info.value) - - @patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-ant-test-key"}) - @patch("cortex.tutor.agents.tutor_agent.tutor_agent.get_tutor_graph") - def test_ask_validation(self, mock_graph): - """Test ask validates inputs.""" - from cortex.tutor.agents.tutor_agent import TutorAgent - from cortex.tutor.config import reset_config - - reset_config() - - agent = TutorAgent() - - with pytest.raises(ValueError): - agent.ask("", "question") - - with pytest.raises(ValueError): - agent.ask("docker", "") From 0329f5b86c1939d66409553dda66fabd590174a9 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 16:40:30 +0530 Subject: [PATCH 22/32] fix: remove langchain imports from deterministic tools Convert LessonLoaderTool and ProgressTrackerTool from LangChain BaseTool subclasses to plain Python classes. Removes remaining langchain/pydantic dependencies from the tutor module. --- .../tools/deterministic/lesson_loader.py | 110 ++---------------- .../tools/deterministic/progress_tracker.py | 96 ++------------- 2 files changed, 18 insertions(+), 188 deletions(-) diff --git a/cortex/tutor/tools/deterministic/lesson_loader.py b/cortex/tutor/tools/deterministic/lesson_loader.py index 0ef1d288..f7573ebd 100644 --- a/cortex/tutor/tools/deterministic/lesson_loader.py +++ b/cortex/tutor/tools/deterministic/lesson_loader.py @@ -7,42 +7,15 @@ from pathlib import Path from typing import Any -from langchain.tools import BaseTool -from pydantic import Field - from cortex.tutor.config import get_config -from cortex.tutor.contracts.lesson_context import LessonContext from cortex.tutor.memory.sqlite_store import SQLiteStore -class LessonLoaderTool(BaseTool): - """ - Deterministic tool for loading cached lesson content. - - This tool retrieves lessons from SQLite cache without LLM calls. - It is fast, free, and should be checked before generating new lessons. - """ - - name: str = "lesson_loader" - description: str = ( - "Load cached lesson content for a package. " - "Use this before generating new lessons to save cost. " - "Returns None if no valid cache exists." - ) - - store: SQLiteStore | None = Field(default=None, exclude=True) - - class Config: - arbitrary_types_allowed = True +class LessonLoaderTool: + """Deterministic tool for loading cached lesson content.""" def __init__(self, db_path: Path | None = None) -> None: - """ - Initialize the lesson loader tool. - - Args: - db_path: Path to SQLite database. Uses config default if not provided. - """ - super().__init__() + """Initialize the lesson loader tool.""" if db_path is None: config = get_config() db_path = config.get_db_path() @@ -53,16 +26,7 @@ def _run( package_name: str, force_fresh: bool = False, ) -> dict[str, Any]: - """ - Load cached lesson content. - - Args: - package_name: Name of the package to load lesson for. - force_fresh: If True, skip cache and return cache miss. - - Returns: - Dict with cached lesson or cache miss indicator. - """ + """Load cached lesson content.""" if force_fresh: return { "success": True, @@ -79,7 +43,7 @@ def _run( "success": True, "cache_hit": True, "lesson": cached, - "cost_saved_gbp": 0.02, # Estimated LLM cost saved + "cost_saved_gbp": 0.02, } return { @@ -97,31 +61,13 @@ def _run( "error": str(e), } - async def _arun( - self, - package_name: str, - force_fresh: bool = False, - ) -> dict[str, Any]: - """Async version - delegates to sync implementation.""" - return self._run(package_name, force_fresh) - def cache_lesson( self, package_name: str, lesson: dict[str, Any], ttl_hours: int = 24, ) -> bool: - """ - Cache a lesson for future retrieval. - - Args: - package_name: Name of the package. - lesson: Lesson content to cache. - ttl_hours: Time-to-live in hours. - - Returns: - True if cached successfully. - """ + """Cache a lesson for future retrieval.""" try: self.store.cache_lesson(package_name, lesson, ttl_hours) return True @@ -129,25 +75,8 @@ def cache_lesson( return False def clear_cache(self, package_name: str | None = None) -> int: - """ - Clear cached lessons. - - Args: - package_name: Specific package to mark as expired (makes it - unretrievable via get_cached_lesson). If None, removes - only already-expired entries from the database. - - Returns: - int: For specific package - 1 if marked as expired, 0 on error. - For None - number of expired entries actually deleted. - - Note: - When package_name is provided, this marks the entry as expired - by calling cache_lesson with ttl_hours=0, rather than deleting it. - The entry persists until clear_expired_cache() runs. - """ + """Clear cached lessons.""" if package_name: - # Mark specific package as expired by caching empty with 0 TTL try: self.store.cache_lesson(package_name, {}, ttl_hours=0) return 1 @@ -158,8 +87,6 @@ def clear_cache(self, package_name: str | None = None) -> int: # Pre-built lesson templates for common packages -# These can be used as fallbacks when LLM is unavailable - FALLBACK_LESSONS = { "docker": { "package_name": "docker", @@ -182,7 +109,7 @@ def clear_cache(self, package_name: str | None = None) -> int: "Use .dockerignore to exclude unnecessary files", ], "installation_command": "apt install docker.io", - "confidence": 0.7, # Lower confidence for fallback + "confidence": 0.7, }, "git": { "package_name": "git", @@ -233,15 +160,7 @@ def clear_cache(self, package_name: str | None = None) -> int: def get_fallback_lesson(package_name: str) -> dict[str, Any] | None: - """ - Get a fallback lesson template for common packages. - - Args: - package_name: Name of the package. - - Returns: - Fallback lesson dict or None. - """ + """Get a fallback lesson template for common packages.""" return FALLBACK_LESSONS.get(package_name.lower()) @@ -249,16 +168,7 @@ def load_lesson_with_fallback( package_name: str, db_path: Path | None = None, ) -> dict[str, Any]: - """ - Load lesson from cache with fallback to templates. - - Args: - package_name: Name of the package. - db_path: Optional database path. - - Returns: - Lesson content dict. - """ + """Load lesson from cache with fallback to templates.""" loader = LessonLoaderTool(db_path) result = loader._run(package_name) diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py index b7f932e8..c19fc643 100644 --- a/cortex/tutor/tools/deterministic/progress_tracker.py +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -2,60 +2,25 @@ Progress Tracker Tool - Deterministic tool for learning progress management. This tool does NOT use LLM calls - it is fast, free, and predictable. -Used for tracking learning progress via SQLite operations. """ -from datetime import datetime from pathlib import Path from typing import Any -from langchain.tools import BaseTool -from pydantic import Field - from cortex.tutor.config import get_config from cortex.tutor.memory.sqlite_store import ( LearningProgress, SQLiteStore, - StudentProfile, ) -class ProgressTrackerTool(BaseTool): - """ - Deterministic tool for tracking learning progress. - - This tool manages SQLite-based progress tracking including: - - Recording topic completions - - Tracking time spent - - Managing student profiles - - Retrieving progress statistics - - No LLM calls are made - pure database operations. - """ - - name: str = "progress_tracker" - description: str = ( - "Track learning progress for packages and topics. " - "Use this to record completions, get progress stats, and manage student profiles. " - "This is a fast, deterministic tool with no LLM cost." - ) +class ProgressTrackerTool: + """Deterministic tool for tracking learning progress.""" - store: SQLiteStore | None = Field(default=None, exclude=True) - - # Error message constants _ERR_PKG_TOPIC_REQUIRED: str = "package_name and topic required" - class Config: - arbitrary_types_allowed = True - def __init__(self, db_path: Path | None = None) -> None: - """ - Initialize the progress tracker tool. - - Args: - db_path: Path to SQLite database. Uses config default if not provided. - """ - super().__init__() + """Initialize the progress tracker tool.""" if db_path is None: config = get_config() db_path = config.get_db_path() @@ -70,20 +35,7 @@ def _run( time_seconds: int | None = None, **kwargs: Any, ) -> dict[str, Any]: - """ - Execute a progress tracking action. - - Args: - action: Action to perform (get_progress, mark_completed, get_stats, etc.) - package_name: Name of the package (required for most actions) - topic: Topic within the package - score: Score achieved (0.0 to 1.0) - time_seconds: Time spent in seconds - **kwargs: Additional arguments for specific actions - - Returns: - Dict containing action results - """ + """Execute a progress tracking action.""" actions = { "get_progress": self._get_progress, "get_all_progress": self._get_all_progress, @@ -115,10 +67,6 @@ def _run( except Exception as e: return {"success": False, "error": str(e)} - async def _arun(self, *args: Any, **kwargs: Any) -> dict[str, Any]: - """Async version - delegates to sync implementation.""" - return self._run(*args, **kwargs) - def _get_progress( self, package_name: str | None, @@ -197,7 +145,6 @@ def _update_progress( if not package_name or not topic: return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} - # Get existing progress to preserve values existing = self.store.get_progress(package_name, topic) total_time = (existing.total_time_seconds if existing else 0) + (time_seconds or 0) @@ -290,7 +237,7 @@ def _reset_progress( """Reset learning progress.""" count = self.store.reset_progress(package_name) scope = f"for {package_name}" if package_name else "all" - return {"success": True, "message": f"Reset {count} progress records {scope}"} + 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.""" @@ -302,48 +249,21 @@ def _get_packages_studied(self, **kwargs: Any) -> dict[str, Any]: def get_learning_progress(package_name: str, topic: str) -> dict[str, Any] | None: - """ - Get learning progress for a specific topic. - - Args: - package_name: Name of the package. - topic: Topic within the package. - - Returns: - Progress dictionary or 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. - - Args: - package_name: Name of the package. - topic: Topic to mark as completed. - score: Score achieved (0.0 to 1.0). - - Returns: - True if successful. - """ + """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. - - Args: - package_name: Name of the package. - - Returns: - Statistics dictionary. - """ + """Get completion statistics for a package.""" tool = ProgressTrackerTool() result = tool._run("get_stats", package_name=package_name) return result.get("stats", {}) From 1b9c88a7f96e423ef10f298344e99541d92780b8 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 16:46:56 +0530 Subject: [PATCH 23/32] fix: black formatting and add exception logging - Fix Black formatting in progress_tracker.py - Add logger.exception for better debugging on failures --- cortex/tutor/tools/deterministic/progress_tracker.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py index c19fc643..fd4af616 100644 --- a/cortex/tutor/tools/deterministic/progress_tracker.py +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -4,6 +4,7 @@ This tool does NOT use LLM calls - it is fast, free, and predictable. """ +import logging from pathlib import Path from typing import Any @@ -13,6 +14,8 @@ SQLiteStore, ) +logger = logging.getLogger(__name__) + class ProgressTrackerTool: """Deterministic tool for tracking learning progress.""" @@ -65,6 +68,7 @@ def _run( **kwargs, ) except Exception as e: + logger.exception("Progress tracker action '%s' failed", action) return {"success": False, "error": str(e)} def _get_progress( @@ -237,7 +241,11 @@ def _reset_progress( """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}"} + 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.""" From 7f0b30db51bfc605d9cc796018d45dbd3e62223c Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 16:53:28 +0530 Subject: [PATCH 24/32] fix: handle zero score correctly in progress tracker - Use explicit None check instead of falsy check for score - Preserve existing completed state when updating progress --- cortex/tutor/tools/deterministic/progress_tracker.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py index fd4af616..1672d9b6 100644 --- a/cortex/tutor/tools/deterministic/progress_tracker.py +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -129,11 +129,12 @@ def _mark_completed( if not package_name or not topic: return {"success": False, "error": self._ERR_PKG_TOPIC_REQUIRED} - self.store.mark_topic_completed(package_name, topic, score or 1.0) + 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": score or 1.0, + "score": effective_score, } def _update_progress( @@ -155,8 +156,8 @@ def _update_progress( progress = LearningProgress( package_name=package_name, topic=topic, - completed=completed, - score=score or (existing.score if existing else 0.0), + completed=completed if completed else (existing.completed if existing else False), + score=score if score is not None else (existing.score if existing else 0.0), total_time_seconds=total_time, ) row_id = self.store.upsert_progress(progress) From b0a19c550de852ba2bc19ee2a0f281a83f8efc46 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 17:08:11 +0530 Subject: [PATCH 25/32] refactor: fix unused kwargs warnings and nested conditionals --- .../tools/deterministic/progress_tracker.py | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py index 1672d9b6..7cd2d2fb 100644 --- a/cortex/tutor/tools/deterministic/progress_tracker.py +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -75,7 +75,7 @@ def _get_progress( self, package_name: str | None, topic: str | None, - **kwargs: Any, + **_kwargs: Any, ) -> dict[str, Any]: """Get progress for a specific package/topic.""" if not package_name or not topic: @@ -99,7 +99,7 @@ def _get_progress( def _get_all_progress( self, package_name: str | None = None, - **kwargs: Any, + **_kwargs: Any, ) -> dict[str, Any]: """Get all progress, optionally filtered by package.""" progress_list = self.store.get_all_progress(package_name) @@ -123,7 +123,7 @@ def _mark_completed( package_name: str | None, topic: str | None, score: float | None = None, - **kwargs: Any, + **_kwargs: Any, ) -> dict[str, Any]: """Mark a topic as completed.""" if not package_name or not topic: @@ -144,7 +144,7 @@ def _update_progress( score: float | None = None, time_seconds: int | None = None, completed: bool = False, - **kwargs: Any, + **_kwargs: Any, ) -> dict[str, Any]: """Update progress for a topic.""" if not package_name or not topic: @@ -153,11 +153,22 @@ def _update_progress( 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: + 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=completed if completed else (existing.completed if existing else False), - score=score if score is not None else (existing.score if existing else 0.0), + completed=final_completed, + score=final_score, total_time_seconds=total_time, ) row_id = self.store.upsert_progress(progress) @@ -170,7 +181,7 @@ def _update_progress( def _get_stats( self, package_name: str | None, - **kwargs: Any, + **_kwargs: Any, ) -> dict[str, Any]: """Get completion statistics for a package.""" if not package_name: @@ -185,7 +196,7 @@ def _get_stats( ), } - def _get_profile(self, **kwargs: Any) -> dict[str, Any]: + def _get_profile(self, **_kwargs: Any) -> dict[str, Any]: """Get student profile.""" profile = self.store.get_student_profile() return { @@ -201,7 +212,7 @@ def _get_profile(self, **kwargs: Any) -> dict[str, Any]: def _update_profile( self, learning_style: str | None = None, - **kwargs: Any, + **_kwargs: Any, ) -> dict[str, Any]: """Update student profile.""" profile = self.store.get_student_profile() @@ -237,7 +248,7 @@ def _add_weak_concept( def _reset_progress( self, package_name: str | None = None, - **kwargs: Any, + **_kwargs: Any, ) -> dict[str, Any]: """Reset learning progress.""" count = self.store.reset_progress(package_name) @@ -248,7 +259,7 @@ def _reset_progress( "message": f"Reset {count} progress records {scope}", } - def _get_packages_studied(self, **kwargs: Any) -> dict[str, Any]: + 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)} From 284f119db786737a5b22cd35098447fb7c1e8d57 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 17:17:27 +0530 Subject: [PATCH 26/32] fix: allow explicit completed=False in update_progress --- cortex/tutor/tools/deterministic/progress_tracker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortex/tutor/tools/deterministic/progress_tracker.py b/cortex/tutor/tools/deterministic/progress_tracker.py index 7cd2d2fb..e1252f7f 100644 --- a/cortex/tutor/tools/deterministic/progress_tracker.py +++ b/cortex/tutor/tools/deterministic/progress_tracker.py @@ -143,7 +143,7 @@ def _update_progress( topic: str | None, score: float | None = None, time_seconds: int | None = None, - completed: bool = False, + completed: bool | None = None, **_kwargs: Any, ) -> dict[str, Any]: """Update progress for a topic.""" @@ -154,7 +154,7 @@ def _update_progress( total_time = (existing.total_time_seconds if existing else 0) + (time_seconds or 0) # Preserve existing values if not explicitly provided - if completed: + if completed is not None: final_completed = completed else: final_completed = existing.completed if existing else False From db6a101c0d0e3560d853526a9e8666c27a4a0685 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Tue, 13 Jan 2026 17:50:40 +0530 Subject: [PATCH 27/32] docs: update AI_TUTOR.md to reflect simplified architecture --- docs/AI_TUTOR.md | 204 +++++++++++++---------------------------------- 1 file changed, 55 insertions(+), 149 deletions(-) diff --git a/docs/AI_TUTOR.md b/docs/AI_TUTOR.md index a3c9c750..776e5089 100644 --- a/docs/AI_TUTOR.md +++ b/docs/AI_TUTOR.md @@ -3,7 +3,7 @@ > **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/) -[![Test Coverage](https://img.shields.io/badge/coverage-85.6%25-brightgreen.svg)](https://github.com/cortexlinux/cortex) +[![Test Coverage](https://img.shields.io/badge/coverage-74%25-brightgreen.svg)](https://github.com/cortexlinux/cortex) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/licenses/Apache-2.0) --- @@ -268,20 +268,20 @@ cortex tutor --reset docker │ TutorAgent │ │ (cortex/tutor/agents/tutor_agent/) │ │ │ -│ • Orchestrates the Plan→Act→Reflect workflow │ -│ • Manages state across phases │ -│ • Coordinates tools and LLM calls │ +│ • Orchestrates lesson generation and Q&A │ +│ • Uses cortex.llm_router for LLM calls │ +│ • Coordinates tools and caching │ └─────────────────────────────────────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Deterministic │ │ Agentic │ │ Memory │ -│ Tools │ │ Tools │ │ Layer │ +│ Deterministic │ │ LLM Layer │ │ Memory │ +│ Tools │ │ (llm.py) │ │ Layer │ │ │ │ │ │ │ -│ • validators │ │ • lesson_gen │ │ • SQLite store │ -│ • lesson_loader │ │ • examples │ │ • Cache mgmt │ -│ • progress_trk │ │ • qa_handler │ │ • Progress DB │ +│ • validators │ │ • llm_router │ │ • SQLite store │ +│ • lesson_loader │ │ • generate_lesson│ │ • Cache mgmt │ +│ • progress_trk │ │ • answer_question│ │ • Progress DB │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ │ │ │ ▼ │ @@ -303,13 +303,13 @@ cortex tutor --reset docker └─────────────────────────────────────────────────────────────────┘ ``` -### Plan→Act→Reflect Pattern +### Cache-First Pattern -The tutor uses **LangGraph** to implement a robust 3-phase workflow: +The tutor uses a simple cache-first pattern to minimize API costs: ``` ┌─────────────────────────────────────────────────────────────────┐ -│ PLAN PHASE │ +│ CHECK CACHE │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Check Cache │───▶│ Has Cache? │───▶│ Use Cached │ │ @@ -317,48 +317,35 @@ The tutor uses **LangGraph** to implement a robust 3-phase workflow: │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ N │ │ ▼ │ -│ ┌──────────────┐ │ -│ │ LLM Planner │ │ -│ │ (~$0.01) │ │ -│ └──────────────┘ │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ ACT PHASE │ +│ GENERATE CONTENT │ │ │ │ ┌────────────────────────┐ ┌────────────────────────┐ │ -│ │ Deterministic Tools │ │ Agentic Tools │ │ -│ │ (FREE) │ │ (LLM-Powered) │ │ +│ │ cortex.llm_router │───▶│ Claude API │ │ │ │ │ │ │ │ -│ │ • Load user progress │ │ • Generate lesson │ │ -│ │ • Validate inputs │ │ • Create examples │ │ -│ │ • Check cache │ │ • Answer questions │ │ -│ │ • Track time │ │ • Build tutorials │ │ +│ │ • generate_lesson() │ │ • Lesson content │ │ +│ │ • answer_question() │ │ • Q&A responses │ │ │ └────────────────────────┘ └────────────────────────┘ │ +│ │ +│ Cost: ~$0.01-0.02 per request │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ REFLECT PHASE │ +│ CACHE & RETURN │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ -│ │ Validate │───▶│ Passed? │───▶│ Cache & │ │ -│ │ Output │ │ │ Y │ Return │ │ -│ │ (FREE) │ └──────────────┘ └──────────────┘ │ -│ │ N │ -│ ▼ │ -│ ┌──────────────┐ │ -│ │ Retry or │ │ -│ │ Fallback │ │ -│ └──────────────┘ │ +│ │ Parse JSON │───▶│ Cache Result │───▶│ Return │ │ +│ │ Response │ │ (24h TTL) │ │ to User │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────────────────────┘ ``` ### Tool Classification -Tools are classified by whether they require LLM calls: - ``` ┌─────────────────────────────────────────────────────────────────┐ │ DETERMINISTIC TOOLS │ @@ -379,19 +366,21 @@ Tools are classified by whether they require LLM calls: └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ -│ AGENTIC TOOLS │ -│ (LLM-Powered - Smart) │ +│ LLM FUNCTIONS │ +│ (cortex/tutor/llm.py) │ ├─────────────────────────────────────────────────────────────────┤ │ │ -│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ -│ │lesson_generator │ │examples_provider│ │ qa_handler │ │ -│ │ │ │ │ │ │ │ -│ │ • Explanations │ │ • Code samples │ │ • Q&A responses │ │ -│ │ • Concepts │ │ • Use cases │ │ • Follow-ups │ │ -│ │ • Theory │ │ • Best practice │ │ • Clarifications│ │ -│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ ┌─────────────────────────────┐ ┌─────────────────────────┐ │ +│ │ generate_lesson() │ │ answer_question() │ │ +│ │ │ │ │ │ +│ │ • Summary & explanation │ │ • Q&A responses │ │ +│ │ • Code examples │ │ • Code examples │ │ +│ │ • Tutorial steps │ │ • Related topics │ │ +│ │ • Best practices │ │ • Confidence score │ │ +│ └─────────────────────────────┘ └─────────────────────────┘ │ │ │ -│ Speed: 2-5s | Cost: ~$0.02 | Reliability: 95%+ (with fallback) │ +│ Uses cortex.llm_router for task-aware routing │ +│ Speed: 2-5s | Cost: ~$0.01-0.02 | Reliability: 95%+ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -417,8 +406,8 @@ User Input Processing Output │ Cache Miss │ ▼ │ ┌───────────────┐ ┌───────────────┐ │ -│ LangGraph │────▶│ Claude API │ │ -│ Workflow │ └───────────────┘ │ +│ llm_router │────▶│ Claude API │ │ +│ │ └───────────────┘ │ └───────────────┘ │ │ │ │ Generated Content │ @@ -446,45 +435,33 @@ cortex/tutor/ ├── 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/ # LangGraph Agents +├── agents/ # Agent implementations │ └── tutor_agent/ │ ├── __init__.py -│ ├── tutor_agent.py # Main TutorAgent class -│ ├── graph.py # LangGraph workflow definition -│ └── state.py # TypedDict state management +│ └── 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 -│ │ -│ └── agentic/ # LLM-powered (smart, costs $) -│ ├── lesson_generator.py # Generate lesson content -│ ├── examples_provider.py# Generate code examples -│ └── qa_handler.py # Handle Q&A interactions +│ └── deterministic/ # No LLM required (fast, free) +│ ├── progress_tracker.py # SQLite progress operations +│ ├── lesson_loader.py # Cache and fallback loading +│ └── validators.py # Input validation │ -├── contracts/ # Pydantic output schemas +├── contracts/ # Data structures │ ├── lesson_context.py # Lesson data structure │ └── progress_context.py # Progress data structure │ -├── prompts/ # 7-Layer prompt templates -│ ├── agents/tutor/system.md # TutorAgent system prompt -│ └── tools/ -│ ├── lesson_generator.md -│ ├── examples_provider.md -│ └── qa_handler.md -│ ├── memory/ # Persistence layer │ └── sqlite_store.py # SQLite operations │ -└── tests/ # Test suite (87% coverage) - ├── test_tutor_agent.py +└── tests/ # Test suite (74% coverage) + ├── test_cli.py ├── test_tools.py ├── test_progress_tracker.py ├── test_integration.py - └── ... + ├── test_interactive_tutor.py + └── test_validators.py ``` ### Database Schema @@ -531,76 +508,6 @@ CREATE TABLE lesson_cache ( ); ``` -### State Management - -The TutorAgent uses TypedDict for type-safe state: - -```python -class TutorAgentState(TypedDict): - """State passed through the LangGraph workflow.""" - - # Input - input: Dict[str, Any] # package_name, question, etc. - force_fresh: bool # Skip cache flag - - # Planning - plan: Dict[str, Any] # Plan phase output - cache_hit: bool # Whether cache was used - - # Context - student_profile: Dict # User's learning history - lesson_content: Dict # Current lesson data - - # Execution - results: Dict[str, Any] # Tool execution results - errors: List[Dict] # Any errors encountered - - # Metadata - cost_gbp: float # Accumulated API cost - checkpoints: List[str] # Workflow checkpoints -``` - -### 7-Layer Prompt Architecture - -Each prompt follows a structured format: - -```markdown -# Layer 1: IDENTITY -You are an expert Linux package tutor... - -# Layer 2: ROLE & BOUNDARIES -You can explain packages, provide examples... -You cannot execute commands, access the filesystem... - -# Layer 3: ANTI-HALLUCINATION RULES -- NEVER invent package features -- NEVER claim capabilities that don't exist -- If unsure, say "I don't have information about..." - -# Layer 4: CONTEXT & INPUTS -Package: {package_name} -User Level: {skill_level} -Previous Topics: {completed_topics} - -# Layer 5: TOOLS & USAGE -Available: lesson_generator, examples_provider -When to use each tool... - -# Layer 6: WORKFLOW & REASONING -1. First, assess user's current knowledge -2. Then, generate appropriate content -3. Finally, validate and structure output - -# Layer 7: OUTPUT FORMAT -Return JSON matching LessonContext schema: -{ - "summary": "...", - "explanation": "...", - "code_examples": [...], - "best_practices": [...] -} -``` - --- ## API Reference @@ -750,17 +657,16 @@ pytest cortex/tutor/tests/test_tutor_agent.py -v ### Test Coverage -Current coverage: **85.6%** (266 tests) +Current coverage: **74%** (158 tests) | Module | Coverage | |--------|----------| -| `agents/tutor_agent/graph.py` | 99% | -| `agents/tutor_agent/state.py` | 97% | -| `agents/tutor_agent/tutor_agent.py` | 88% | -| `memory/sqlite_store.py` | 95% | -| `tools/deterministic/validators.py` | 95% | -| `branding.py` | 92% | -| `cli.py` | 89% | +| `agents/tutor_agent/tutor_agent.py` | 59% | +| `memory/sqlite_store.py` | 92% | +| `tools/deterministic/validators.py` | 92% | +| `tools/deterministic/progress_tracker.py` | 84% | +| `tools/deterministic/lesson_loader.py` | 75% | +| `cli.py` | 91% | --- From bbe7029a175a67a99f6e7f2ff5e0cde97d9bce1d Mon Sep 17 00:00:00 2001 From: Vamsi Date: Wed, 14 Jan 2026 16:45:41 +0530 Subject: [PATCH 28/32] test: add llm.py tests, update coverage docs --- docs/AI_TUTOR.md | 10 +- tests/tutor/test_llm.py | 253 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 tests/tutor/test_llm.py diff --git a/docs/AI_TUTOR.md b/docs/AI_TUTOR.md index 776e5089..0c3c4974 100644 --- a/docs/AI_TUTOR.md +++ b/docs/AI_TUTOR.md @@ -3,7 +3,7 @@ > **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/) -[![Test Coverage](https://img.shields.io/badge/coverage-74%25-brightgreen.svg)](https://github.com/cortexlinux/cortex) +[![Test Coverage](https://img.shields.io/badge/coverage->80%25-brightgreen.svg)](https://github.com/cortexlinux/cortex) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/licenses/Apache-2.0) --- @@ -657,16 +657,16 @@ pytest cortex/tutor/tests/test_tutor_agent.py -v ### Test Coverage -Current coverage: **74%** (158 tests) +Current coverage: **>80%** (172 tests) | Module | Coverage | |--------|----------| -| `agents/tutor_agent/tutor_agent.py` | 59% | +| `llm.py` | 100% | | `memory/sqlite_store.py` | 92% | | `tools/deterministic/validators.py` | 92% | -| `tools/deterministic/progress_tracker.py` | 84% | -| `tools/deterministic/lesson_loader.py` | 75% | | `cli.py` | 91% | +| `tools/deterministic/progress_tracker.py` | 81% | +| `tools/deterministic/lesson_loader.py` | 72% | --- diff --git a/tests/tutor/test_llm.py b/tests/tutor/test_llm.py new file mode 100644 index 00000000..c90a790a --- /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"] == 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"] == 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"] From bc773d33b317dcdabb843767d362f7300fd4ac69 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Wed, 14 Jan 2026 17:03:30 +0530 Subject: [PATCH 29/32] fix: use pytest.approx for floats, suppress config warning --- pyproject.toml | 1 + tests/tutor/test_llm.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a2b71afc..e2668fba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/test_llm.py b/tests/tutor/test_llm.py index c90a790a..56ae4164 100644 --- a/tests/tutor/test_llm.py +++ b/tests/tutor/test_llm.py @@ -105,7 +105,7 @@ def test_generate_lesson_success(self): assert result["success"] is True assert result["lesson"]["summary"] == "Test summary" - assert result["cost_usd"] == 0.01 + assert result["cost_usd"] == pytest.approx(0.01) def test_generate_lesson_with_options(self): """Test lesson generation with custom options.""" @@ -194,7 +194,7 @@ def test_answer_question_success(self): assert result["success"] is True assert "containerization" in result["answer"]["answer"] - assert result["cost_usd"] == 0.005 + assert result["cost_usd"] == pytest.approx(0.005) def test_answer_question_with_context(self): """Test question answering with context.""" From 1c08c7fbeb8d61680ab1487658d53e768c68f911 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Fri, 16 Jan 2026 16:44:00 +0530 Subject: [PATCH 30/32] docs: fix placeholder URL in AI_TUTOR.md --- docs/AI_TUTOR.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AI_TUTOR.md b/docs/AI_TUTOR.md index 5e2358c3..a41bc740 100644 --- a/docs/AI_TUTOR.md +++ b/docs/AI_TUTOR.md @@ -716,7 +716,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines. ```bash # Clone and setup -git clone https://github.com/YOUR_USERNAME/cortex.git +git clone https://github.com/cortexlinux/cortex.git cd cortex # Create venv and install dev dependencies From 1e12ac33abdfb46c626058983e8355a9083b6df2 Mon Sep 17 00:00:00 2001 From: Vamsi Date: Fri, 16 Jan 2026 16:55:06 +0530 Subject: [PATCH 31/32] docs: remove hardcoded model versions from AI_TUTOR.md --- docs/AI_TUTOR.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/AI_TUTOR.md b/docs/AI_TUTOR.md index a41bc740..ac8a4f0b 100644 --- a/docs/AI_TUTOR.md +++ b/docs/AI_TUTOR.md @@ -605,10 +605,7 @@ exit_code = cmd_reset(package_name="docker") # Specific ```bash # Required: API key for Claude -export ANTHROPIC_API_KEY=sk-ant-... - -# Optional: Override model (default: claude-sonnet-4-20250514) -export TUTOR_MODEL=claude-sonnet-4-20250514 +export ANTHROPIC_API_KEY=your-api-key-here # Optional: Enable debug output export TUTOR_DEBUG=true @@ -623,7 +620,6 @@ Configuration can also be set in `~/.cortex/config.yaml`: ```yaml tutor: - model: claude-sonnet-4-20250514 cache_ttl_hours: 24 max_retries: 3 debug: false From a96a3039f10bc36eee9dea82f415483556b5b9dc Mon Sep 17 00:00:00 2001 From: Vamsi Date: Fri, 16 Jan 2026 17:11:13 +0530 Subject: [PATCH 32/32] docs: add TUTOR_OFFLINE to config section and fix placeholders --- docs/AI_TUTOR.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/AI_TUTOR.md b/docs/AI_TUTOR.md index ac8a4f0b..291fc0be 100644 --- a/docs/AI_TUTOR.md +++ b/docs/AI_TUTOR.md @@ -612,6 +612,9 @@ 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 @@ -623,6 +626,7 @@ tutor: cache_ttl_hours: 24 max_retries: 3 debug: false + offline: false ``` --- @@ -653,10 +657,10 @@ pytest tests/tutor/test_tutor_agent.py -v ```bash # Set the API key -export ANTHROPIC_API_KEY=sk-ant-your-key-here +export ANTHROPIC_API_KEY=your-api-key-here # Or add to ~/.cortex/.env -echo 'ANTHROPIC_API_KEY=sk-ant-...' >> ~/.cortex/.env +echo 'ANTHROPIC_API_KEY=your-api-key-here' >> ~/.cortex/.env ```