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