diff --git a/syllabus_server/cache.py b/syllabus_server/cache.py new file mode 100644 index 0000000..f8bd63e --- /dev/null +++ b/syllabus_server/cache.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +""" +Caching layer for parsed syllabus data. + +This module provides persistent caching of ParsedSyllabus objects to avoid +redundant PDF parsing and LLM API calls. +""" +from __future__ import annotations + +import hashlib +import logging +import os +import pickle +import sys +import typing as t +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from .models import ParsedSyllabus +from .pdf_utils import _load_pdf_path + +logger = logging.getLogger(__name__) + + +@dataclass +class CacheEntry: + """Metadata and data for a cached syllabus.""" + + key: str # SHA-256 hash of PDF bytes + source: str # Original path or URL + cached_at: datetime # When cached + parsed_syllabus: ParsedSyllabus # The cached data + + +def _get_default_cache_dir() -> Path: + """Get platform-appropriate cache directory. + + Returns: + Path to default cache directory based on platform + """ + if os.name == "posix": # macOS, Linux + return Path.home() / ".cache" / "syllabusmcp" + elif os.name == "nt": # Windows + local_app_data = os.getenv("LOCALAPPDATA", str(Path.home())) + return Path(local_app_data) / "syllabusmcp" / "cache" + else: + return Path.home() / ".syllabusmcp_cache" + + +class SyllabusCache: + """Manages caching of parsed syllabi. + + Provides persistent file-based caching using pickle serialization. + Cache keys are SHA-256 hashes of raw PDF bytes for content-based caching. + """ + + def __init__(self, cache_dir: Path | None = None): + """Initialize cache manager. + + Args: + cache_dir: Cache directory path. If None, uses platform default. + """ + if cache_dir is None: + # Check for environment variable override + env_cache_dir = os.getenv("SYLLABUSMCP_CACHE_DIR") + if env_cache_dir: + cache_dir = Path(env_cache_dir) + else: + cache_dir = _get_default_cache_dir() + + self.cache_dir = cache_dir + self._syllabi_dir = cache_dir / "syllabi" + + def get(self, pdf_path_or_url: str) -> ParsedSyllabus | None: + """Retrieve cached syllabus if available. + + Args: + pdf_path_or_url: Path or URL to syllabus PDF + + Returns: + Cached ParsedSyllabus or None if not cached or error occurred + """ + try: + key = self._compute_key(pdf_path_or_url) + cache_file = self._syllabi_dir / f"{key}.pkl" + + if not cache_file.exists(): + logger.info(f"Cache MISS: {pdf_path_or_url} (key={key[:8]}...)") + return None + + with open(cache_file, "rb") as f: + entry: CacheEntry = pickle.load(f) + + logger.info(f"Cache HIT: {pdf_path_or_url} (key={key[:8]}...)") + return entry.parsed_syllabus + + except Exception as e: + # Graceful degradation - treat errors as cache miss + logger.warning(f"Cache read error for {pdf_path_or_url}: {e}") + print(f"Cache read error: {e}", file=sys.stderr) + return None + + def set(self, pdf_path_or_url: str, parsed: ParsedSyllabus) -> None: + """Cache a parsed syllabus. + + Args: + pdf_path_or_url: Path or URL to syllabus PDF + parsed: Parsed syllabus data to cache + """ + try: + key = self._compute_key(pdf_path_or_url) + entry = CacheEntry( + key=key, + source=pdf_path_or_url, + cached_at=datetime.now(), + parsed_syllabus=parsed, + ) + + # Ensure directory exists + self._syllabi_dir.mkdir(parents=True, exist_ok=True) + + cache_file = self._syllabi_dir / f"{key}.pkl" + with open(cache_file, "wb") as f: + pickle.dump(entry, f) + + logger.info(f"Cache SET: {pdf_path_or_url} (key={key[:8]}...)") + + except Exception as e: + # Graceful degradation - caching is optional + logger.warning(f"Cache write error for {pdf_path_or_url}: {e}") + print(f"Cache write error: {e}", file=sys.stderr) + + def invalidate(self, pdf_path_or_url: str) -> bool: + """Invalidate a specific cached syllabus. + + Args: + pdf_path_or_url: Path or URL to syllabus PDF + + Returns: + True if entry was found and removed, False otherwise + """ + try: + key = self._compute_key(pdf_path_or_url) + cache_file = self._syllabi_dir / f"{key}.pkl" + + if cache_file.exists(): + cache_file.unlink() + logger.info(f"Cache INVALIDATE: {pdf_path_or_url} (key={key[:8]}...)") + return True + + return False + + except Exception as e: + logger.warning(f"Cache invalidate error for {pdf_path_or_url}: {e}") + print(f"Cache invalidate error: {e}", file=sys.stderr) + return False + + def clear(self) -> int: + """Clear all cached syllabi. + + Returns: + Number of entries removed + """ + try: + if not self._syllabi_dir.exists(): + return 0 + + cache_files = list(self._syllabi_dir.glob("*.pkl")) + count = len(cache_files) + + for cache_file in cache_files: + cache_file.unlink() + + logger.info(f"Cache CLEAR: removed {count} entries") + return count + + except Exception as e: + logger.warning(f"Cache clear error: {e}") + print(f"Cache clear error: {e}", file=sys.stderr) + return 0 + + def list_keys(self) -> list[str]: + """List all cached syllabus keys. + + Returns: + List of cache keys (SHA-256 hashes) + """ + try: + if not self._syllabi_dir.exists(): + return [] + + cache_files = list(self._syllabi_dir.glob("*.pkl")) + # Extract key from filename (remove .pkl extension) + return [f.stem for f in cache_files] + + except Exception as e: + logger.warning(f"Cache list_keys error: {e}") + print(f"Cache list_keys error: {e}", file=sys.stderr) + return [] + + def get_statistics(self) -> dict[str, t.Any]: + """Get cache statistics. + + Returns: + Dictionary with cache stats: + - count: number of cached entries + - total_size: total size in bytes + - cache_dir: cache directory path + - syllabi_dir: syllabi subdirectory path + """ + try: + if not self._syllabi_dir.exists(): + return { + "count": 0, + "total_size": 0, + "cache_dir": str(self.cache_dir), + "syllabi_dir": str(self._syllabi_dir), + } + + cache_files = list(self._syllabi_dir.glob("*.pkl")) + total_size = sum(f.stat().st_size for f in cache_files) + + return { + "count": len(cache_files), + "total_size": total_size, + "cache_dir": str(self.cache_dir), + "syllabi_dir": str(self._syllabi_dir), + } + + except Exception as e: + logger.warning(f"Cache get_statistics error: {e}") + print(f"Cache get_statistics error: {e}", file=sys.stderr) + return { + "count": 0, + "total_size": 0, + "cache_dir": str(self.cache_dir), + "syllabi_dir": str(self._syllabi_dir), + } + + def _compute_key(self, pdf_path_or_url: str) -> str: + """Compute cache key from PDF content. + + Uses SHA-256 hash of raw PDF bytes for content-based caching. + Same content produces same key regardless of filename or URL. + + Args: + pdf_path_or_url: Path or URL to syllabus PDF + + Returns: + SHA-256 hash of raw PDF bytes as hex string + """ + # Get local file path (downloads if URL) + local_path = _load_pdf_path(pdf_path_or_url) + + # Read raw PDF bytes + with open(local_path, "rb") as f: + pdf_bytes = f.read() + + # Compute SHA-256 hash + return hashlib.sha256(pdf_bytes).hexdigest() diff --git a/syllabus_server/server.py b/syllabus_server/server.py index 2441350..d9e061c 100644 --- a/syllabus_server/server.py +++ b/syllabus_server/server.py @@ -9,6 +9,7 @@ from openai import OpenAI from prompts import load_prompt +from .cache import SyllabusCache from .models import (Assignment, CourseSection, ExplicitMeeting, MeetingPattern, ParsedSyllabus, Policies, ScheduleEntry) from .pdf_utils import extract_pdf_pages @@ -21,6 +22,10 @@ raise RuntimeError("OPENAI_API_KEY environment variable is not set.") client = OpenAI(api_key=_api_key) +# Initialize cache (respects SYLLABUSMCP_CACHE_DIR and SYLLABUSMCP_DISABLE_CACHE env vars) +_cache_enabled = os.getenv("SYLLABUSMCP_DISABLE_CACHE", "").lower() not in ("1", "true", "yes") +_cache = SyllabusCache() if _cache_enabled else None + # ----------------------------- # SYSTEM PROMPT @@ -35,10 +40,24 @@ # ----------------------------- @mcp.tool() -def parse_syllabus(pdf_path_or_url: str) -> ParsedSyllabus: +def parse_syllabus(pdf_path_or_url: str, use_cache: bool = True) -> ParsedSyllabus: """ Parse a syllabus PDF/URL into ParsedSyllabus. + + Args: + pdf_path_or_url: Path or URL to syllabus PDF + use_cache: Whether to use caching (default: True) + + Returns: + Parsed syllabus data """ + # Check cache first + if use_cache and _cache_enabled and _cache: + cached = _cache.get(pdf_path_or_url) + if cached: + return cached + + # Cache miss - parse via LLM pages = extract_pdf_pages(pdf_path_or_url) # Join all pages for global parsing @@ -148,6 +167,10 @@ def parse_syllabus(pdf_path_or_url: str) -> ParsedSyllabus: schedule=schedule, policies=policies, ) + + # Cache the result + if use_cache and _cache_enabled and _cache: + _cache.set(pdf_path_or_url, parsed) return parsed diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..09ac63d --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for the SyllabusCache class. +""" +import pickle +from pathlib import Path + +import pytest + +from syllabus_server.cache import CacheEntry, SyllabusCache +from syllabus_server.models import ParsedSyllabus, Policies + + +@pytest.fixture +def temp_cache(tmp_path): + """Provide isolated cache directory for tests.""" + cache_dir = tmp_path / "test_cache" + return SyllabusCache(cache_dir=cache_dir) + + +@pytest.fixture +def sample_parsed_syllabus(): + """Provide sample ParsedSyllabus for testing.""" + return ParsedSyllabus( + course_code="TEST-101", + course_title="Test Course", + term="Fall 2025", + timezone="America/New_York", + sections=[], + assignments=[], + schedule=[], + policies=Policies(), + ) + + +@pytest.fixture +def sample_pdf(tmp_path): + """Create a sample PDF file for testing.""" + pdf_path = tmp_path / "test.pdf" + # Create a minimal PDF file (not a real PDF, but enough for testing) + pdf_path.write_bytes(b"%PDF-1.4\ntest content\n%%EOF") + return str(pdf_path) + + +def test_cache_miss(temp_cache, sample_pdf): + """Cache returns None for uncached syllabus.""" + result = temp_cache.get(sample_pdf) + assert result is None + + +def test_cache_hit(temp_cache, sample_pdf, sample_parsed_syllabus): + """Cache returns cached syllabus on subsequent access.""" + # First set the cache + temp_cache.set(sample_pdf, sample_parsed_syllabus) + + # Then retrieve it + result = temp_cache.get(sample_pdf) + + assert result is not None + assert result.course_code == sample_parsed_syllabus.course_code + assert result.course_title == sample_parsed_syllabus.course_title + assert result.term == sample_parsed_syllabus.term + + +def test_cache_key_consistency(temp_cache, tmp_path): + """Same content produces same cache key.""" + # Create two files with identical content + content = b"%PDF-1.4\nidentical content\n%%EOF" + + file1 = tmp_path / "file1.pdf" + file1.write_bytes(content) + + file2 = tmp_path / "file2.pdf" + file2.write_bytes(content) + + key1 = temp_cache._compute_key(str(file1)) + key2 = temp_cache._compute_key(str(file2)) + + assert key1 == key2 + + +def test_cache_key_different_content(temp_cache, tmp_path): + """Different content produces different cache keys.""" + file1 = tmp_path / "file1.pdf" + file1.write_bytes(b"%PDF-1.4\ncontent A\n%%EOF") + + file2 = tmp_path / "file2.pdf" + file2.write_bytes(b"%PDF-1.4\ncontent B\n%%EOF") + + key1 = temp_cache._compute_key(str(file1)) + key2 = temp_cache._compute_key(str(file2)) + + assert key1 != key2 + + +def test_cache_invalidate(temp_cache, sample_pdf, sample_parsed_syllabus): + """Invalidate removes specific entry.""" + # Set cache + temp_cache.set(sample_pdf, sample_parsed_syllabus) + assert temp_cache.get(sample_pdf) is not None + + # Invalidate + result = temp_cache.invalidate(sample_pdf) + assert result is True + + # Verify it's gone + assert temp_cache.get(sample_pdf) is None + + +def test_cache_invalidate_nonexistent(temp_cache, sample_pdf): + """Invalidate returns False for non-existent entry.""" + result = temp_cache.invalidate(sample_pdf) + assert result is False + + +def test_cache_clear(temp_cache, tmp_path, sample_parsed_syllabus): + """Clear removes all entries.""" + # Create multiple cache entries + file1 = tmp_path / "test1.pdf" + file1.write_bytes(b"%PDF-1.4\ncontent 1\n%%EOF") + + file2 = tmp_path / "test2.pdf" + file2.write_bytes(b"%PDF-1.4\ncontent 2\n%%EOF") + + temp_cache.set(str(file1), sample_parsed_syllabus) + temp_cache.set(str(file2), sample_parsed_syllabus) + + # Clear cache + count = temp_cache.clear() + assert count == 2 + + # Verify all gone + assert temp_cache.get(str(file1)) is None + assert temp_cache.get(str(file2)) is None + + +def test_cache_clear_empty(temp_cache): + """Clear returns 0 for empty cache.""" + count = temp_cache.clear() + assert count == 0 + + +def test_cache_list_keys(temp_cache, tmp_path, sample_parsed_syllabus): + """List keys returns all cached keys.""" + # Create multiple cache entries + file1 = tmp_path / "test1.pdf" + file1.write_bytes(b"%PDF-1.4\ncontent 1\n%%EOF") + + file2 = tmp_path / "test2.pdf" + file2.write_bytes(b"%PDF-1.4\ncontent 2\n%%EOF") + + temp_cache.set(str(file1), sample_parsed_syllabus) + temp_cache.set(str(file2), sample_parsed_syllabus) + + keys = temp_cache.list_keys() + assert len(keys) == 2 + + # Verify keys are SHA-256 hashes (64 hex characters) + for key in keys: + assert len(key) == 64 + assert all(c in "0123456789abcdef" for c in key) + + +def test_cache_list_keys_empty(temp_cache): + """List keys returns empty list for empty cache.""" + keys = temp_cache.list_keys() + assert keys == [] + + +def test_cache_statistics(temp_cache, sample_pdf, sample_parsed_syllabus): + """Statistics reflect cache state.""" + # Empty cache + stats = temp_cache.get_statistics() + assert stats["count"] == 0 + assert stats["total_size"] == 0 + + # Add entry + temp_cache.set(sample_pdf, sample_parsed_syllabus) + + stats = temp_cache.get_statistics() + assert stats["count"] == 1 + assert stats["total_size"] > 0 + assert "cache_dir" in stats + assert "syllabi_dir" in stats + + +def test_cache_corrupted_file(temp_cache, sample_pdf, sample_parsed_syllabus): + """Gracefully handle corrupted cache files.""" + # Set cache + temp_cache.set(sample_pdf, sample_parsed_syllabus) + + # Corrupt the cache file + key = temp_cache._compute_key(sample_pdf) + cache_file = temp_cache._syllabi_dir / f"{key}.pkl" + cache_file.write_bytes(b"corrupted data") + + # Should return None (graceful degradation) + result = temp_cache.get(sample_pdf) + assert result is None + + +def test_cache_entry_metadata(temp_cache, sample_pdf, sample_parsed_syllabus): + """Cache entry includes correct metadata.""" + temp_cache.set(sample_pdf, sample_parsed_syllabus) + + # Load cache entry directly + key = temp_cache._compute_key(sample_pdf) + cache_file = temp_cache._syllabi_dir / f"{key}.pkl" + + with open(cache_file, "rb") as f: + entry: CacheEntry = pickle.load(f) + + assert entry.key == key + assert entry.source == sample_pdf + assert entry.cached_at is not None + assert entry.parsed_syllabus == sample_parsed_syllabus + + +def test_cache_with_relative_path(temp_cache, tmp_path, sample_parsed_syllabus): + """Cache works with relative paths.""" + # Create a PDF in current directory context + pdf_path = tmp_path / "relative.pdf" + pdf_path.write_bytes(b"%PDF-1.4\nrelative path test\n%%EOF") + + # Use absolute path for testing (relative paths depend on cwd) + temp_cache.set(str(pdf_path), sample_parsed_syllabus) + result = temp_cache.get(str(pdf_path)) + + assert result is not None + assert result.course_code == sample_parsed_syllabus.course_code + + +def test_cache_directory_creation(tmp_path, sample_pdf, sample_parsed_syllabus): + """Cache creates directory structure on first write.""" + cache_dir = tmp_path / "new_cache" + cache = SyllabusCache(cache_dir=cache_dir) + + # Directory shouldn't exist yet + assert not cache_dir.exists() + + # Set cache entry + cache.set(sample_pdf, sample_parsed_syllabus) + + # Directory should now exist + assert cache_dir.exists() + assert (cache_dir / "syllabi").exists() + + +def test_cache_with_env_override(tmp_path, sample_pdf, sample_parsed_syllabus, monkeypatch): + """Cache respects SYLLABUSMCP_CACHE_DIR environment variable.""" + env_cache_dir = tmp_path / "env_cache" + monkeypatch.setenv("SYLLABUSMCP_CACHE_DIR", str(env_cache_dir)) + + cache = SyllabusCache() + + assert cache.cache_dir == env_cache_dir + + # Verify it actually uses this directory + cache.set(sample_pdf, sample_parsed_syllabus) + assert (env_cache_dir / "syllabi").exists() diff --git a/tests/test_cache_integration.py b/tests/test_cache_integration.py new file mode 100644 index 0000000..203cd5e --- /dev/null +++ b/tests/test_cache_integration.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +""" +Integration tests for cache integration with parse_syllabus. +""" +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from syllabus_server.cache import SyllabusCache +from syllabus_server.models import ParsedSyllabus, Policies + + +@pytest.fixture +def sample_pdf(tmp_path): + """Create a sample PDF file for testing.""" + pdf_path = tmp_path / "test_syllabus.pdf" + pdf_path.write_bytes(b"%PDF-1.4\ntest syllabus content\n%%EOF") + return str(pdf_path) + + +@pytest.fixture +def mock_llm_response(): + """Mock LLM response for parse_syllabus.""" + return { + "course_code": "TEST-101", + "course_title": "Introduction to Testing", + "term": "Fall 2025", + "timezone": "America/New_York", + "sections": [], + "assignments": [], + "schedule": [], + "policies": {}, + } + + +@pytest.fixture +def temp_cache_dir(tmp_path): + """Provide temporary cache directory and set environment variable.""" + cache_dir = tmp_path / "integration_cache" + return cache_dir + + +def test_parse_syllabus_caches_result(sample_pdf, temp_cache_dir, mock_llm_response, monkeypatch): + """parse_syllabus() caches result on first call.""" + # Set required environment variables + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("SYLLABUSMCP_CACHE_DIR", str(temp_cache_dir)) + + # Need to reimport to pick up env var + import importlib + import syllabus_server.server as server_module + importlib.reload(server_module) + + # Mock the LLM call and PDF extraction + mock_completion = MagicMock() + mock_completion.choices[0].message.content = str(mock_llm_response).replace("'", '"') + + with patch.object(server_module.client.chat.completions, 'create', return_value=mock_completion), \ + patch('syllabus_server.server.extract_pdf_pages', return_value=["Test syllabus content"]): + # First call - should parse and cache + # Access the underlying function from the MCP tool + result1 = server_module.parse_syllabus.fn(sample_pdf) + + assert result1 is not None + assert result1.course_code == "TEST-101" + + # Verify cache was populated + cache = SyllabusCache(cache_dir=temp_cache_dir) + stats = cache.get_statistics() + assert stats["count"] == 1 + + +def test_parse_syllabus_uses_cache(sample_pdf, temp_cache_dir, monkeypatch): + """parse_syllabus() returns cached result on second call without calling LLM.""" + # Set required environment variables + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("SYLLABUSMCP_CACHE_DIR", str(temp_cache_dir)) + + # Create a cached entry + cache = SyllabusCache(cache_dir=temp_cache_dir) + cached_syllabus = ParsedSyllabus( + course_code="CACHED-101", + course_title="Cached Course", + term="Spring 2025", + timezone="America/New_York", + sections=[], + assignments=[], + schedule=[], + policies=Policies(), + ) + cache.set(sample_pdf, cached_syllabus) + + # Need to reimport to pick up env var + import importlib + import syllabus_server.server as server_module + importlib.reload(server_module) + + # Mock the LLM call - this should NOT be called + mock_completion = MagicMock() + + with patch.object(server_module.client.chat.completions, 'create', return_value=mock_completion) as mock_llm: + # Call parse_syllabus + # Access the underlying function from the MCP tool + result = server_module.parse_syllabus.fn(sample_pdf) + + # Should return cached result + assert result.course_code == "CACHED-101" + assert result.course_title == "Cached Course" + + # LLM should NOT have been called + mock_llm.assert_not_called() + + +def test_parse_syllabus_cache_disable_flag(sample_pdf, temp_cache_dir, mock_llm_response, monkeypatch): + """use_cache=False bypasses cache.""" + # Set required environment variables + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("SYLLABUSMCP_CACHE_DIR", str(temp_cache_dir)) + + # Create a cached entry + cache = SyllabusCache(cache_dir=temp_cache_dir) + cached_syllabus = ParsedSyllabus( + course_code="CACHED-101", + course_title="Cached Course", + term="Spring 2025", + timezone="America/New_York", + sections=[], + assignments=[], + schedule=[], + policies=Policies(), + ) + cache.set(sample_pdf, cached_syllabus) + + # Need to reimport to pick up env var + import importlib + import syllabus_server.server as server_module + importlib.reload(server_module) + + # Mock the LLM call and PDF extraction + mock_completion = MagicMock() + mock_completion.choices[0].message.content = str(mock_llm_response).replace("'", '"') + + with patch.object(server_module.client.chat.completions, 'create', return_value=mock_completion) as mock_llm, \ + patch('syllabus_server.server.extract_pdf_pages', return_value=["Test syllabus content"]): + # Call with use_cache=False + # Access the underlying function from the MCP tool + result = server_module.parse_syllabus.fn(sample_pdf, use_cache=False) + + # Should return fresh parse, not cached + assert result.course_code == "TEST-101" # From mock_llm_response + + # LLM should have been called + mock_llm.assert_called_once() + + +def test_parse_syllabus_with_env_disable(sample_pdf, mock_llm_response, monkeypatch): + """SYLLABUSMCP_DISABLE_CACHE env var disables caching.""" + # Set required environment variables + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("SYLLABUSMCP_DISABLE_CACHE", "1") + + # Need to reimport to pick up env var + import importlib + import syllabus_server.server as server_module + importlib.reload(server_module) + + # Verify cache is disabled + assert server_module._cache is None or not server_module._cache_enabled + + # Mock the LLM call and PDF extraction + mock_completion = MagicMock() + mock_completion.choices[0].message.content = str(mock_llm_response).replace("'", '"') + + with patch.object(server_module.client.chat.completions, 'create', return_value=mock_completion) as mock_llm, \ + patch('syllabus_server.server.extract_pdf_pages', return_value=["Test syllabus content"]): + # Call parse_syllabus + # Access the underlying function from the MCP tool + result = server_module.parse_syllabus.fn(sample_pdf) + + # Should work but use LLM (no cache) + assert result.course_code == "TEST-101" + mock_llm.assert_called_once() + + +def test_cache_survives_multiple_calls(sample_pdf, temp_cache_dir, monkeypatch): + """Cache persists across multiple parse_syllabus calls.""" + # Set required environment variables + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("SYLLABUSMCP_CACHE_DIR", str(temp_cache_dir)) + + # Pre-populate cache + cache = SyllabusCache(cache_dir=temp_cache_dir) + cached_syllabus = ParsedSyllabus( + course_code="PERSIST-101", + course_title="Persistent Course", + term="Fall 2025", + timezone="America/New_York", + sections=[], + assignments=[], + schedule=[], + policies=Policies(), + ) + cache.set(sample_pdf, cached_syllabus) + + # Need to reimport to pick up env var + import importlib + import syllabus_server.server as server_module + importlib.reload(server_module) + + # Mock LLM + mock_completion = MagicMock() + + with patch.object(server_module.client.chat.completions, 'create', return_value=mock_completion) as mock_llm: + # Call multiple times + # Access the underlying function from the MCP tool + result1 = server_module.parse_syllabus.fn(sample_pdf) + result2 = server_module.parse_syllabus.fn(sample_pdf) + result3 = server_module.parse_syllabus.fn(sample_pdf) + + # All should return cached result + assert result1.course_code == "PERSIST-101" + assert result2.course_code == "PERSIST-101" + assert result3.course_code == "PERSIST-101" + + # LLM should never be called + mock_llm.assert_not_called()