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

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

View 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