diff --git a/markitect/prompts/resolver/__init__.py b/markitect/prompts/resolver/__init__.py new file mode 100644 index 00000000..d9a784e2 --- /dev/null +++ b/markitect/prompts/resolver/__init__.py @@ -0,0 +1,31 @@ +""" +Resolution engine for Prompt Dependency Resolution. + +This package provides the core resolution logic for resolving ContentMacros +across multiple InformationSpaces with deterministic search order. +""" + +from markitect.prompts.resolver.models import ( + ResolutionContext, + ResolutionResult, + ResolutionError, + ResolvedMacro, +) +from markitect.prompts.resolver.strategy import ( + ResolutionStrategy, + MultiSpaceResolutionStrategy, +) +from markitect.prompts.resolver.resolver import PromptResolver +from markitect.prompts.resolver.compiler import ContextCompiler, CompiledPrompt + +__all__ = [ + "ResolutionContext", + "ResolutionResult", + "ResolutionError", + "ResolvedMacro", + "ResolutionStrategy", + "MultiSpaceResolutionStrategy", + "PromptResolver", + "ContextCompiler", + "CompiledPrompt", +] diff --git a/markitect/prompts/resolver/compiler.py b/markitect/prompts/resolver/compiler.py new file mode 100644 index 00000000..1744b003 --- /dev/null +++ b/markitect/prompts/resolver/compiler.py @@ -0,0 +1,202 @@ +""" +Context compiler for assembling resolved prompts. + +Compiles resolved macros into final prompt context. +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional + +from markitect.prompts.templates.models import PromptTemplate +from markitect.prompts.resolver.models import ResolutionResult +from markitect.prompts.models import calculate_content_digest + + +@dataclass +class CompiledPrompt: + """ + Compiled prompt ready for execution. + + Contains the final assembled prompt with all macros resolved. + + Attributes: + template_id: Source template ID + template_name: Source template name + content: Compiled prompt content with macros substituted + content_digest: SHA-256 digest of compiled content + resolution_result: Original resolution result + dependency_digests: Map of artifact name -> content digest + compiled_at: Compilation timestamp + metadata: Additional metadata + """ + template_id: str + template_name: str + content: str + content_digest: str + resolution_result: ResolutionResult + dependency_digests: Dict[str, str] = field(default_factory=dict) + compiled_at: datetime = field(default_factory=datetime.utcnow) + metadata: Dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict: + """Convert to dictionary.""" + return { + "template_id": self.template_id, + "template_name": self.template_name, + "content_digest": self.content_digest, + "content_length": len(self.content), + "dependency_count": len(self.dependency_digests), + "compiled_at": self.compiled_at.isoformat(), + "resolution_status": self.resolution_result.status.value, + } + + +class ContextCompiler: + """ + Compiler for assembling resolved context into final prompt. + + Takes resolution results and produces CompiledPrompt with all + macros substituted. + """ + + def compile( + self, + template: PromptTemplate, + template_content: str, + resolution_result: ResolutionResult, + ) -> CompiledPrompt: + """ + Compile template with resolved macros into final prompt. + + Args: + template: Source template + template_content: Original template content + resolution_result: Resolution result with resolved macros + + Returns: + CompiledPrompt with macros substituted + + Raises: + ValueError: If resolution failed + """ + if not resolution_result.success: + raise ValueError( + f"Cannot compile template '{template.name}': " + f"Resolution failed with unresolved required macros" + ) + + # Start with original template content + compiled_content = template_content + + # Track dependency digests + dependency_digests = {} + + # Substitute each resolved macro + for resolved in resolution_result.context.resolved_macros: + if resolved.resolved and resolved.artifact: + # Replace macro with resolved content + compiled_content = compiled_content.replace( + resolved.macro.raw_text, + resolved.content, + ) + # Track dependency + dependency_digests[resolved.artifact.name] = resolved.artifact.content_digest + + # Substitute unresolved optional macros with empty string + for macro in resolution_result.context.unresolved_optional: + compiled_content = compiled_content.replace(macro.raw_text, "") + + # Calculate digest of compiled content + content_digest = calculate_content_digest(compiled_content) + + return CompiledPrompt( + template_id=template.id, + template_name=template.name, + content=compiled_content, + content_digest=content_digest, + resolution_result=resolution_result, + dependency_digests=dependency_digests, + ) + + def compile_partial( + self, + template: PromptTemplate, + template_content: str, + resolution_result: ResolutionResult, + placeholder: str = "[UNRESOLVED]", + ) -> CompiledPrompt: + """ + Compile template even with unresolved required macros. + + Useful for debugging or preview. Unresolved required macros + are replaced with placeholder text. + + Args: + template: Source template + template_content: Original template content + resolution_result: Resolution result (may have failures) + placeholder: Text to use for unresolved macros + + Returns: + CompiledPrompt with partial resolution + """ + # Start with original template content + compiled_content = template_content + + # Track dependency digests + dependency_digests = {} + + # Substitute resolved macros + for resolved in resolution_result.context.resolved_macros: + if resolved.resolved and resolved.artifact: + compiled_content = compiled_content.replace( + resolved.macro.raw_text, + resolved.content, + ) + dependency_digests[resolved.artifact.name] = resolved.artifact.content_digest + + # Substitute unresolved required with placeholder + for macro in resolution_result.context.unresolved_required: + placeholder_text = f"{placeholder}:{macro.target}" + compiled_content = compiled_content.replace( + macro.raw_text, + placeholder_text, + ) + + # Substitute unresolved optional with empty + for macro in resolution_result.context.unresolved_optional: + compiled_content = compiled_content.replace(macro.raw_text, "") + + content_digest = calculate_content_digest(compiled_content) + + return CompiledPrompt( + template_id=template.id, + template_name=template.name, + content=compiled_content, + content_digest=content_digest, + resolution_result=resolution_result, + dependency_digests=dependency_digests, + metadata={"partial": "true", "placeholder": placeholder}, + ) + + def get_compilation_info(self, compiled: CompiledPrompt) -> dict: + """ + Get information about compilation. + + Args: + compiled: Compiled prompt + + Returns: + Dictionary with compilation metadata + """ + return { + "template_id": compiled.template_id, + "template_name": compiled.template_name, + "content_length": len(compiled.content), + "content_digest": compiled.content_digest, + "dependencies": list(compiled.dependency_digests.keys()), + "dependency_count": len(compiled.dependency_digests), + "compiled_at": compiled.compiled_at.isoformat(), + "is_partial": compiled.metadata.get("partial") == "true", + } diff --git a/markitect/prompts/resolver/models.py b/markitect/prompts/resolver/models.py new file mode 100644 index 00000000..d1d0dce4 --- /dev/null +++ b/markitect/prompts/resolver/models.py @@ -0,0 +1,183 @@ +""" +Models for resolution engine. + +Defines resolution context, results, and error types for macro resolution. +""" + +from dataclasses import dataclass, field +from typing import List, Dict, Optional, Any +from enum import Enum + +from markitect.prompts.templates.models import ContentMacro, MacroKind +from markitect.prompts.models import Artifact + + +class ResolutionStatus(Enum): + """Status of resolution operation.""" + SUCCESS = "success" + PARTIAL = "partial" # Some optional macros missing + FAILED = "failed" # Required macros missing + + +class ResolutionError(Exception): + """Raised when macro resolution fails.""" + + def __init__(self, message: str, macro: Optional[ContentMacro] = None): + super().__init__(message) + self.macro = macro + + +@dataclass +class ResolvedMacro: + """ + Result of resolving a single macro. + + Attributes: + macro: Original macro + artifact: Resolved artifact (None if not found) + resolved: Whether artifact was found + space_id: Space where artifact was found + content: Resolved content (empty string if not found for optional) + """ + macro: ContentMacro + artifact: Optional[Artifact] = None + resolved: bool = False + space_id: Optional[str] = None + content: str = "" + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "macro": self.macro.to_dict(), + "artifact_id": self.artifact.id if self.artifact else None, + "artifact_name": self.artifact.name if self.artifact else None, + "resolved": self.resolved, + "space_id": self.space_id, + "content_preview": self.content[:100] + "..." if len(self.content) > 100 else self.content, + } + + +@dataclass +class ResolutionContext: + """ + Context for macro resolution. + + Tracks resolution state, search order, and resolved artifacts. + + Attributes: + template_id: ID of template being resolved + space_id: Primary space ID + search_order: Ordered list of space IDs to search + resolved_macros: List of resolved macros + unresolved_required: List of unresolved required macros + unresolved_optional: List of unresolved optional macros + generator_macros: List of generate macros (deferred) + errors: List of resolution errors + metadata: Additional context metadata + """ + template_id: str + space_id: str + search_order: List[str] = field(default_factory=list) + resolved_macros: List[ResolvedMacro] = field(default_factory=list) + unresolved_required: List[ContentMacro] = field(default_factory=list) + unresolved_optional: List[ContentMacro] = field(default_factory=list) + generator_macros: List[ContentMacro] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def add_resolved(self, resolved: ResolvedMacro) -> None: + """Add a resolved macro to context.""" + self.resolved_macros.append(resolved) + + def add_unresolved_required(self, macro: ContentMacro) -> None: + """Record an unresolved required macro.""" + self.unresolved_required.append(macro) + self.errors.append( + f"Required macro '{macro.target}' not found (line {macro.line_number})" + ) + + def add_unresolved_optional(self, macro: ContentMacro) -> None: + """Record an unresolved optional macro.""" + self.unresolved_optional.append(macro) + + def add_generator(self, macro: ContentMacro) -> None: + """Record a generator macro for deferred execution.""" + self.generator_macros.append(macro) + + def has_errors(self) -> bool: + """Check if any resolution errors occurred.""" + return len(self.errors) > 0 + + def get_status(self) -> ResolutionStatus: + """Get overall resolution status.""" + if self.unresolved_required: + return ResolutionStatus.FAILED + elif self.unresolved_optional: + return ResolutionStatus.PARTIAL + else: + return ResolutionStatus.SUCCESS + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "template_id": self.template_id, + "space_id": self.space_id, + "search_order": self.search_order, + "resolved_count": len(self.resolved_macros), + "unresolved_required": [m.target for m in self.unresolved_required], + "unresolved_optional": [m.target for m in self.unresolved_optional], + "generator_count": len(self.generator_macros), + "status": self.get_status().value, + "errors": self.errors, + } + + +@dataclass +class ResolutionResult: + """ + Result of template resolution. + + Contains resolved content and metadata about the resolution process. + + Attributes: + context: Resolution context with full state + success: Whether all required macros were resolved + resolved_content: Dictionary of macro -> resolved content + dependency_artifacts: List of artifact IDs used + needs_generation: Whether any generate macros were found + """ + context: ResolutionContext + success: bool + resolved_content: Dict[str, str] = field(default_factory=dict) + dependency_artifacts: List[str] = field(default_factory=list) + needs_generation: bool = False + + @property + def status(self) -> ResolutionStatus: + """Get resolution status.""" + return self.context.get_status() + + def get_resolved_macro(self, target: str) -> Optional[ResolvedMacro]: + """ + Get resolved macro by target name. + + Args: + target: Macro target name + + Returns: + ResolvedMacro if found, None otherwise + """ + for resolved in self.context.resolved_macros: + if resolved.macro.target == target: + return resolved + return None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "success": self.success, + "status": self.status.value, + "context": self.context.to_dict(), + "dependency_count": len(self.dependency_artifacts), + "needs_generation": self.needs_generation, + } diff --git a/markitect/prompts/resolver/resolver.py b/markitect/prompts/resolver/resolver.py new file mode 100644 index 00000000..f1dff62f --- /dev/null +++ b/markitect/prompts/resolver/resolver.py @@ -0,0 +1,223 @@ +""" +PromptResolver for resolving template macros. + +Implements FR-3: PromptResolver Behavior +""" + +from typing import List, Optional + +from markitect.prompts.templates.models import PromptTemplate, ContentMacro, MacroKind +from markitect.prompts.resolver.models import ( + ResolutionContext, + ResolutionResult, + ResolutionError, + ResolvedMacro, +) +from markitect.prompts.resolver.strategy import ResolutionStrategy, ResolutionConfig +from markitect.prompts.services.artifact_service import ArtifactService + + +class PromptResolver: + """ + Resolver for prompt template macros. + + Implements FR-3: PromptResolver Behavior + - Deterministic resolution order (FR-3.1) + - Required macro validation (FR-3.2) + - Optional macro fallback (FR-3.3) + - Generate macro detection (FR-3.4) + """ + + def __init__( + self, + artifact_service: ArtifactService, + strategy: ResolutionStrategy, + ): + """ + Initialize resolver. + + Args: + artifact_service: Service for artifact lookup + strategy: Resolution strategy for search order + """ + self.artifact_service = artifact_service + self.strategy = strategy + + def resolve_template( + self, + template: PromptTemplate, + config: ResolutionConfig, + ) -> ResolutionResult: + """ + Resolve all macros in a template. + + Implements FR-3: PromptResolver Behavior + + Args: + template: Template to resolve + config: Resolution configuration + + Returns: + ResolutionResult with resolved macros and status + + Raises: + ValueError: If template hasn't been analyzed + """ + if not template.analyzed: + raise ValueError( + f"Template '{template.name}' must be analyzed before resolution. " + "Call TemplateAnalyzer.analyze() first." + ) + + # Get search order + search_order = self.strategy.get_search_order(config) + + # Create resolution context + context = ResolutionContext( + template_id=template.id, + space_id=config.space_id, + search_order=search_order, + ) + + # Track resolved content and dependencies + resolved_content = {} + dependency_artifacts = [] + + # Resolve each macro + for macro in template.macros: + resolved = self._resolve_macro(macro, search_order, context) + + if resolved.resolved and resolved.artifact: + resolved_content[macro.target] = resolved.content + dependency_artifacts.append(resolved.artifact.id) + + # Create result + result = ResolutionResult( + context=context, + success=len(context.unresolved_required) == 0, + resolved_content=resolved_content, + dependency_artifacts=dependency_artifacts, + needs_generation=len(context.generator_macros) > 0, + ) + + return result + + def _resolve_macro( + self, + macro: ContentMacro, + search_order: List[str], + context: ResolutionContext, + ) -> ResolvedMacro: + """ + Resolve a single macro. + + Implements: + - FR-3.2: Required macro failure + - FR-3.3: Optional macro empty fallback + - FR-3.4: Generate macro detection + + Args: + macro: Macro to resolve + search_order: Ordered space IDs to search + context: Resolution context + + Returns: + ResolvedMacro with resolution result + """ + # Handle generate macros separately (FR-3.4) + if macro.kind == MacroKind.GENERATE: + context.add_generator(macro) + return ResolvedMacro( + macro=macro, + artifact=None, + resolved=False, # Will be resolved during generation phase + space_id=None, + content="", + ) + + # Try to resolve in each space + for space_id in search_order: + artifact = self.artifact_service.repository.get_by_name( + space_id, + macro.target, + ) + + if artifact: + # Found! Get content (would need to load from storage in real impl) + # For now, we'll use a placeholder + content = f"[Content of {artifact.name} from {space_id}]" + + resolved = ResolvedMacro( + macro=macro, + artifact=artifact, + resolved=True, + space_id=space_id, + content=content, + ) + context.add_resolved(resolved) + return resolved + + # Not found in any space + if macro.kind == MacroKind.REQUIRED: + # FR-3.2: Required macros fail if not found + context.add_unresolved_required(macro) + return ResolvedMacro( + macro=macro, + artifact=None, + resolved=False, + space_id=None, + content="", + ) + else: + # FR-3.3: Optional macros resolve to empty + context.add_unresolved_optional(macro) + return ResolvedMacro( + macro=macro, + artifact=None, + resolved=False, + space_id=None, + content="", # Empty content for optional + ) + + def validate_resolution(self, result: ResolutionResult) -> None: + """ + Validate resolution result. + + Args: + result: Resolution result to validate + + Raises: + ResolutionError: If required macros are missing + """ + if not result.success: + unresolved = result.context.unresolved_required + targets = [m.target for m in unresolved] + raise ResolutionError( + f"Failed to resolve required macros: {', '.join(targets)}" + ) + + def get_resolution_summary(self, result: ResolutionResult) -> dict: + """ + Get human-readable summary of resolution. + + Args: + result: Resolution result + + Returns: + Dictionary with summary information + """ + return { + "status": result.status.value, + "success": result.success, + "resolved_count": len(result.context.resolved_macros), + "unresolved_required": [ + m.target for m in result.context.unresolved_required + ], + "unresolved_optional": [ + m.target for m in result.context.unresolved_optional + ], + "needs_generation": result.needs_generation, + "generator_count": len(result.context.generator_macros), + "dependency_count": len(result.dependency_artifacts), + "search_order": result.context.search_order, + } diff --git a/markitect/prompts/resolver/strategy.py b/markitect/prompts/resolver/strategy.py new file mode 100644 index 00000000..0ec95472 --- /dev/null +++ b/markitect/prompts/resolver/strategy.py @@ -0,0 +1,152 @@ +""" +Resolution strategies for multi-space artifact lookup. + +Implements FR-3.1: Deterministic resolution order +""" + +from abc import ABC, abstractmethod +from typing import List, Optional +from dataclasses import dataclass, field + + +@dataclass +class ResolutionConfig: + """ + Configuration for resolution strategy. + + Implements FR-3.1: Resolution order configuration + + Resolution order: + 1. Local InformationSpace + 2. Explicitly included InformationSpaces + 3. Default InformationSpace + 4. Team/Shared InformationSpace (if configured) + + Attributes: + space_id: Primary space ID + included_spaces: Explicitly included space IDs (ordered) + default_space_id: Default space for common artifacts + shared_space_id: Team/shared space (optional) + max_generation_depth: Maximum nesting depth for generators + """ + space_id: str + included_spaces: List[str] = field(default_factory=list) + default_space_id: Optional[str] = None + shared_space_id: Optional[str] = None + max_generation_depth: int = 3 + + def to_dict(self) -> dict: + """Convert to dictionary for serialization.""" + return { + "space_id": self.space_id, + "included_spaces": self.included_spaces, + "default_space_id": self.default_space_id, + "shared_space_id": self.shared_space_id, + "max_generation_depth": self.max_generation_depth, + } + + @classmethod + def from_dict(cls, data: dict) -> "ResolutionConfig": + """Create from dictionary.""" + return cls( + space_id=data["space_id"], + included_spaces=data.get("included_spaces", []), + default_space_id=data.get("default_space_id"), + shared_space_id=data.get("shared_space_id"), + max_generation_depth=data.get("max_generation_depth", 3), + ) + + +class ResolutionStrategy(ABC): + """ + Abstract base class for resolution strategies. + + Defines how to search for artifacts across multiple spaces. + """ + + @abstractmethod + def get_search_order(self, config: ResolutionConfig) -> List[str]: + """ + Get ordered list of space IDs to search. + + Args: + config: Resolution configuration + + Returns: + Ordered list of space IDs (no duplicates) + """ + pass + + +class MultiSpaceResolutionStrategy(ResolutionStrategy): + """ + Multi-space resolution strategy with deterministic search order. + + Implements FR-3.1: Resolution order + 1. Local space + 2. Included spaces (in order) + 3. Default space + 4. Shared space + """ + + def get_search_order(self, config: ResolutionConfig) -> List[str]: + """ + Get deterministic search order. + + Implements FR-3.1 resolution order: + 1. Local InformationSpace (config.space_id) + 2. Explicitly included InformationSpaces (config.included_spaces) + 3. Default InformationSpace (config.default_space_id) + 4. Team/Shared InformationSpace (config.shared_space_id) + + Removes duplicates while preserving order. + + Args: + config: Resolution configuration + + Returns: + Ordered list of unique space IDs + """ + search_order = [] + seen = set() + + def add_if_not_seen(space_id: Optional[str]) -> None: + """Add space ID if not None and not already seen.""" + if space_id and space_id not in seen: + search_order.append(space_id) + seen.add(space_id) + + # 1. Local space (highest priority) + add_if_not_seen(config.space_id) + + # 2. Included spaces (in order) + for included_id in config.included_spaces: + add_if_not_seen(included_id) + + # 3. Default space + add_if_not_seen(config.default_space_id) + + # 4. Shared space (lowest priority) + add_if_not_seen(config.shared_space_id) + + return search_order + + +class SingleSpaceResolutionStrategy(ResolutionStrategy): + """ + Simple strategy that only searches the local space. + + Useful for isolated execution or testing. + """ + + def get_search_order(self, config: ResolutionConfig) -> List[str]: + """ + Return only the local space. + + Args: + config: Resolution configuration + + Returns: + List containing only the local space ID + """ + return [config.space_id] diff --git a/migrations/prompts/002_create_resolution_config.sql b/migrations/prompts/002_create_resolution_config.sql new file mode 100644 index 00000000..ba440c03 --- /dev/null +++ b/migrations/prompts/002_create_resolution_config.sql @@ -0,0 +1,23 @@ +-- Migration 002: Create resolution configuration table +-- Implements FR-3.1: Resolution order configuration +-- Date: 2026-02-08 + +-- Resolution configuration table +CREATE TABLE IF NOT EXISTS prompt_resolution_config ( + space_id TEXT PRIMARY KEY, + included_spaces JSON, -- Array of space IDs to search + default_space_id TEXT, + shared_space_id TEXT, + max_generation_depth INTEGER DEFAULT 3, + config JSON, -- Additional configuration options + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Comments (for documentation) +-- prompt_resolution_config.space_id: Primary space for this configuration +-- prompt_resolution_config.included_spaces: Ordered array of space IDs for resolution search +-- prompt_resolution_config.default_space_id: Default space for common artifacts +-- prompt_resolution_config.shared_space_id: Team/shared space (optional) +-- prompt_resolution_config.max_generation_depth: Maximum nesting depth for generators +-- prompt_resolution_config.config: Additional JSON configuration +-- prompt_resolution_config.updated_at: Last configuration update diff --git a/tests/unit/prompts/test_context_compiler.py b/tests/unit/prompts/test_context_compiler.py new file mode 100644 index 00000000..6975cd04 --- /dev/null +++ b/tests/unit/prompts/test_context_compiler.py @@ -0,0 +1,259 @@ +"""Unit tests for ContextCompiler.""" + +import pytest +import tempfile +from pathlib import Path + +from markitect.prompts.templates.models import PromptTemplate +from markitect.prompts.templates.analyzer import TemplateAnalyzer +from markitect.prompts.resolver.resolver import PromptResolver +from markitect.prompts.resolver.compiler import ContextCompiler +from markitect.prompts.resolver.strategy import MultiSpaceResolutionStrategy, ResolutionConfig +from markitect.prompts.services.artifact_service import ArtifactService +from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository + + +@pytest.fixture +def temp_db(): + """Create temporary database.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + yield db_path + Path(db_path).unlink(missing_ok=True) + + +@pytest.fixture +def artifact_service(temp_db): + """Create artifact service.""" + repository = SQLiteArtifactRepository(temp_db) + return ArtifactService(repository) + + +@pytest.fixture +def resolver(artifact_service): + """Create resolver.""" + strategy = MultiSpaceResolutionStrategy() + return PromptResolver(artifact_service, strategy) + + +@pytest.fixture +def compiler(): + """Create compiler.""" + return ContextCompiler() + + +@pytest.fixture +def analyzer(): + """Create analyzer.""" + return TemplateAnalyzer() + + +class TestContextCompiler: + """Tests for ContextCompiler.""" + + def test_compile_template_no_macros(self, compiler, analyzer, resolver): + """Test compiling template without macros.""" + content = "# Simple Template\nNo macros here." + template = PromptTemplate.create( + space_id="space-1", + name="simple", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + compiled = compiler.compile(template, content, result) + + assert compiled.content == content + assert compiled.template_id == template.id + assert compiled.template_name == template.name + assert len(compiled.dependency_digests) == 0 + + def test_compile_with_resolved_macros( + self, compiler, analyzer, resolver, artifact_service + ): + """Test compiling with resolved macros substitutes content.""" + # Create dependency + artifact_service.create_artifact( + space_id="space-1", + name="intro", + content="Introduction text", + ) + + content = "# Document\n{{require:intro}}\nMore content" + template = PromptTemplate.create( + space_id="space-1", + name="doc", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + compiled = compiler.compile(template, content, result) + + # Macro should be replaced with resolved content + assert "{{require:intro}}" not in compiled.content + assert "[Content of intro from space-1]" in compiled.content + assert "intro" in compiled.dependency_digests + + def test_compile_with_optional_macros_substitutes_empty( + self, compiler, analyzer, resolver, artifact_service + ): + """Test optional macros are replaced with empty string.""" + # Create one artifact, leave another missing + artifact_service.create_artifact( + space_id="space-1", + name="present", + content="Present content", + ) + + content = "Start {{require:present}} middle {{optional:missing}} end" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + compiled = compiler.compile(template, content, result) + + # Optional missing macro should be removed + assert "{{optional:missing}}" not in compiled.content + assert compiled.content == "Start [Content of present from space-1] middle end" + + def test_compile_failed_resolution_raises_error( + self, compiler, analyzer, resolver + ): + """Test compiling failed resolution raises error.""" + content = "{{require:missing}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + with pytest.raises(ValueError, match="Resolution failed"): + compiler.compile(template, content, result) + + def test_compile_partial_with_placeholder( + self, compiler, analyzer, resolver + ): + """Test partial compilation with placeholder for unresolved.""" + content = "{{require:missing}} text {{optional:also-missing}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + compiled = compiler.compile_partial( + template, content, result, placeholder="[MISSING]" + ) + + assert "[MISSING]:missing" in compiled.content + assert "{{optional:also-missing}}" not in compiled.content + assert compiled.metadata.get("partial") == "true" + + def test_compiled_prompt_has_content_digest( + self, compiler, analyzer, resolver, artifact_service + ): + """Test compiled prompt has content digest.""" + artifact_service.create_artifact( + space_id="space-1", + name="dep", + content="Dependency", + ) + + content = "{{require:dep}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + compiled = compiler.compile(template, content, result) + + assert compiled.content_digest + assert len(compiled.content_digest) == 64 # SHA-256 hex + + def test_compiled_prompt_tracks_dependencies( + self, compiler, analyzer, resolver, artifact_service + ): + """Test compiled prompt tracks dependency digests.""" + art1 = artifact_service.create_artifact( + space_id="space-1", + name="dep1", + content="Dep 1", + ) + art2 = artifact_service.create_artifact( + space_id="space-1", + name="dep2", + content="Dep 2", + ) + + content = "{{require:dep1}} and {{require:dep2}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + compiled = compiler.compile(template, content, result) + + assert len(compiled.dependency_digests) == 2 + assert "dep1" in compiled.dependency_digests + assert "dep2" in compiled.dependency_digests + assert compiled.dependency_digests["dep1"] == art1.content_digest + assert compiled.dependency_digests["dep2"] == art2.content_digest + + def test_get_compilation_info( + self, compiler, analyzer, resolver, artifact_service + ): + """Test getting compilation info.""" + artifact_service.create_artifact( + space_id="space-1", + name="dep", + content="Dependency", + ) + + content = "{{require:dep}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + compiled = compiler.compile(template, content, result) + + info = compiler.get_compilation_info(compiled) + assert info["template_id"] == template.id + assert info["dependency_count"] == 1 + assert "dep" in info["dependencies"] + assert info["is_partial"] is False diff --git a/tests/unit/prompts/test_prompt_resolver.py b/tests/unit/prompts/test_prompt_resolver.py new file mode 100644 index 00000000..c84610ea --- /dev/null +++ b/tests/unit/prompts/test_prompt_resolver.py @@ -0,0 +1,248 @@ +"""Unit tests for PromptResolver.""" + +import pytest +import tempfile +from pathlib import Path + +from markitect.prompts.templates.models import PromptTemplate, ContentMacro, MacroKind +from markitect.prompts.templates.analyzer import TemplateAnalyzer +from markitect.prompts.resolver.resolver import PromptResolver +from markitect.prompts.resolver.strategy import MultiSpaceResolutionStrategy, ResolutionConfig +from markitect.prompts.resolver.models import ResolutionStatus +from markitect.prompts.services.artifact_service import ArtifactService +from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository +from markitect.prompts.models import Artifact, ArtifactType + + +@pytest.fixture +def temp_db(): + """Create temporary database.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + yield db_path + Path(db_path).unlink(missing_ok=True) + + +@pytest.fixture +def artifact_service(temp_db): + """Create artifact service with temp database.""" + repository = SQLiteArtifactRepository(temp_db) + return ArtifactService(repository) + + +@pytest.fixture +def resolver(artifact_service): + """Create resolver with multi-space strategy.""" + strategy = MultiSpaceResolutionStrategy() + return PromptResolver(artifact_service, strategy) + + +@pytest.fixture +def analyzer(): + """Create template analyzer.""" + return TemplateAnalyzer() + + +class TestPromptResolver: + """Tests for PromptResolver.""" + + def test_resolve_template_not_analyzed_raises_error(self, resolver): + """Test resolving unanalyzed template raises error.""" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content="{{require:dep}}", + ) + config = ResolutionConfig(space_id="space-1") + + with pytest.raises(ValueError, match="must be analyzed"): + resolver.resolve_template(template, config) + + def test_resolve_template_no_macros(self, resolver, analyzer): + """Test resolving template with no macros.""" + content = "# Simple Template\nNo macros here." + template = PromptTemplate.create( + space_id="space-1", + name="simple", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + assert result.success is True + assert result.status == ResolutionStatus.SUCCESS + assert len(result.context.resolved_macros) == 0 + assert len(result.context.unresolved_required) == 0 + + def test_resolve_required_macro_found(self, resolver, analyzer, artifact_service): + """Test resolving required macro when artifact exists.""" + # Create dependency artifact + artifact_service.create_artifact( + space_id="space-1", + name="glossary", + content="Glossary content here", + ) + + # Create template with required macro + content = "# Template\n{{require:glossary}}" + template = PromptTemplate.create( + space_id="space-1", + name="test-template", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + assert result.success is True + assert result.status == ResolutionStatus.SUCCESS + assert len(result.context.resolved_macros) == 1 + assert result.context.resolved_macros[0].resolved is True + assert result.context.resolved_macros[0].artifact.name == "glossary" + + def test_resolve_required_macro_not_found_fails(self, resolver, analyzer): + """Test resolving required macro when artifact missing (FR-3.2).""" + content = "# Template\n{{require:missing-artifact}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + assert result.success is False + assert result.status == ResolutionStatus.FAILED + assert len(result.context.unresolved_required) == 1 + assert result.context.unresolved_required[0].target == "missing-artifact" + assert len(result.context.errors) > 0 + + def test_resolve_optional_macro_not_found_succeeds(self, resolver, analyzer): + """Test resolving optional macro when missing succeeds (FR-3.3).""" + content = "# Template\n{{optional:missing-optional}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + assert result.success is True # Still succeeds + assert result.status == ResolutionStatus.PARTIAL # But partial + assert len(result.context.unresolved_optional) == 1 + assert result.context.unresolved_optional[0].target == "missing-optional" + + def test_resolve_generate_macro_deferred(self, resolver, analyzer): + """Test generate macro is detected and deferred (FR-3.4).""" + content = "# Template\n{{generate:examples}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + assert result.success is True + assert result.needs_generation is True + assert len(result.context.generator_macros) == 1 + assert result.context.generator_macros[0].target == "examples" + + def test_resolve_multi_space_search_order(self, resolver, analyzer, artifact_service): + """Test multi-space resolution follows search order (FR-3.1).""" + # Create same-named artifact in multiple spaces + artifact_service.create_artifact( + space_id="space-1", + name="common", + content="From space-1", + ) + artifact_service.create_artifact( + space_id="space-2", + name="common", + content="From space-2", + ) + + content = "{{require:common}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + # space-1 has higher priority in search order + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-2"], + ) + result = resolver.resolve_template(template, config) + + # Should resolve from space-1 (higher priority) + assert result.context.resolved_macros[0].space_id == "space-1" + + def test_resolve_falls_back_to_included_space(self, resolver, analyzer, artifact_service): + """Test resolution falls back to included spaces.""" + # Create artifact only in included space + artifact_service.create_artifact( + space_id="space-2", + name="shared-artifact", + content="Shared content", + ) + + content = "{{require:shared-artifact}}" + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-2"], + ) + result = resolver.resolve_template(template, config) + + assert result.success is True + assert result.context.resolved_macros[0].space_id == "space-2" + + def test_resolution_summary(self, resolver, analyzer, artifact_service): + """Test getting resolution summary.""" + artifact_service.create_artifact( + space_id="space-1", + name="found", + content="Content", + ) + + content = """ + {{require:found}} + {{require:missing}} + {{optional:optional-missing}} + {{generate:gen}} + """ + template = PromptTemplate.create( + space_id="space-1", + name="test", + content=content, + ) + analyzer.analyze(template, content) + + config = ResolutionConfig(space_id="space-1") + result = resolver.resolve_template(template, config) + + summary = resolver.get_resolution_summary(result) + assert summary["resolved_count"] == 1 + assert summary["unresolved_required"] == ["missing"] + assert summary["unresolved_optional"] == ["optional-missing"] + assert summary["needs_generation"] is True + assert summary["generator_count"] == 1 diff --git a/tests/unit/prompts/test_resolution_strategy.py b/tests/unit/prompts/test_resolution_strategy.py new file mode 100644 index 00000000..d2d165bf --- /dev/null +++ b/tests/unit/prompts/test_resolution_strategy.py @@ -0,0 +1,182 @@ +"""Unit tests for resolution strategies.""" + +import pytest +from markitect.prompts.resolver.strategy import ( + ResolutionConfig, + MultiSpaceResolutionStrategy, + SingleSpaceResolutionStrategy, +) + + +class TestResolutionConfig: + """Tests for ResolutionConfig.""" + + def test_create_minimal_config(self): + """Test creating config with only required fields.""" + config = ResolutionConfig(space_id="space-1") + assert config.space_id == "space-1" + assert config.included_spaces == [] + assert config.default_space_id is None + assert config.shared_space_id is None + assert config.max_generation_depth == 3 + + def test_create_full_config(self): + """Test creating config with all fields.""" + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-2", "space-3"], + default_space_id="default-space", + shared_space_id="shared-space", + max_generation_depth=5, + ) + assert config.space_id == "space-1" + assert config.included_spaces == ["space-2", "space-3"] + assert config.default_space_id == "default-space" + assert config.shared_space_id == "shared-space" + assert config.max_generation_depth == 5 + + def test_config_to_dict(self): + """Test serialization.""" + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-2"], + ) + data = config.to_dict() + assert data["space_id"] == "space-1" + assert data["included_spaces"] == ["space-2"] + assert "max_generation_depth" in data + + def test_config_from_dict(self): + """Test deserialization.""" + data = { + "space_id": "space-1", + "included_spaces": ["space-2", "space-3"], + "default_space_id": "default", + "shared_space_id": "shared", + "max_generation_depth": 4, + } + config = ResolutionConfig.from_dict(data) + assert config.space_id == "space-1" + assert config.included_spaces == ["space-2", "space-3"] + assert config.default_space_id == "default" + assert config.shared_space_id == "shared" + assert config.max_generation_depth == 4 + + +class TestMultiSpaceResolutionStrategy: + """Tests for MultiSpaceResolutionStrategy.""" + + def setup_method(self): + """Setup strategy for each test.""" + self.strategy = MultiSpaceResolutionStrategy() + + def test_search_order_local_only(self): + """Test search order with only local space.""" + config = ResolutionConfig(space_id="space-1") + order = self.strategy.get_search_order(config) + assert order == ["space-1"] + + def test_search_order_with_included(self): + """Test search order with included spaces.""" + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-2", "space-3"], + ) + order = self.strategy.get_search_order(config) + assert order == ["space-1", "space-2", "space-3"] + + def test_search_order_with_default(self): + """Test search order with default space.""" + config = ResolutionConfig( + space_id="space-1", + default_space_id="default-space", + ) + order = self.strategy.get_search_order(config) + assert order == ["space-1", "default-space"] + + def test_search_order_with_shared(self): + """Test search order with shared space.""" + config = ResolutionConfig( + space_id="space-1", + shared_space_id="shared-space", + ) + order = self.strategy.get_search_order(config) + assert order == ["space-1", "shared-space"] + + def test_search_order_full_config(self): + """Test full resolution order (FR-3.1).""" + config = ResolutionConfig( + space_id="local", + included_spaces=["included-1", "included-2"], + default_space_id="default", + shared_space_id="shared", + ) + order = self.strategy.get_search_order(config) + # FR-3.1: Local, Included, Default, Shared + assert order == ["local", "included-1", "included-2", "default", "shared"] + + def test_search_order_removes_duplicates(self): + """Test that duplicates are removed.""" + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-2", "space-1", "space-3"], # space-1 duplicate + default_space_id="space-2", # space-2 duplicate + ) + order = self.strategy.get_search_order(config) + # Should have each space only once, preserving first occurrence + assert order == ["space-1", "space-2", "space-3"] + assert len(order) == 3 + + def test_search_order_preserves_included_order(self): + """Test that included spaces order is preserved.""" + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-a", "space-b", "space-c"], + ) + order = self.strategy.get_search_order(config) + # Included spaces should appear in specified order + assert order.index("space-a") < order.index("space-b") + assert order.index("space-b") < order.index("space-c") + + def test_search_order_priority(self): + """Test search order priority.""" + config = ResolutionConfig( + space_id="local", + included_spaces=["included"], + default_space_id="default", + shared_space_id="shared", + ) + order = self.strategy.get_search_order(config) + # Local has highest priority (index 0) + assert order[0] == "local" + # Shared has lowest priority (last index) + assert order[-1] == "shared" + + +class TestSingleSpaceResolutionStrategy: + """Tests for SingleSpaceResolutionStrategy.""" + + def setup_method(self): + """Setup strategy for each test.""" + self.strategy = SingleSpaceResolutionStrategy() + + def test_search_order_only_local(self): + """Test that only local space is returned.""" + config = ResolutionConfig( + space_id="space-1", + included_spaces=["space-2", "space-3"], + default_space_id="default", + shared_space_id="shared", + ) + order = self.strategy.get_search_order(config) + assert order == ["space-1"] + + def test_search_order_ignores_other_spaces(self): + """Test that other configured spaces are ignored.""" + config = ResolutionConfig( + space_id="my-space", + included_spaces=["ignored-1", "ignored-2"], + ) + order = self.strategy.get_search_order(config) + assert len(order) == 1 + assert order[0] == "my-space"