feat(prompts): implement Phase 2 - Templates & Macros (FR-2)
Implement PromptTemplate models with ContentMacro parsing and analysis.
Core Features:
- PromptTemplate extending Artifact for template-specific operations
- ContentMacro model supporting REQUIRED, OPTIONAL, GENERATE kinds
- MacroParser for extracting macros from template content
- TemplateAnalyzer for dependency extraction and validation
- TemplateService for high-level template operations
- Template metadata for model hints and expected inputs
Macro Syntax:
- {{require:artifact-name}} - Required dependency
- {{optional:artifact-name}} - Optional dependency
- {{generate:template-name|param=value}} - Nested generation
Tests (38 passing):
- 18 template model tests (macros, templates, metadata)
- 20 parser tests (parsing, validation, parameters, aliases)
Implements:
- FR-2.1: PromptTemplate as content artifact with macros
- FR-2.2: ContentMacro detection and extraction
- FR-2.3: Required/Optional/Generate macro kinds
Files Created:
- markitect/prompts/templates/models.py
- markitect/prompts/templates/parser.py
- markitect/prompts/templates/analyzer.py
- markitect/prompts/services/template_service.py
- tests/unit/prompts/test_template_models.py
- tests/unit/prompts/test_macro_parser.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
260
markitect/prompts/services/template_service.py
Normal file
260
markitect/prompts/services/template_service.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Template service for high-level template management operations.
|
||||
|
||||
This service extends artifact service to handle PromptTemplate-specific
|
||||
operations including macro analysis and dependency extraction.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from markitect.prompts.models import ArtifactMetadata, ArtifactType
|
||||
from markitect.prompts.templates.models import (
|
||||
PromptTemplate,
|
||||
TemplateMetadata,
|
||||
)
|
||||
from markitect.prompts.templates.analyzer import TemplateAnalyzer, TemplateAnalysisResult
|
||||
from markitect.prompts.services.artifact_service import ArtifactService
|
||||
from markitect.prompts.repositories.interfaces import ArtifactNotFoundError
|
||||
|
||||
|
||||
class TemplateService:
|
||||
"""
|
||||
Service for template management operations.
|
||||
|
||||
Provides high-level business logic for creating and analyzing templates,
|
||||
building on top of ArtifactService.
|
||||
"""
|
||||
|
||||
def __init__(self, artifact_service: ArtifactService):
|
||||
"""
|
||||
Initialize service with artifact service.
|
||||
|
||||
Args:
|
||||
artifact_service: Artifact service for persistence
|
||||
"""
|
||||
self.artifact_service = artifact_service
|
||||
self.analyzer = TemplateAnalyzer()
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
space_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
artifact_metadata: Optional[ArtifactMetadata] = None,
|
||||
template_metadata: Optional[TemplateMetadata] = None,
|
||||
analyze: bool = True,
|
||||
) -> PromptTemplate:
|
||||
"""
|
||||
Create and optionally analyze a new template.
|
||||
|
||||
Args:
|
||||
space_id: ID of containing space
|
||||
name: Template name
|
||||
content: Template content with macros
|
||||
artifact_metadata: General artifact metadata
|
||||
template_metadata: Template-specific metadata
|
||||
analyze: Whether to analyze macros immediately
|
||||
|
||||
Returns:
|
||||
Created template (analyzed if analyze=True)
|
||||
|
||||
Raises:
|
||||
DuplicateArtifactError: If template already exists
|
||||
MacroParsingError: If macro syntax is invalid (when analyze=True)
|
||||
"""
|
||||
# Create template
|
||||
template = PromptTemplate.create(
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
content=content,
|
||||
artifact_metadata=artifact_metadata,
|
||||
template_metadata=template_metadata,
|
||||
)
|
||||
|
||||
# Persist artifact
|
||||
self.artifact_service.create_artifact(
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
content=content,
|
||||
artifact_type=ArtifactType.TEMPLATE,
|
||||
metadata=artifact_metadata,
|
||||
)
|
||||
|
||||
# Analyze if requested
|
||||
if analyze:
|
||||
self.analyzer.analyze(template, content)
|
||||
|
||||
return template
|
||||
|
||||
def get_template(self, template_id: str, content: str) -> PromptTemplate:
|
||||
"""
|
||||
Retrieve template by ID.
|
||||
|
||||
Args:
|
||||
template_id: Template identifier
|
||||
content: Template content (needed to avoid storing content in DB twice)
|
||||
|
||||
Returns:
|
||||
PromptTemplate instance
|
||||
|
||||
Raises:
|
||||
ArtifactNotFoundError: If template doesn't exist
|
||||
"""
|
||||
artifact = self.artifact_service.get_artifact(template_id)
|
||||
if artifact.artifact_type != ArtifactType.TEMPLATE:
|
||||
raise ValueError(
|
||||
f"Artifact '{template_id}' is not a template "
|
||||
f"(type: {artifact.artifact_type})"
|
||||
)
|
||||
|
||||
template = PromptTemplate.from_artifact(artifact)
|
||||
# Analyze to populate macros
|
||||
self.analyzer.analyze(template, content)
|
||||
return template
|
||||
|
||||
def get_template_by_name(
|
||||
self,
|
||||
space_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
) -> PromptTemplate:
|
||||
"""
|
||||
Retrieve template by space and name.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
name: Template name
|
||||
content: Template content
|
||||
|
||||
Returns:
|
||||
PromptTemplate instance
|
||||
|
||||
Raises:
|
||||
ArtifactNotFoundError: If template doesn't exist
|
||||
"""
|
||||
artifact = self.artifact_service.get_artifact_by_name(space_id, name)
|
||||
if artifact.artifact_type != ArtifactType.TEMPLATE:
|
||||
raise ValueError(
|
||||
f"Artifact '{name}' is not a template "
|
||||
f"(type: {artifact.artifact_type})"
|
||||
)
|
||||
|
||||
template = PromptTemplate.from_artifact(artifact)
|
||||
self.analyzer.analyze(template, content)
|
||||
return template
|
||||
|
||||
def analyze_template(
|
||||
self,
|
||||
template: PromptTemplate,
|
||||
content: str,
|
||||
) -> TemplateAnalysisResult:
|
||||
"""
|
||||
Analyze template to extract macros and dependencies.
|
||||
|
||||
Args:
|
||||
template: Template to analyze
|
||||
content: Template content
|
||||
|
||||
Returns:
|
||||
Analysis result with dependency information
|
||||
|
||||
Raises:
|
||||
MacroParsingError: If macro syntax is invalid
|
||||
"""
|
||||
return self.analyzer.analyze(template, content)
|
||||
|
||||
def list_templates(self, space_id: str) -> List[PromptTemplate]:
|
||||
"""
|
||||
List all templates in a space.
|
||||
|
||||
Note: Templates are returned without macro analysis.
|
||||
Call analyze_template() on individual templates as needed.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
|
||||
Returns:
|
||||
List of templates (unanalyzed)
|
||||
"""
|
||||
artifacts = self.artifact_service.list_artifacts(
|
||||
space_id=space_id,
|
||||
artifact_type=ArtifactType.TEMPLATE,
|
||||
)
|
||||
|
||||
templates = [PromptTemplate.from_artifact(a) for a in artifacts]
|
||||
return templates
|
||||
|
||||
def quick_check_content(self, content: str) -> dict:
|
||||
"""
|
||||
Quick validation of template content.
|
||||
|
||||
Useful for checking content before template creation.
|
||||
|
||||
Args:
|
||||
content: Template content to check
|
||||
|
||||
Returns:
|
||||
Dictionary with macro counts and validation info
|
||||
"""
|
||||
return self.analyzer.quick_check(content)
|
||||
|
||||
def update_template_content(
|
||||
self,
|
||||
template_id: str,
|
||||
new_content: str,
|
||||
reanalyze: bool = True,
|
||||
) -> PromptTemplate:
|
||||
"""
|
||||
Update template content.
|
||||
|
||||
Args:
|
||||
template_id: Template to update
|
||||
new_content: New content
|
||||
reanalyze: Whether to reanalyze macros
|
||||
|
||||
Returns:
|
||||
Updated template
|
||||
|
||||
Raises:
|
||||
ArtifactNotFoundError: If template doesn't exist
|
||||
MacroParsingError: If new content has invalid macros
|
||||
"""
|
||||
# Update artifact content
|
||||
artifact = self.artifact_service.update_artifact_content(
|
||||
template_id,
|
||||
new_content,
|
||||
)
|
||||
|
||||
# Create template from updated artifact
|
||||
template = PromptTemplate.from_artifact(artifact)
|
||||
|
||||
# Reanalyze if requested
|
||||
if reanalyze:
|
||||
self.analyzer.analyze(template, new_content)
|
||||
|
||||
return template
|
||||
|
||||
def delete_template(self, template_id: str) -> bool:
|
||||
"""
|
||||
Delete a template.
|
||||
|
||||
Args:
|
||||
template_id: Template to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
return self.artifact_service.delete_artifact(template_id)
|
||||
|
||||
def template_exists(self, space_id: str, name: str) -> bool:
|
||||
"""
|
||||
Check if template exists.
|
||||
|
||||
Args:
|
||||
space_id: Space identifier
|
||||
name: Template name
|
||||
|
||||
Returns:
|
||||
True if template exists
|
||||
"""
|
||||
return self.artifact_service.artifact_exists(space_id, name)
|
||||
24
markitect/prompts/templates/__init__.py
Normal file
24
markitect/prompts/templates/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Template and macro management for Prompt Dependency Resolution.
|
||||
|
||||
This package provides PromptTemplate definitions, macro parsing,
|
||||
and template analysis for dependency extraction.
|
||||
"""
|
||||
|
||||
from markitect.prompts.templates.models import (
|
||||
PromptTemplate,
|
||||
ContentMacro,
|
||||
MacroKind,
|
||||
TemplateMetadata,
|
||||
)
|
||||
from markitect.prompts.templates.parser import MacroParser
|
||||
from markitect.prompts.templates.analyzer import TemplateAnalyzer
|
||||
|
||||
__all__ = [
|
||||
"PromptTemplate",
|
||||
"ContentMacro",
|
||||
"MacroKind",
|
||||
"TemplateMetadata",
|
||||
"MacroParser",
|
||||
"TemplateAnalyzer",
|
||||
]
|
||||
193
markitect/prompts/templates/analyzer.py
Normal file
193
markitect/prompts/templates/analyzer.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Template analyzer for dependency extraction.
|
||||
|
||||
Implements FR-2.2: TemplateAnalysis
|
||||
Analyzes templates to extract and catalog content macros.
|
||||
"""
|
||||
|
||||
from typing import List, Set
|
||||
|
||||
from markitect.prompts.templates.models import PromptTemplate, MacroKind
|
||||
from markitect.prompts.templates.parser import MacroParser
|
||||
|
||||
|
||||
class TemplateAnalysisResult:
|
||||
"""
|
||||
Result of template analysis.
|
||||
|
||||
Provides summary information about template dependencies.
|
||||
|
||||
Attributes:
|
||||
template: Analyzed template
|
||||
total_macros: Total number of macros found
|
||||
required_count: Number of required macros
|
||||
optional_count: Number of optional macros
|
||||
generate_count: Number of generate macros
|
||||
unique_targets: Set of unique target names
|
||||
has_circular_potential: Whether template references itself
|
||||
"""
|
||||
|
||||
def __init__(self, template: PromptTemplate):
|
||||
self.template = template
|
||||
self.total_macros = len(template.macros)
|
||||
self.required_count = len(template.get_required_dependencies())
|
||||
self.optional_count = len(template.get_optional_dependencies())
|
||||
self.generate_count = len(template.get_generators())
|
||||
self.unique_targets = self._get_unique_targets()
|
||||
self.has_circular_potential = template.name in self.unique_targets
|
||||
|
||||
def _get_unique_targets(self) -> Set[str]:
|
||||
"""Get set of unique target names from all macros."""
|
||||
return {macro.target for macro in self.template.macros}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert result to dictionary."""
|
||||
return {
|
||||
"template_id": self.template.id,
|
||||
"template_name": self.template.name,
|
||||
"total_macros": self.total_macros,
|
||||
"required_count": self.required_count,
|
||||
"optional_count": self.optional_count,
|
||||
"generate_count": self.generate_count,
|
||||
"unique_targets": list(self.unique_targets),
|
||||
"has_circular_potential": self.has_circular_potential,
|
||||
}
|
||||
|
||||
|
||||
class TemplateAnalyzer:
|
||||
"""
|
||||
Analyzer for extracting dependencies from templates.
|
||||
|
||||
Implements FR-2.2: TemplateAnalysis
|
||||
Uses MacroParser to detect and extract ContentMacros, then populates
|
||||
the template's macro list and provides analysis results.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.parser = MacroParser()
|
||||
|
||||
def analyze(self, template: PromptTemplate, content: str) -> TemplateAnalysisResult:
|
||||
"""
|
||||
Analyze template content and extract macros.
|
||||
|
||||
Updates the template's macros list and marks it as analyzed.
|
||||
|
||||
Args:
|
||||
template: Template to analyze
|
||||
content: Template content to parse
|
||||
|
||||
Returns:
|
||||
Analysis result with summary information
|
||||
|
||||
Raises:
|
||||
MacroParsingError: If macro syntax is invalid
|
||||
"""
|
||||
# Parse macros from content
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
# Update template
|
||||
template.macros = macros
|
||||
template.analyzed = True
|
||||
|
||||
# Create and return analysis result
|
||||
return TemplateAnalysisResult(template)
|
||||
|
||||
def quick_check(self, content: str) -> dict:
|
||||
"""
|
||||
Quick check of template content without full analysis.
|
||||
|
||||
Useful for validation before template creation.
|
||||
|
||||
Args:
|
||||
content: Template content
|
||||
|
||||
Returns:
|
||||
Dictionary with macro counts and basic info
|
||||
"""
|
||||
has_macros = self.parser.has_macros(content)
|
||||
if not has_macros:
|
||||
return {
|
||||
"has_macros": False,
|
||||
"required": 0,
|
||||
"optional": 0,
|
||||
"generate": 0,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
counts = self.parser.count_macros(content)
|
||||
return {
|
||||
"has_macros": True,
|
||||
"required": counts['required'],
|
||||
"optional": counts['optional'],
|
||||
"generate": counts['generate'],
|
||||
"total": sum(counts.values()),
|
||||
}
|
||||
|
||||
def find_dependencies(self, template: PromptTemplate) -> dict:
|
||||
"""
|
||||
Extract dependency information from analyzed template.
|
||||
|
||||
Args:
|
||||
template: Analyzed template
|
||||
|
||||
Returns:
|
||||
Dictionary with categorized dependencies
|
||||
|
||||
Raises:
|
||||
ValueError: If template has not been analyzed
|
||||
"""
|
||||
if not template.analyzed:
|
||||
raise ValueError(
|
||||
f"Template '{template.name}' has not been analyzed. "
|
||||
"Call analyze() first."
|
||||
)
|
||||
|
||||
return {
|
||||
"required": template.get_required_dependencies(),
|
||||
"optional": template.get_optional_dependencies(),
|
||||
"generators": template.get_generators(),
|
||||
"all_targets": list({m.target for m in template.macros}),
|
||||
}
|
||||
|
||||
def validate_references(
|
||||
self,
|
||||
template: PromptTemplate,
|
||||
available_artifacts: Set[str],
|
||||
) -> dict:
|
||||
"""
|
||||
Validate that template references can be resolved.
|
||||
|
||||
Args:
|
||||
template: Template to validate
|
||||
available_artifacts: Set of available artifact names
|
||||
|
||||
Returns:
|
||||
Dictionary with validation results:
|
||||
- missing_required: List of missing required artifacts
|
||||
- missing_optional: List of missing optional artifacts
|
||||
- missing_generators: List of missing generator templates
|
||||
- is_valid: True if all required deps are available
|
||||
|
||||
Raises:
|
||||
ValueError: If template has not been analyzed
|
||||
"""
|
||||
if not template.analyzed:
|
||||
raise ValueError(
|
||||
f"Template '{template.name}' has not been analyzed. "
|
||||
"Call analyze() first."
|
||||
)
|
||||
|
||||
required = set(template.get_required_dependencies())
|
||||
optional = set(template.get_optional_dependencies())
|
||||
generators = set(template.get_generators())
|
||||
|
||||
missing_required = required - available_artifacts
|
||||
missing_optional = optional - available_artifacts
|
||||
missing_generators = generators - available_artifacts
|
||||
|
||||
return {
|
||||
"missing_required": list(missing_required),
|
||||
"missing_optional": list(missing_optional),
|
||||
"missing_generators": list(missing_generators),
|
||||
"is_valid": len(missing_required) == 0,
|
||||
}
|
||||
275
markitect/prompts/templates/models.py
Normal file
275
markitect/prompts/templates/models.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Template models for Prompt Dependency Resolution.
|
||||
|
||||
Defines PromptTemplate and ContentMacro models for template-based
|
||||
prompt generation with dependency resolution.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, List, Optional
|
||||
from enum import Enum
|
||||
|
||||
from markitect.prompts.models import Artifact, ArtifactType, ArtifactMetadata
|
||||
|
||||
|
||||
class MacroKind(Enum):
|
||||
"""Types of content macros supported in templates."""
|
||||
REQUIRED = "required" # Must be resolved, fail if missing
|
||||
OPTIONAL = "optional" # Resolve if available, empty if missing
|
||||
GENERATE = "generate" # Trigger generator template execution
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContentMacro:
|
||||
"""
|
||||
Content macro extracted from a template.
|
||||
|
||||
Implements FR-2.3: ContentMacro kinds (Required, Optional, Generate)
|
||||
|
||||
Syntax: {{<kind>:<target>[|<param1>=<value1>|<param2>=<value2>...]}}
|
||||
|
||||
Examples:
|
||||
{{require:glossary}}
|
||||
{{optional:technical-constraints}}
|
||||
{{generate:code-examples|language=python|framework=fastapi}}
|
||||
|
||||
Attributes:
|
||||
kind: Type of macro (required, optional, generate)
|
||||
target: Name of artifact or template to resolve
|
||||
parameters: Optional parameters for macro resolution
|
||||
raw_text: Original macro text from template
|
||||
line_number: Line number where macro appears (for error reporting)
|
||||
"""
|
||||
kind: MacroKind
|
||||
target: str
|
||||
parameters: Dict[str, str] = field(default_factory=dict)
|
||||
raw_text: str = ""
|
||||
line_number: int = 0
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of macro."""
|
||||
params = ''.join(f"|{k}={v}" for k, v in self.parameters.items())
|
||||
return f"{{{{{self.kind.value}:{self.target}{params}}}}}"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert macro to dictionary for serialization."""
|
||||
return {
|
||||
"kind": self.kind.value,
|
||||
"target": self.target,
|
||||
"parameters": self.parameters,
|
||||
"raw_text": self.raw_text,
|
||||
"line_number": self.line_number,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ContentMacro":
|
||||
"""Create macro from dictionary."""
|
||||
return cls(
|
||||
kind=MacroKind(data["kind"]),
|
||||
target=data["target"],
|
||||
parameters=data.get("parameters", {}),
|
||||
raw_text=data.get("raw_text", ""),
|
||||
line_number=data.get("line_number", 0),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateMetadata:
|
||||
"""
|
||||
Extended metadata specific to prompt templates.
|
||||
|
||||
Attributes:
|
||||
purpose: Description of what the template generates
|
||||
model_hints: Suggested model parameters
|
||||
expected_inputs: Documentation of expected required/optional inputs
|
||||
output_type: Expected type of generated content
|
||||
"""
|
||||
purpose: Optional[str] = None
|
||||
model_hints: Dict[str, Any] = field(default_factory=dict)
|
||||
expected_inputs: List[str] = field(default_factory=list)
|
||||
output_type: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"purpose": self.purpose,
|
||||
"model_hints": self.model_hints,
|
||||
"expected_inputs": self.expected_inputs,
|
||||
"output_type": self.output_type,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "TemplateMetadata":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
purpose=data.get("purpose"),
|
||||
model_hints=data.get("model_hints", {}),
|
||||
expected_inputs=data.get("expected_inputs", []),
|
||||
output_type=data.get("output_type"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplate:
|
||||
"""
|
||||
Prompt template with content macros.
|
||||
|
||||
Extends Artifact with template-specific functionality.
|
||||
Implements FR-2: PromptTemplate Definition
|
||||
|
||||
A PromptTemplate is an Artifact of type TEMPLATE containing ContentMacros
|
||||
that reference other artifacts or trigger nested generation.
|
||||
|
||||
Attributes:
|
||||
artifact: Underlying artifact
|
||||
template_metadata: Template-specific metadata
|
||||
macros: Extracted content macros
|
||||
analyzed: Whether template has been analyzed
|
||||
"""
|
||||
artifact: Artifact
|
||||
template_metadata: TemplateMetadata = field(default_factory=TemplateMetadata)
|
||||
macros: List[ContentMacro] = field(default_factory=list)
|
||||
analyzed: bool = False
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
space_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
artifact_metadata: Optional[ArtifactMetadata] = None,
|
||||
template_metadata: Optional[TemplateMetadata] = None,
|
||||
) -> "PromptTemplate":
|
||||
"""
|
||||
Create a new template.
|
||||
|
||||
Args:
|
||||
space_id: ID of containing space
|
||||
name: Template name
|
||||
content: Template content with macros
|
||||
artifact_metadata: General artifact metadata
|
||||
template_metadata: Template-specific metadata
|
||||
|
||||
Returns:
|
||||
New PromptTemplate instance
|
||||
"""
|
||||
artifact = Artifact.create(
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
content=content,
|
||||
artifact_type=ArtifactType.TEMPLATE,
|
||||
metadata=artifact_metadata,
|
||||
)
|
||||
|
||||
return cls(
|
||||
artifact=artifact,
|
||||
template_metadata=template_metadata or TemplateMetadata(),
|
||||
macros=[],
|
||||
analyzed=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_artifact(cls, artifact: Artifact) -> "PromptTemplate":
|
||||
"""
|
||||
Create template from existing artifact.
|
||||
|
||||
Args:
|
||||
artifact: Artifact to wrap
|
||||
|
||||
Returns:
|
||||
PromptTemplate instance
|
||||
|
||||
Raises:
|
||||
ValueError: If artifact is not of type TEMPLATE
|
||||
"""
|
||||
if artifact.artifact_type != ArtifactType.TEMPLATE:
|
||||
raise ValueError(
|
||||
f"Artifact must be of type TEMPLATE, got {artifact.artifact_type}"
|
||||
)
|
||||
|
||||
return cls(
|
||||
artifact=artifact,
|
||||
template_metadata=TemplateMetadata(),
|
||||
macros=[],
|
||||
analyzed=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Get template ID."""
|
||||
return self.artifact.id
|
||||
|
||||
@property
|
||||
def space_id(self) -> str:
|
||||
"""Get space ID."""
|
||||
return self.artifact.space_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get template name."""
|
||||
return self.artifact.name
|
||||
|
||||
@property
|
||||
def content_digest(self) -> str:
|
||||
"""Get content digest."""
|
||||
return self.artifact.content_digest
|
||||
|
||||
def get_required_dependencies(self) -> List[str]:
|
||||
"""
|
||||
Get list of required artifact names.
|
||||
|
||||
Returns:
|
||||
List of artifact names from required macros
|
||||
"""
|
||||
return [
|
||||
macro.target
|
||||
for macro in self.macros
|
||||
if macro.kind == MacroKind.REQUIRED
|
||||
]
|
||||
|
||||
def get_optional_dependencies(self) -> List[str]:
|
||||
"""
|
||||
Get list of optional artifact names.
|
||||
|
||||
Returns:
|
||||
List of artifact names from optional macros
|
||||
"""
|
||||
return [
|
||||
macro.target
|
||||
for macro in self.macros
|
||||
if macro.kind == MacroKind.OPTIONAL
|
||||
]
|
||||
|
||||
def get_generators(self) -> List[str]:
|
||||
"""
|
||||
Get list of generator template names.
|
||||
|
||||
Returns:
|
||||
List of template names from generate macros
|
||||
"""
|
||||
return [
|
||||
macro.target
|
||||
for macro in self.macros
|
||||
if macro.kind == MacroKind.GENERATE
|
||||
]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert template to dictionary for serialization."""
|
||||
return {
|
||||
"artifact": self.artifact.to_dict(),
|
||||
"template_metadata": self.template_metadata.to_dict(),
|
||||
"macros": [macro.to_dict() for macro in self.macros],
|
||||
"analyzed": self.analyzed,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PromptTemplate":
|
||||
"""Create template from dictionary."""
|
||||
return cls(
|
||||
artifact=Artifact.from_dict(data["artifact"]),
|
||||
template_metadata=TemplateMetadata.from_dict(
|
||||
data.get("template_metadata", {})
|
||||
),
|
||||
macros=[ContentMacro.from_dict(m) for m in data.get("macros", [])],
|
||||
analyzed=data.get("analyzed", False),
|
||||
)
|
||||
214
markitect/prompts/templates/parser.py
Normal file
214
markitect/prompts/templates/parser.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Macro parser for extracting ContentMacros from template content.
|
||||
|
||||
Implements FR-2.2: Macro detection and extraction
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from markitect.prompts.templates.models import ContentMacro, MacroKind
|
||||
|
||||
|
||||
class MacroParsingError(Exception):
|
||||
"""Raised when macro syntax is invalid."""
|
||||
pass
|
||||
|
||||
|
||||
class MacroParser:
|
||||
"""
|
||||
Parser for extracting content macros from template text.
|
||||
|
||||
Supports macro syntax:
|
||||
{{<kind>:<target>[|<param1>=<value1>|<param2>=<value2>...]}}
|
||||
|
||||
Where kind is: require, optional, or generate
|
||||
|
||||
Examples:
|
||||
{{require:glossary}}
|
||||
{{optional:technical-constraints}}
|
||||
{{generate:code-examples|language=python|framework=fastapi}}
|
||||
"""
|
||||
|
||||
# Macro pattern: {{kind:target|param=value|...}}
|
||||
# More permissive pattern to catch all macro-like syntax for validation
|
||||
# Allows empty target to enable validation error messages
|
||||
MACRO_PATTERN = re.compile(
|
||||
r'\{\{([a-zA-Z]+):([^}|]*)([^}]*)\}\}',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Parameter pattern: |key=value
|
||||
PARAM_PATTERN = re.compile(r'\|([^=]+)=([^|]+)')
|
||||
|
||||
# Supported macro kinds mapping
|
||||
KIND_MAPPING = {
|
||||
'require': MacroKind.REQUIRED,
|
||||
'required': MacroKind.REQUIRED,
|
||||
'optional': MacroKind.OPTIONAL,
|
||||
'generate': MacroKind.GENERATE,
|
||||
'gen': MacroKind.GENERATE,
|
||||
}
|
||||
|
||||
def parse(self, content: str) -> List[ContentMacro]:
|
||||
"""
|
||||
Extract all content macros from template content.
|
||||
|
||||
Args:
|
||||
content: Template content string
|
||||
|
||||
Returns:
|
||||
List of extracted ContentMacros
|
||||
|
||||
Raises:
|
||||
MacroParsingError: If macro syntax is invalid
|
||||
"""
|
||||
macros = []
|
||||
lines = content.split('\n')
|
||||
|
||||
for line_num, line in enumerate(lines, start=1):
|
||||
line_macros = self._parse_line(line, line_num)
|
||||
macros.extend(line_macros)
|
||||
|
||||
return macros
|
||||
|
||||
def _parse_line(self, line: str, line_number: int) -> List[ContentMacro]:
|
||||
"""
|
||||
Extract macros from a single line.
|
||||
|
||||
Args:
|
||||
line: Line of text
|
||||
line_number: Line number for error reporting
|
||||
|
||||
Returns:
|
||||
List of macros found in line
|
||||
"""
|
||||
macros = []
|
||||
|
||||
for match in self.MACRO_PATTERN.finditer(line):
|
||||
try:
|
||||
macro = self._parse_match(match, line_number)
|
||||
macros.append(macro)
|
||||
except MacroParsingError as e:
|
||||
# Add line context to error
|
||||
raise MacroParsingError(
|
||||
f"Line {line_number}: {e}"
|
||||
) from e
|
||||
|
||||
return macros
|
||||
|
||||
def _parse_match(self, match: re.Match, line_number: int) -> ContentMacro:
|
||||
"""
|
||||
Parse a regex match into a ContentMacro.
|
||||
|
||||
Args:
|
||||
match: Regex match object
|
||||
line_number: Line number
|
||||
|
||||
Returns:
|
||||
Parsed ContentMacro
|
||||
|
||||
Raises:
|
||||
MacroParsingError: If macro is malformed
|
||||
"""
|
||||
kind_str = match.group(1).lower()
|
||||
target = match.group(2).strip()
|
||||
params_str = match.group(3)
|
||||
raw_text = match.group(0)
|
||||
|
||||
# Validate and map kind
|
||||
if kind_str not in self.KIND_MAPPING:
|
||||
raise MacroParsingError(
|
||||
f"Invalid macro kind '{kind_str}', expected: require, optional, or generate"
|
||||
)
|
||||
|
||||
kind = self.KIND_MAPPING[kind_str]
|
||||
|
||||
# Validate target
|
||||
if not target:
|
||||
raise MacroParsingError(
|
||||
f"Macro target cannot be empty in: {raw_text}"
|
||||
)
|
||||
|
||||
# Parse parameters
|
||||
parameters = self._parse_parameters(params_str)
|
||||
|
||||
return ContentMacro(
|
||||
kind=kind,
|
||||
target=target,
|
||||
parameters=parameters,
|
||||
raw_text=raw_text,
|
||||
line_number=line_number,
|
||||
)
|
||||
|
||||
def _parse_parameters(self, params_str: str) -> dict:
|
||||
"""
|
||||
Parse parameter string into dictionary.
|
||||
|
||||
Args:
|
||||
params_str: Parameter string like "|key1=value1|key2=value2"
|
||||
|
||||
Returns:
|
||||
Dictionary of parameters
|
||||
"""
|
||||
if not params_str:
|
||||
return {}
|
||||
|
||||
parameters = {}
|
||||
for match in self.PARAM_PATTERN.finditer(params_str):
|
||||
key = match.group(1).strip()
|
||||
value = match.group(2).strip()
|
||||
parameters[key] = value
|
||||
|
||||
return parameters
|
||||
|
||||
def find_macro_positions(self, content: str) -> List[Tuple[int, int, str]]:
|
||||
"""
|
||||
Find positions of all macros in content.
|
||||
|
||||
Useful for macro substitution during resolution.
|
||||
|
||||
Args:
|
||||
content: Template content
|
||||
|
||||
Returns:
|
||||
List of (start_pos, end_pos, macro_text) tuples
|
||||
"""
|
||||
positions = []
|
||||
for match in self.MACRO_PATTERN.finditer(content):
|
||||
positions.append((
|
||||
match.start(),
|
||||
match.end(),
|
||||
match.group(0)
|
||||
))
|
||||
return positions
|
||||
|
||||
def count_macros(self, content: str) -> dict:
|
||||
"""
|
||||
Count macros by kind.
|
||||
|
||||
Args:
|
||||
content: Template content
|
||||
|
||||
Returns:
|
||||
Dictionary with counts: {'required': N, 'optional': M, 'generate': K}
|
||||
"""
|
||||
macros = self.parse(content)
|
||||
counts = {
|
||||
'required': sum(1 for m in macros if m.kind == MacroKind.REQUIRED),
|
||||
'optional': sum(1 for m in macros if m.kind == MacroKind.OPTIONAL),
|
||||
'generate': sum(1 for m in macros if m.kind == MacroKind.GENERATE),
|
||||
}
|
||||
return counts
|
||||
|
||||
def has_macros(self, content: str) -> bool:
|
||||
"""
|
||||
Check if content contains any macros.
|
||||
|
||||
Args:
|
||||
content: Template content
|
||||
|
||||
Returns:
|
||||
True if any macros found
|
||||
"""
|
||||
return bool(self.MACRO_PATTERN.search(content))
|
||||
179
tests/unit/prompts/test_macro_parser.py
Normal file
179
tests/unit/prompts/test_macro_parser.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Unit tests for macro parser."""
|
||||
|
||||
import pytest
|
||||
from markitect.prompts.templates.parser import MacroParser, MacroParsingError
|
||||
from markitect.prompts.templates.models import MacroKind
|
||||
|
||||
|
||||
class TestMacroParser:
|
||||
"""Tests for MacroParser."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Setup parser for each test."""
|
||||
self.parser = MacroParser()
|
||||
|
||||
def test_parse_required_macro(self):
|
||||
"""Test parsing required macro."""
|
||||
content = "Some text {{require:glossary}} more text"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 1
|
||||
assert macros[0].kind == MacroKind.REQUIRED
|
||||
assert macros[0].target == "glossary"
|
||||
assert macros[0].parameters == {}
|
||||
|
||||
def test_parse_optional_macro(self):
|
||||
"""Test parsing optional macro."""
|
||||
content = "Text {{optional:constraints}}"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 1
|
||||
assert macros[0].kind == MacroKind.OPTIONAL
|
||||
assert macros[0].target == "constraints"
|
||||
|
||||
def test_parse_generate_macro(self):
|
||||
"""Test parsing generate macro."""
|
||||
content = "{{generate:examples}}"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 1
|
||||
assert macros[0].kind == MacroKind.GENERATE
|
||||
assert macros[0].target == "examples"
|
||||
|
||||
def test_parse_macro_with_parameters(self):
|
||||
"""Test parsing macro with parameters."""
|
||||
content = "{{generate:code|language=python|framework=fastapi}}"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 1
|
||||
assert macros[0].target == "code"
|
||||
assert macros[0].parameters == {
|
||||
"language": "python",
|
||||
"framework": "fastapi",
|
||||
}
|
||||
|
||||
def test_parse_multiple_macros(self):
|
||||
"""Test parsing multiple macros."""
|
||||
content = """
|
||||
{{require:glossary}}
|
||||
Some text here
|
||||
{{optional:notes}}
|
||||
{{generate:examples|lang=python}}
|
||||
"""
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 3
|
||||
assert macros[0].kind == MacroKind.REQUIRED
|
||||
assert macros[1].kind == MacroKind.OPTIONAL
|
||||
assert macros[2].kind == MacroKind.GENERATE
|
||||
|
||||
def test_parse_with_line_numbers(self):
|
||||
"""Test that line numbers are recorded."""
|
||||
content = """Line 1
|
||||
{{require:dep1}}
|
||||
Line 3
|
||||
{{optional:dep2}}
|
||||
"""
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert macros[0].line_number == 2
|
||||
assert macros[1].line_number == 4
|
||||
|
||||
def test_parse_case_insensitive(self):
|
||||
"""Test macro kind is case insensitive."""
|
||||
content = "{{REQUIRE:test}} {{Optional:test2}} {{Generate:test3}}"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 3
|
||||
assert all(m.kind in MacroKind for m in macros)
|
||||
|
||||
def test_parse_alias_gen_for_generate(self):
|
||||
"""Test 'gen' alias for 'generate'."""
|
||||
content = "{{gen:test}}"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 1
|
||||
assert macros[0].kind == MacroKind.GENERATE
|
||||
|
||||
def test_parse_alias_required_for_require(self):
|
||||
"""Test 'required' alias for 'require'."""
|
||||
content = "{{required:test}}"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert len(macros) == 1
|
||||
assert macros[0].kind == MacroKind.REQUIRED
|
||||
|
||||
def test_parse_preserves_raw_text(self):
|
||||
"""Test raw text is preserved."""
|
||||
content = "{{require:test|a=1}}"
|
||||
macros = self.parser.parse(content)
|
||||
|
||||
assert macros[0].raw_text == "{{require:test|a=1}}"
|
||||
|
||||
def test_parse_empty_content(self):
|
||||
"""Test parsing empty content."""
|
||||
macros = self.parser.parse("")
|
||||
assert macros == []
|
||||
|
||||
def test_parse_no_macros(self):
|
||||
"""Test parsing content without macros."""
|
||||
content = "Just regular text without any macros"
|
||||
macros = self.parser.parse(content)
|
||||
assert macros == []
|
||||
|
||||
def test_parse_invalid_macro_kind_raises_error(self):
|
||||
"""Test invalid macro kind raises error."""
|
||||
content = "{{invalid:target}}"
|
||||
with pytest.raises(MacroParsingError, match="Invalid macro kind"):
|
||||
self.parser.parse(content)
|
||||
|
||||
def test_parse_empty_target_raises_error(self):
|
||||
"""Test empty target raises error."""
|
||||
content = "{{require:}}"
|
||||
with pytest.raises(MacroParsingError, match="target cannot be empty"):
|
||||
self.parser.parse(content)
|
||||
|
||||
def test_find_macro_positions(self):
|
||||
"""Test finding macro positions."""
|
||||
content = "Start {{require:dep1}} middle {{optional:dep2}} end"
|
||||
positions = self.parser.find_macro_positions(content)
|
||||
|
||||
assert len(positions) == 2
|
||||
assert content[positions[0][0]:positions[0][1]] == "{{require:dep1}}"
|
||||
assert content[positions[1][0]:positions[1][1]] == "{{optional:dep2}}"
|
||||
|
||||
def test_count_macros(self):
|
||||
"""Test macro counting."""
|
||||
content = """
|
||||
{{require:dep1}}
|
||||
{{require:dep2}}
|
||||
{{optional:dep3}}
|
||||
{{generate:gen1}}
|
||||
"""
|
||||
counts = self.parser.count_macros(content)
|
||||
|
||||
assert counts['required'] == 2
|
||||
assert counts['optional'] == 1
|
||||
assert counts['generate'] == 1
|
||||
|
||||
def test_has_macros_true(self):
|
||||
"""Test has_macros returns True when macros present."""
|
||||
content = "Text {{require:test}} text"
|
||||
assert self.parser.has_macros(content) is True
|
||||
|
||||
def test_has_macros_false(self):
|
||||
"""Test has_macros returns False when no macros."""
|
||||
content = "Just plain text"
|
||||
assert self.parser.has_macros(content) is False
|
||||
|
||||
def test_parse_macro_with_spaces_in_target(self):
|
||||
"""Test target with spaces is trimmed."""
|
||||
content = "{{require: my-artifact }}"
|
||||
macros = self.parser.parse(content)
|
||||
assert macros[0].target == "my-artifact"
|
||||
|
||||
def test_parse_parameter_with_spaces(self):
|
||||
"""Test parameters with spaces are trimmed."""
|
||||
content = "{{generate:test| key = value }}"
|
||||
macros = self.parser.parse(content)
|
||||
assert macros[0].parameters == {"key": "value"}
|
||||
236
tests/unit/prompts/test_template_models.py
Normal file
236
tests/unit/prompts/test_template_models.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Unit tests for template models."""
|
||||
|
||||
import pytest
|
||||
from markitect.prompts.templates.models import (
|
||||
ContentMacro,
|
||||
MacroKind,
|
||||
PromptTemplate,
|
||||
TemplateMetadata,
|
||||
)
|
||||
from markitect.prompts.models import ArtifactType
|
||||
|
||||
|
||||
class TestContentMacro:
|
||||
"""Tests for ContentMacro."""
|
||||
|
||||
def test_create_required_macro(self):
|
||||
"""Test creating required macro."""
|
||||
macro = ContentMacro(
|
||||
kind=MacroKind.REQUIRED,
|
||||
target="glossary",
|
||||
)
|
||||
assert macro.kind == MacroKind.REQUIRED
|
||||
assert macro.target == "glossary"
|
||||
assert macro.parameters == {}
|
||||
|
||||
def test_create_macro_with_parameters(self):
|
||||
"""Test creating macro with parameters."""
|
||||
macro = ContentMacro(
|
||||
kind=MacroKind.GENERATE,
|
||||
target="code-examples",
|
||||
parameters={"language": "python", "framework": "fastapi"},
|
||||
)
|
||||
assert macro.parameters == {"language": "python", "framework": "fastapi"}
|
||||
|
||||
def test_macro_str_representation(self):
|
||||
"""Test string representation."""
|
||||
macro = ContentMacro(
|
||||
kind=MacroKind.REQUIRED,
|
||||
target="test",
|
||||
)
|
||||
assert str(macro) == "{{required:test}}"
|
||||
|
||||
def test_macro_str_with_parameters(self):
|
||||
"""Test string representation with parameters."""
|
||||
macro = ContentMacro(
|
||||
kind=MacroKind.GENERATE,
|
||||
target="gen",
|
||||
parameters={"key": "value"},
|
||||
)
|
||||
assert "generate:gen" in str(macro)
|
||||
assert "key=value" in str(macro)
|
||||
|
||||
def test_macro_to_dict(self):
|
||||
"""Test serialization to dict."""
|
||||
macro = ContentMacro(
|
||||
kind=MacroKind.OPTIONAL,
|
||||
target="test",
|
||||
parameters={"a": "1"},
|
||||
raw_text="{{optional:test|a=1}}",
|
||||
line_number=42,
|
||||
)
|
||||
data = macro.to_dict()
|
||||
assert data["kind"] == "optional"
|
||||
assert data["target"] == "test"
|
||||
assert data["parameters"] == {"a": "1"}
|
||||
assert data["line_number"] == 42
|
||||
|
||||
def test_macro_from_dict(self):
|
||||
"""Test deserialization from dict."""
|
||||
data = {
|
||||
"kind": "required",
|
||||
"target": "test",
|
||||
"parameters": {"x": "y"},
|
||||
"raw_text": "{{require:test|x=y}}",
|
||||
"line_number": 10,
|
||||
}
|
||||
macro = ContentMacro.from_dict(data)
|
||||
assert macro.kind == MacroKind.REQUIRED
|
||||
assert macro.target == "test"
|
||||
assert macro.parameters == {"x": "y"}
|
||||
assert macro.line_number == 10
|
||||
|
||||
|
||||
class TestTemplateMetadata:
|
||||
"""Tests for TemplateMetadata."""
|
||||
|
||||
def test_create_empty_metadata(self):
|
||||
"""Test creating empty metadata."""
|
||||
meta = TemplateMetadata()
|
||||
assert meta.purpose is None
|
||||
assert meta.model_hints == {}
|
||||
assert meta.expected_inputs == []
|
||||
assert meta.output_type is None
|
||||
|
||||
def test_create_metadata_with_values(self):
|
||||
"""Test creating metadata with values."""
|
||||
meta = TemplateMetadata(
|
||||
purpose="Generate API docs",
|
||||
model_hints={"temperature": 0.7},
|
||||
expected_inputs=["api-spec", "examples"],
|
||||
output_type="markdown",
|
||||
)
|
||||
assert meta.purpose == "Generate API docs"
|
||||
assert meta.model_hints == {"temperature": 0.7}
|
||||
assert meta.expected_inputs == ["api-spec", "examples"]
|
||||
assert meta.output_type == "markdown"
|
||||
|
||||
def test_metadata_to_dict(self):
|
||||
"""Test serialization."""
|
||||
meta = TemplateMetadata(purpose="Test")
|
||||
data = meta.to_dict()
|
||||
assert data["purpose"] == "Test"
|
||||
assert "model_hints" in data
|
||||
|
||||
def test_metadata_from_dict(self):
|
||||
"""Test deserialization."""
|
||||
data = {
|
||||
"purpose": "Test",
|
||||
"model_hints": {"temp": 0.5},
|
||||
"expected_inputs": ["in1"],
|
||||
"output_type": "json",
|
||||
}
|
||||
meta = TemplateMetadata.from_dict(data)
|
||||
assert meta.purpose == "Test"
|
||||
assert meta.model_hints == {"temp": 0.5}
|
||||
|
||||
|
||||
class TestPromptTemplate:
|
||||
"""Tests for PromptTemplate."""
|
||||
|
||||
def test_create_template(self):
|
||||
"""Test template creation."""
|
||||
template = PromptTemplate.create(
|
||||
space_id="space-1",
|
||||
name="test-template",
|
||||
content="# Template\n{{require:glossary}}",
|
||||
)
|
||||
assert template.space_id == "space-1"
|
||||
assert template.name == "test-template"
|
||||
assert template.artifact.artifact_type == ArtifactType.TEMPLATE
|
||||
assert not template.analyzed
|
||||
assert template.macros == []
|
||||
|
||||
def test_template_properties(self):
|
||||
"""Test template properties."""
|
||||
template = PromptTemplate.create(
|
||||
space_id="space-1",
|
||||
name="test",
|
||||
content="content",
|
||||
)
|
||||
assert template.id # Has UUID
|
||||
assert template.space_id == "space-1"
|
||||
assert template.name == "test"
|
||||
assert template.content_digest # Has digest
|
||||
|
||||
def test_get_required_dependencies(self):
|
||||
"""Test extracting required dependencies."""
|
||||
template = PromptTemplate.create(
|
||||
space_id="space-1",
|
||||
name="test",
|
||||
content="content",
|
||||
)
|
||||
template.macros = [
|
||||
ContentMacro(kind=MacroKind.REQUIRED, target="dep1"),
|
||||
ContentMacro(kind=MacroKind.OPTIONAL, target="dep2"),
|
||||
ContentMacro(kind=MacroKind.REQUIRED, target="dep3"),
|
||||
]
|
||||
required = template.get_required_dependencies()
|
||||
assert required == ["dep1", "dep3"]
|
||||
|
||||
def test_get_optional_dependencies(self):
|
||||
"""Test extracting optional dependencies."""
|
||||
template = PromptTemplate.create(
|
||||
space_id="space-1",
|
||||
name="test",
|
||||
content="content",
|
||||
)
|
||||
template.macros = [
|
||||
ContentMacro(kind=MacroKind.REQUIRED, target="dep1"),
|
||||
ContentMacro(kind=MacroKind.OPTIONAL, target="dep2"),
|
||||
ContentMacro(kind=MacroKind.OPTIONAL, target="dep3"),
|
||||
]
|
||||
optional = template.get_optional_dependencies()
|
||||
assert optional == ["dep2", "dep3"]
|
||||
|
||||
def test_get_generators(self):
|
||||
"""Test extracting generators."""
|
||||
template = PromptTemplate.create(
|
||||
space_id="space-1",
|
||||
name="test",
|
||||
content="content",
|
||||
)
|
||||
template.macros = [
|
||||
ContentMacro(kind=MacroKind.GENERATE, target="gen1"),
|
||||
ContentMacro(kind=MacroKind.REQUIRED, target="dep1"),
|
||||
ContentMacro(kind=MacroKind.GENERATE, target="gen2"),
|
||||
]
|
||||
generators = template.get_generators()
|
||||
assert generators == ["gen1", "gen2"]
|
||||
|
||||
def test_from_artifact_invalid_type(self):
|
||||
"""Test from_artifact raises error for non-template."""
|
||||
from markitect.prompts.models import Artifact
|
||||
artifact = Artifact.create(
|
||||
space_id="space-1",
|
||||
name="not-template",
|
||||
content="content",
|
||||
artifact_type=ArtifactType.CONTENT,
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be of type TEMPLATE"):
|
||||
PromptTemplate.from_artifact(artifact)
|
||||
|
||||
def test_template_to_dict(self):
|
||||
"""Test serialization."""
|
||||
template = PromptTemplate.create(
|
||||
space_id="space-1",
|
||||
name="test",
|
||||
content="content",
|
||||
)
|
||||
template.analyzed = True
|
||||
data = template.to_dict()
|
||||
assert "artifact" in data
|
||||
assert "template_metadata" in data
|
||||
assert data["analyzed"] is True
|
||||
|
||||
def test_template_from_dict(self):
|
||||
"""Test deserialization."""
|
||||
original = PromptTemplate.create(
|
||||
space_id="space-1",
|
||||
name="test",
|
||||
content="content",
|
||||
)
|
||||
data = original.to_dict()
|
||||
restored = PromptTemplate.from_dict(data)
|
||||
assert restored.id == original.id
|
||||
assert restored.name == original.name
|
||||
Reference in New Issue
Block a user