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

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