feat(prompts): implement Phase 3 - Resolver Engine (FR-3)

Implement deterministic multi-space resolution with configurable search order.

Core Features:
- ResolutionContext and ResolutionResult for tracking resolution state
- MultiSpaceResolutionStrategy implementing FR-3.1 search order:
  1. Local InformationSpace
  2. Explicitly included InformationSpaces
  3. Default InformationSpace
  4. Team/Shared InformationSpace
- PromptResolver with macro resolution logic
- ContextCompiler for assembling resolved prompts
- ResolutionConfig for configurable resolution behavior

Resolution Behavior:
- Required macros fail if not found (FR-3.2)
- Optional macros resolve to empty (FR-3.3)
- Generate macros detected for deferred execution (FR-3.4)
- Deterministic search order with duplicate removal
- Partial compilation support for debugging

Tests (31 passing):
- 14 strategy tests (search order, duplicates, priority)
- 9 resolver tests (required, optional, generate, multi-space)
- 8 compiler tests (substitution, dependencies, digests)

Implements:
- FR-3.1: Deterministic resolution order
- FR-3.2: Required macro validation
- FR-3.3: Optional macro fallback
- FR-3.4: Generate macro detection
- FR-3.5: Max generation depth configuration

Files Created:
- markitect/prompts/resolver/models.py
- markitect/prompts/resolver/strategy.py
- markitect/prompts/resolver/resolver.py
- markitect/prompts/resolver/compiler.py
- migrations/prompts/002_create_resolution_config.sql
- tests/unit/prompts/test_resolution_strategy.py
- tests/unit/prompts/test_prompt_resolver.py
- tests/unit/prompts/test_context_compiler.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 22:45:46 +01:00
parent e6840fe696
commit 5f463e5b20
9 changed files with 1503 additions and 0 deletions

View File

@@ -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",
]

View File

@@ -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",
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"