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:
2026-02-08 22:34:22 +01:00
parent 945544880d
commit e6840fe696
7 changed files with 1381 additions and 0 deletions

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

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

View 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),
)

View 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))