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:
31
markitect/prompts/resolver/__init__.py
Normal file
31
markitect/prompts/resolver/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
202
markitect/prompts/resolver/compiler.py
Normal file
202
markitect/prompts/resolver/compiler.py
Normal 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",
|
||||||
|
}
|
||||||
183
markitect/prompts/resolver/models.py
Normal file
183
markitect/prompts/resolver/models.py
Normal 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,
|
||||||
|
}
|
||||||
223
markitect/prompts/resolver/resolver.py
Normal file
223
markitect/prompts/resolver/resolver.py
Normal 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,
|
||||||
|
}
|
||||||
152
markitect/prompts/resolver/strategy.py
Normal file
152
markitect/prompts/resolver/strategy.py
Normal 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]
|
||||||
23
migrations/prompts/002_create_resolution_config.sql
Normal file
23
migrations/prompts/002_create_resolution_config.sql
Normal 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
|
||||||
259
tests/unit/prompts/test_context_compiler.py
Normal file
259
tests/unit/prompts/test_context_compiler.py
Normal 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
|
||||||
248
tests/unit/prompts/test_prompt_resolver.py
Normal file
248
tests/unit/prompts/test_prompt_resolver.py
Normal 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
|
||||||
182
tests/unit/prompts/test_resolution_strategy.py
Normal file
182
tests/unit/prompts/test_resolution_strategy.py
Normal 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"
|
||||||
Reference in New Issue
Block a user