diff --git a/markitect/prompts/services/template_service.py b/markitect/prompts/services/template_service.py new file mode 100644 index 00000000..b22df126 --- /dev/null +++ b/markitect/prompts/services/template_service.py @@ -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) diff --git a/markitect/prompts/templates/__init__.py b/markitect/prompts/templates/__init__.py new file mode 100644 index 00000000..e9d9fbf2 --- /dev/null +++ b/markitect/prompts/templates/__init__.py @@ -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", +] diff --git a/markitect/prompts/templates/analyzer.py b/markitect/prompts/templates/analyzer.py new file mode 100644 index 00000000..e042e7f4 --- /dev/null +++ b/markitect/prompts/templates/analyzer.py @@ -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, + } diff --git a/markitect/prompts/templates/models.py b/markitect/prompts/templates/models.py new file mode 100644 index 00000000..f7d86d5d --- /dev/null +++ b/markitect/prompts/templates/models.py @@ -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: {{:[|=|=...]}} + + 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), + ) diff --git a/markitect/prompts/templates/parser.py b/markitect/prompts/templates/parser.py new file mode 100644 index 00000000..845d2d50 --- /dev/null +++ b/markitect/prompts/templates/parser.py @@ -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: + {{:[|=|=...]}} + + 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)) diff --git a/tests/unit/prompts/test_macro_parser.py b/tests/unit/prompts/test_macro_parser.py new file mode 100644 index 00000000..5cf84756 --- /dev/null +++ b/tests/unit/prompts/test_macro_parser.py @@ -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"} diff --git a/tests/unit/prompts/test_template_models.py b/tests/unit/prompts/test_template_models.py new file mode 100644 index 00000000..d844866b --- /dev/null +++ b/tests/unit/prompts/test_template_models.py @@ -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