From aa7834a7f3d113d2b78774f60e7ab0d50275fede Mon Sep 17 00:00:00 2001 From: Shree Date: Mon, 29 Dec 2025 15:26:16 +0530 Subject: [PATCH 1/2] feat: Enhance cortex ask with interactive tutor capabilities (Issue #393) - Enhanced system prompt to detect educational vs diagnostic queries - Added LearningTracker class for tracking educational topics - Learning history stored in ~/.cortex/learning_history.json - Increased max_tokens from 500 to 2000 for longer responses - Added terminal-friendly formatting rules - Rich Markdown rendering for proper terminal display - Added 25 new unit tests (50 total) for ask module - Updated COMMANDS.md with cortex ask documentation --- cortex/ask.py | 218 ++++++++++++++++++++++++++++++++++++++++++-- cortex/cli.py | 5 +- docs/COMMANDS.md | 78 ++++++++++++++++ tests/test_ask.py | 228 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 518 insertions(+), 11 deletions(-) diff --git a/cortex/ask.py b/cortex/ask.py index 2aa0b932..d1078065 100644 --- a/cortex/ask.py +++ b/cortex/ask.py @@ -1,15 +1,19 @@ """Natural language query interface for Cortex. Handles user questions about installed packages, configurations, -and system state using LLM with semantic caching. +and system state using LLM with semantic caching. Also provides +educational content and tracks learning progress. """ import json import os import platform +import re import shutil import sqlite3 import subprocess +from datetime import datetime +from pathlib import Path from typing import Any @@ -132,6 +136,134 @@ def gather_context(self) -> dict[str, Any]: } +class LearningTracker: + """Tracks educational topics the user has explored.""" + + PROGRESS_FILE = Path.home() / ".cortex" / "learning_history.json" + + # Patterns that indicate educational questions + EDUCATIONAL_PATTERNS = [ + r"^explain\b", + r"^teach\s+me\b", + r"^what\s+is\b", + r"^what\s+are\b", + r"^how\s+does\b", + r"^how\s+do\b", + r"^how\s+to\b", + r"\bbest\s+practices?\b", + r"^tutorial\b", + r"^guide\s+to\b", + r"^learn\s+about\b", + r"^introduction\s+to\b", + r"^basics\s+of\b", + ] + + def __init__(self): + """Initialize the learning tracker.""" + self._compiled_patterns = [ + re.compile(p, re.IGNORECASE) for p in self.EDUCATIONAL_PATTERNS + ] + + def is_educational_query(self, question: str) -> bool: + """Determine if a question is educational in nature.""" + for pattern in self._compiled_patterns: + if pattern.search(question): + return True + return False + + def extract_topic(self, question: str) -> str: + """Extract the main topic from an educational question.""" + # Remove common prefixes + topic = question.lower() + prefixes_to_remove = [ + r"^explain\s+", + r"^teach\s+me\s+about\s+", + r"^teach\s+me\s+", + r"^what\s+is\s+", + r"^what\s+are\s+", + r"^how\s+does\s+", + r"^how\s+do\s+", + r"^how\s+to\s+", + r"^tutorial\s+on\s+", + r"^guide\s+to\s+", + r"^learn\s+about\s+", + r"^introduction\s+to\s+", + r"^basics\s+of\s+", + r"^best\s+practices\s+for\s+", + ] + for prefix in prefixes_to_remove: + topic = re.sub(prefix, "", topic, flags=re.IGNORECASE) + + # Clean up and truncate + topic = topic.strip("? ").strip() + # Take first 50 chars as topic identifier + if len(topic) > 50: + topic = topic[:50].rsplit(" ", 1)[0] + return topic + + def record_topic(self, question: str) -> None: + """Record that the user explored an educational topic.""" + if not self.is_educational_query(question): + return + + topic = self.extract_topic(question) + if not topic: + return + + history = self._load_history() + + # Update or add topic + if topic in history["topics"]: + history["topics"][topic]["count"] += 1 + history["topics"][topic]["last_accessed"] = datetime.now().isoformat() + else: + history["topics"][topic] = { + "count": 1, + "first_accessed": datetime.now().isoformat(), + "last_accessed": datetime.now().isoformat(), + } + + history["total_queries"] = history.get("total_queries", 0) + 1 + self._save_history(history) + + def get_history(self) -> dict[str, Any]: + """Get the learning history.""" + return self._load_history() + + def get_recent_topics(self, limit: int = 5) -> list[str]: + """Get recently explored topics.""" + history = self._load_history() + topics = history.get("topics", {}) + + # Sort by last_accessed + sorted_topics = sorted( + topics.items(), + key=lambda x: x[1].get("last_accessed", ""), + reverse=True, + ) + return [t[0] for t in sorted_topics[:limit]] + + def _load_history(self) -> dict[str, Any]: + """Load learning history from file.""" + if not self.PROGRESS_FILE.exists(): + return {"topics": {}, "total_queries": 0} + + try: + with open(self.PROGRESS_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {"topics": {}, "total_queries": 0} + + def _save_history(self, history: dict[str, Any]) -> None: + """Save learning history to file.""" + try: + self.PROGRESS_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(self.PROGRESS_FILE, "w") as f: + json.dump(history, f, indent=2) + except OSError: + pass # Silently fail if we can't write + + class AskHandler: """Handles natural language questions about the system.""" @@ -155,6 +287,7 @@ def __init__( self.offline = offline self.model = model or self._default_model() self.info_gatherer = SystemInfoGatherer() + self.learning_tracker = LearningTracker() # Initialize cache try: @@ -201,18 +334,63 @@ def _initialize_client(self): raise ValueError(f"Unsupported provider: {self.provider}") def _get_system_prompt(self, context: dict[str, Any]) -> str: - return f"""You are a helpful Linux system assistant. Answer questions about the user's system clearly and concisely. + return f"""You are a helpful Linux system assistant and tutor. You help users with both system-specific questions AND educational queries about Linux, packages, and best practices. System Context: {json.dumps(context, indent=2)} -Rules: -1. Provide direct, human-readable answers -2. Use the system context to give accurate information +## Query Type Detection + +Automatically detect the type of question and respond appropriately: + +### Educational Questions (tutorials, explanations, learning) +Triggered by questions like: "explain...", "teach me...", "how does X work", "what is...", "best practices for...", "tutorial on...", "learn about...", "guide to..." + +For educational questions: +1. Provide structured, tutorial-style explanations +2. Include practical code examples with proper formatting +3. Highlight best practices and common pitfalls to avoid +4. Break complex topics into digestible sections +5. Use clear section labels and bullet points for readability +6. Mention related topics the user might want to explore next +7. Tailor examples to the user's system when relevant (e.g., use apt for Debian-based systems) + +### Diagnostic Questions (system-specific, troubleshooting) +Triggered by questions about: current system state, "why is my...", "what packages...", "check my...", specific errors, system status + +For diagnostic questions: +1. Analyze the provided system context +2. Give specific, actionable answers 3. Be concise but informative 4. If you don't have enough information, say so clearly -5. For package compatibility questions, consider the system's Python version and OS -6. Return ONLY the answer text, no JSON or markdown formatting""" + +## Output Formatting Rules (CRITICAL - Follow exactly) +1. NEVER use markdown headings (# or ##) - they render poorly in terminals +2. For section titles, use **Bold Text** on its own line instead +3. Use bullet points (-) for lists +4. Use numbered lists (1. 2. 3.) for sequential steps +5. Use triple backticks with language name for code blocks (```bash) +6. Use *italic* sparingly for emphasis +7. Keep lines under 100 characters when possible +8. Add blank lines between sections for readability +9. For tables, use simple text formatting, not markdown tables + +Example of good formatting: +**Installation Steps** + +1. Update your package list: +```bash +sudo apt update +``` + +2. Install the package: +```bash +sudo apt install nginx +``` + +**Key Points** +- Point one here +- Point two here""" def _call_openai(self, question: str, system_prompt: str) -> str: response = self.client.chat.completions.create( @@ -222,7 +400,7 @@ def _call_openai(self, question: str, system_prompt: str) -> str: {"role": "user", "content": question}, ], temperature=0.3, - max_tokens=500, + max_tokens=2000, ) # Defensive: content may be None or choices could be empty in edge cases try: @@ -234,7 +412,7 @@ def _call_openai(self, question: str, system_prompt: str) -> str: def _call_claude(self, question: str, system_prompt: str) -> str: response = self.client.messages.create( model=self.model, - max_tokens=500, + max_tokens=2000, temperature=0.3, system=system_prompt, messages=[{"role": "user", "content": question}], @@ -344,4 +522,26 @@ def ask(self, question: str) -> str: except (OSError, sqlite3.Error): pass # Silently fail cache writes + # Track educational topics for learning history + self.learning_tracker.record_topic(question) + return answer + + def get_learning_history(self) -> dict[str, Any]: + """Get the user's learning history. + + Returns: + Dictionary with topics explored and statistics + """ + return self.learning_tracker.get_history() + + def get_recent_topics(self, limit: int = 5) -> list[str]: + """Get recently explored educational topics. + + Args: + limit: Maximum number of topics to return + + Returns: + List of topic strings + """ + return self.learning_tracker.get_recent_topics(limit) diff --git a/cortex/cli.py b/cortex/cli.py index 274a4f55..8c5f7d33 100644 --- a/cortex/cli.py +++ b/cortex/cli.py @@ -6,6 +6,8 @@ from datetime import datetime from typing import Any +from rich.markdown import Markdown + from cortex.ask import AskHandler from cortex.branding import VERSION, console, cx_header, cx_print, show_banner from cortex.coordinator import InstallationCoordinator, StepStatus @@ -297,7 +299,8 @@ def ask(self, question: str) -> int: offline=self.offline, ) answer = handler.ask(question) - console.print(answer) + # Render as markdown for proper formatting in terminal + console.print(Markdown(answer)) return 0 except ImportError as e: # Provide a helpful message if provider SDK is missing diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6e4eea4e..7080146f 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -8,6 +8,7 @@ This document provides a comprehensive reference for all commands available in t |---------|-------------| | `cortex` | Show help and available commands | | `cortex install ` | Install software | +| `cortex ask ` | Ask questions about your system or learn about Linux | | `cortex demo` | See Cortex in action | | `cortex wizard` | Configure API key | | `cortex status` | Show comprehensive system status and health checks | @@ -71,6 +72,65 @@ cortex install "python3 with pip and virtualenv" --execute --- +### `cortex ask` + +Ask natural language questions about your system or learn about Linux, packages, and best practices. The AI automatically detects whether you're asking a diagnostic question about your system or an educational question to learn something new. + +**Usage:** +```bash +cortex ask "" +``` + +**Question Types:** + +**Diagnostic Questions** - Questions about your specific system: +```bash +# System status queries +cortex ask "why is my disk full" +cortex ask "what packages need updating" +cortex ask "is my Python version compatible with TensorFlow" +cortex ask "check my GPU drivers" +``` + +**Educational Questions** - Learn about Linux, packages, and best practices: +```bash +# Explanations and tutorials +cortex ask "explain how Docker containers work" +cortex ask "what is systemd and how do I use it" +cortex ask "teach me about nginx configuration" +cortex ask "best practices for securing a Linux server" +cortex ask "how to set up a Python virtual environment" +``` + +**Features:** +- **Automatic Intent Detection**: The AI distinguishes between diagnostic and educational queries +- **System-Aware Responses**: Uses your actual system context (OS, Python version, GPU, etc.) +- **Structured Learning**: Educational responses include examples, best practices, and related topics +- **Learning Progress Tracking**: Educational topics you explore are tracked in `~/.cortex/learning_history.json` +- **Response Caching**: Repeated questions return cached responses for faster performance + +**Examples:** +```bash +# Diagnostic: Get specific info about your system +cortex ask "what version of Python do I have" +cortex ask "can I run PyTorch on this system" + +# Educational: Learn with structured tutorials +cortex ask "explain how apt package management works" +cortex ask "what are best practices for Docker security" +cortex ask "guide to setting up nginx as a reverse proxy" + +# Mix of both +cortex ask "how do I install and configure Redis" +``` + +**Notes:** +- Educational responses are longer and include code examples with syntax highlighting +- The `--offline` flag can be used to only return cached responses +- Learning history helps track what topics you've explored over time + +--- + ### `cortex demo` Run an interactive demonstration of Cortex capabilities. Perfect for first-time users or presentations. @@ -366,6 +426,24 @@ cortex stack webdev --dry-run cortex stack webdev ``` +### Learning with Cortex Ask +```bash +# 1. Ask diagnostic questions about your system +cortex ask "what version of Python do I have" +cortex ask "is Docker installed" + +# 2. Learn about new topics with educational queries +cortex ask "explain how Docker containers work" +cortex ask "best practices for nginx configuration" + +# 3. Get step-by-step tutorials +cortex ask "teach me how to set up a Python virtual environment" +cortex ask "guide to configuring SSH keys" + +# 4. Your learning topics are automatically tracked +# View at ~/.cortex/learning_history.json +``` + --- ## Getting Help diff --git a/tests/test_ask.py b/tests/test_ask.py index aaa9a237..a1329462 100644 --- a/tests/test_ask.py +++ b/tests/test_ask.py @@ -1,14 +1,16 @@ """Unit tests for the ask module.""" +import json import os import sys import tempfile import unittest +from pathlib import Path from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from cortex.ask import AskHandler, SystemInfoGatherer +from cortex.ask import AskHandler, LearningTracker, SystemInfoGatherer class TestSystemInfoGatherer(unittest.TestCase): @@ -268,5 +270,229 @@ def test_unsupported_provider(self): AskHandler(api_key="test", provider="unsupported") +class TestLearningTracker(unittest.TestCase): + """Tests for LearningTracker.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + self.temp_file = Path(self.temp_dir) / "learning_history.json" + # Patch the PROGRESS_FILE to use temp location + self.patcher = patch.object(LearningTracker, "PROGRESS_FILE", self.temp_file) + self.patcher.start() + self.tracker = LearningTracker() + + def tearDown(self): + """Clean up temporary files.""" + import shutil + + self.patcher.stop() + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_is_educational_query_explain(self): + """Test detection of 'explain' queries.""" + self.assertTrue(self.tracker.is_educational_query("explain how docker works")) + self.assertTrue(self.tracker.is_educational_query("Explain nginx configuration")) + + def test_is_educational_query_teach_me(self): + """Test detection of 'teach me' queries.""" + self.assertTrue(self.tracker.is_educational_query("teach me about systemd")) + self.assertTrue(self.tracker.is_educational_query("Teach me how to use git")) + + def test_is_educational_query_what_is(self): + """Test detection of 'what is' queries.""" + self.assertTrue(self.tracker.is_educational_query("what is kubernetes")) + self.assertTrue(self.tracker.is_educational_query("What are containers")) + + def test_is_educational_query_how_does(self): + """Test detection of 'how does' queries.""" + self.assertTrue(self.tracker.is_educational_query("how does DNS work")) + self.assertTrue(self.tracker.is_educational_query("How do containers work")) + + def test_is_educational_query_best_practices(self): + """Test detection of 'best practices' queries.""" + self.assertTrue(self.tracker.is_educational_query("best practices for security")) + self.assertTrue( + self.tracker.is_educational_query("what are best practice for nginx") + ) + + def test_is_educational_query_tutorial(self): + """Test detection of 'tutorial' queries.""" + self.assertTrue(self.tracker.is_educational_query("tutorial on docker compose")) + + def test_is_educational_query_non_educational(self): + """Test that non-educational queries return False.""" + self.assertFalse(self.tracker.is_educational_query("why is my disk full")) + self.assertFalse(self.tracker.is_educational_query("what packages need updating")) + self.assertFalse(self.tracker.is_educational_query("check my system status")) + + def test_extract_topic_explain(self): + """Test topic extraction from 'explain' queries.""" + topic = self.tracker.extract_topic("explain how docker containers work") + self.assertEqual(topic, "how docker containers work") + + def test_extract_topic_teach_me(self): + """Test topic extraction from 'teach me' queries.""" + topic = self.tracker.extract_topic("teach me about systemd services") + self.assertEqual(topic, "systemd services") + + def test_extract_topic_what_is(self): + """Test topic extraction from 'what is' queries.""" + topic = self.tracker.extract_topic("what is kubernetes?") + self.assertEqual(topic, "kubernetes") + + def test_extract_topic_truncation(self): + """Test that long topics are truncated.""" + long_question = "explain " + "a" * 100 + topic = self.tracker.extract_topic(long_question) + self.assertLessEqual(len(topic), 50) + + def test_record_topic_creates_file(self): + """Test that recording a topic creates the history file.""" + self.tracker.record_topic("explain docker") + self.assertTrue(self.temp_file.exists()) + + def test_record_topic_stores_data(self): + """Test that recorded topics are stored correctly.""" + self.tracker.record_topic("explain docker containers") + history = self.tracker.get_history() + self.assertIn("docker containers", history["topics"]) + self.assertEqual(history["topics"]["docker containers"]["count"], 1) + + def test_record_topic_increments_count(self): + """Test that repeated topics increment the count.""" + self.tracker.record_topic("explain docker") + self.tracker.record_topic("explain docker") + history = self.tracker.get_history() + self.assertEqual(history["topics"]["docker"]["count"], 2) + + def test_record_topic_ignores_non_educational(self): + """Test that non-educational queries are not recorded.""" + self.tracker.record_topic("why is my disk full") + history = self.tracker.get_history() + self.assertEqual(len(history["topics"]), 0) + + def test_get_recent_topics(self): + """Test getting recent topics.""" + self.tracker.record_topic("explain docker") + self.tracker.record_topic("what is kubernetes") + self.tracker.record_topic("teach me nginx") + + recent = self.tracker.get_recent_topics(limit=2) + self.assertEqual(len(recent), 2) + # Most recent should be first + self.assertEqual(recent[0], "nginx") + + def test_get_recent_topics_empty(self): + """Test getting recent topics when none exist.""" + recent = self.tracker.get_recent_topics() + self.assertEqual(recent, []) + + def test_total_queries_tracked(self): + """Test that total educational queries are tracked.""" + self.tracker.record_topic("explain docker") + self.tracker.record_topic("what is kubernetes") + history = self.tracker.get_history() + self.assertEqual(history["total_queries"], 2) + + +class TestAskHandlerLearning(unittest.TestCase): + """Tests for AskHandler learning features.""" + + def setUp(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + self.temp_file = Path(self.temp_dir) / "learning_history.json" + self.patcher = patch.object(LearningTracker, "PROGRESS_FILE", self.temp_file) + self.patcher.start() + + def tearDown(self): + """Clean up temporary files.""" + import shutil + + self.patcher.stop() + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_ask_records_educational_topic(self): + """Test that educational questions are recorded.""" + os.environ["CORTEX_FAKE_RESPONSE"] = "Docker is a containerization platform..." + handler = AskHandler(api_key="fake-key", provider="fake") + handler.cache = None + + handler.ask("explain how docker works") + + history = handler.get_learning_history() + self.assertIn("how docker works", history["topics"]) + + def test_ask_does_not_record_diagnostic(self): + """Test that diagnostic questions are not recorded.""" + os.environ["CORTEX_FAKE_RESPONSE"] = "Your disk is 80% full." + handler = AskHandler(api_key="fake-key", provider="fake") + handler.cache = None + + handler.ask("why is my disk full") + + history = handler.get_learning_history() + self.assertEqual(len(history["topics"]), 0) + + def test_get_recent_topics_via_handler(self): + """Test getting recent topics through handler.""" + os.environ["CORTEX_FAKE_RESPONSE"] = "Test response" + handler = AskHandler(api_key="fake-key", provider="fake") + handler.cache = None + + handler.ask("explain kubernetes") + handler.ask("what is docker") + + recent = handler.get_recent_topics(limit=5) + self.assertEqual(len(recent), 2) + + def test_system_prompt_contains_educational_instructions(self): + """Test that system prompt includes educational guidance.""" + handler = AskHandler(api_key="fake-key", provider="fake") + context = handler.info_gatherer.gather_context() + prompt = handler._get_system_prompt(context) + + self.assertIn("Educational Questions", prompt) + self.assertIn("Diagnostic Questions", prompt) + self.assertIn("tutorial-style", prompt) + self.assertIn("best practices", prompt) + + +class TestSystemPromptEnhancement(unittest.TestCase): + """Tests for enhanced system prompt.""" + + def test_prompt_includes_query_type_detection(self): + """Test that prompt includes query type detection section.""" + handler = AskHandler(api_key="fake-key", provider="fake") + context = {"python_version": "3.11", "os": {"system": "Linux"}} + prompt = handler._get_system_prompt(context) + + self.assertIn("Query Type Detection", prompt) + self.assertIn("explain", prompt.lower()) + self.assertIn("teach me", prompt.lower()) + + def test_prompt_includes_educational_instructions(self): + """Test that prompt includes educational response instructions.""" + handler = AskHandler(api_key="fake-key", provider="fake") + context = {} + prompt = handler._get_system_prompt(context) + + self.assertIn("code examples", prompt.lower()) + self.assertIn("best practices", prompt.lower()) + self.assertIn("related topics", prompt.lower()) + + def test_prompt_includes_diagnostic_instructions(self): + """Test that prompt includes diagnostic response instructions.""" + handler = AskHandler(api_key="fake-key", provider="fake") + context = {} + prompt = handler._get_system_prompt(context) + + self.assertIn("system context", prompt.lower()) + self.assertIn("actionable", prompt.lower()) + + if __name__ == "__main__": unittest.main() From 96b99211ca46115354f9a538dd0d445ed8ba6e2a Mon Sep 17 00:00:00 2001 From: Shree Date: Mon, 29 Dec 2025 15:28:14 +0530 Subject: [PATCH 2/2] style: Format code with black and ruff --- cortex/ask.py | 6 ++---- tests/test_ask.py | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/cortex/ask.py b/cortex/ask.py index d1078065..87cf166c 100644 --- a/cortex/ask.py +++ b/cortex/ask.py @@ -160,9 +160,7 @@ class LearningTracker: def __init__(self): """Initialize the learning tracker.""" - self._compiled_patterns = [ - re.compile(p, re.IGNORECASE) for p in self.EDUCATIONAL_PATTERNS - ] + self._compiled_patterns = [re.compile(p, re.IGNORECASE) for p in self.EDUCATIONAL_PATTERNS] def is_educational_query(self, question: str) -> bool: """Determine if a question is educational in nature.""" @@ -249,7 +247,7 @@ def _load_history(self) -> dict[str, Any]: return {"topics": {}, "total_queries": 0} try: - with open(self.PROGRESS_FILE, "r") as f: + with open(self.PROGRESS_FILE) as f: return json.load(f) except (json.JSONDecodeError, OSError): return {"topics": {}, "total_queries": 0} diff --git a/tests/test_ask.py b/tests/test_ask.py index a1329462..a8f53983 100644 --- a/tests/test_ask.py +++ b/tests/test_ask.py @@ -313,9 +313,7 @@ def test_is_educational_query_how_does(self): def test_is_educational_query_best_practices(self): """Test detection of 'best practices' queries.""" self.assertTrue(self.tracker.is_educational_query("best practices for security")) - self.assertTrue( - self.tracker.is_educational_query("what are best practice for nginx") - ) + self.assertTrue(self.tracker.is_educational_query("what are best practice for nginx")) def test_is_educational_query_tutorial(self): """Test detection of 'tutorial' queries."""