From b3e89b280cf44d00ba6612ff9b8fd73ad1667d46 Mon Sep 17 00:00:00 2001 From: Nisarg Patel <63173283+Nisarg38@users.noreply.github.com> Date: Mon, 12 Jan 2026 23:39:49 -0600 Subject: [PATCH] Implement layer composition feature in Promptix - Added LayerComposer component for multi-dimensional prompt customization. - Introduced methods for composing prompts with layer overrides and listing available layers. - Enhanced Promptix class with get_composed_prompt and list_layers methods. - Added new exceptions for layer-related errors: LayerNotFoundError and LayerRequiredError. - Updated container to register LayerComposer and its dependencies. - Created comprehensive unit and functional tests for layer composition functionality. - Added configuration and test fixtures for layer-based prompts. This implementation allows for dynamic prompt generation based on user-defined layers, improving flexibility and customization in prompt management. --- prompts/CodeReviewer/versions/v009.md | 5 + prompts/ComplexCodeReviewer/versions/v008.md | 7 + prompts/SimpleChat/versions/v009.md | 1 + prompts/TemplateDemo/versions/v008.md | 16 + prompts/simple_chat/versions/v008.md | 11 + src/promptix/core/base.py | 126 +++- src/promptix/core/components/__init__.py | 6 +- .../core/components/layer_composer.py | 550 +++++++++++++++ src/promptix/core/container.py | 11 +- src/promptix/core/exceptions.py | 51 ++ tests/conftest.py | 9 + .../CustomerSupportLayers/config.yaml | 57 ++ .../CustomerSupportLayers/current.md | 42 ++ .../layers/product_line/hardware/current.md | 23 + .../layers/product_line/software/current.md | 23 + .../layers/region/eu/current.md | 17 + .../layers/region/us/current.md | 12 + .../layers/tier/basic/current.md | 15 + .../layers/tier/premium/current.md | 21 + .../layers/tier/premium/versions/v1.md | 13 + .../CustomerSupportLayers/versions/v1.md | 29 + .../CustomerSupport/config.yaml | 57 ++ .../CustomerSupport/current.md | 42 ++ .../layers/product_line/hardware/current.md | 23 + .../layers/product_line/software/current.md | 23 + .../layers/region/eu/current.md | 17 + .../layers/region/us/current.md | 12 + .../layers/tier/basic/current.md | 15 + .../layers/tier/premium/current.md | 21 + .../layers/tier/premium/versions/v1.md | 13 + .../CustomerSupport/versions/v1.md | 29 + tests/functional/test_layers_api.py | 269 ++++++++ tests/unit/test_layer_composition.py | 626 ++++++++++++++++++ 33 files changed, 2181 insertions(+), 11 deletions(-) create mode 100644 prompts/CodeReviewer/versions/v009.md create mode 100644 prompts/ComplexCodeReviewer/versions/v008.md create mode 100644 prompts/SimpleChat/versions/v009.md create mode 100644 prompts/TemplateDemo/versions/v008.md create mode 100644 prompts/simple_chat/versions/v008.md create mode 100644 src/promptix/core/components/layer_composer.py create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/config.yaml create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/current.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/hardware/current.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/software/current.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/eu/current.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/us/current.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/basic/current.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/current.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/versions/v1.md create mode 100644 tests/fixtures/test_prompts/CustomerSupportLayers/versions/v1.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/config.yaml create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/current.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/hardware/current.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/software/current.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/eu/current.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/us/current.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/basic/current.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/current.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/versions/v1.md create mode 100644 tests/fixtures/test_prompts_layers/CustomerSupport/versions/v1.md create mode 100644 tests/functional/test_layers_api.py create mode 100644 tests/unit/test_layer_composition.py diff --git a/prompts/CodeReviewer/versions/v009.md b/prompts/CodeReviewer/versions/v009.md new file mode 100644 index 0000000..700beb9 --- /dev/null +++ b/prompts/CodeReviewer/versions/v009.md @@ -0,0 +1,5 @@ +You are a code review assistant specialized in {{programming_language}}. Please review the following code snippet and provide feedback on {{review_focus}}: + +```{{programming_language}} +{{code_snippet}} +``` diff --git a/prompts/ComplexCodeReviewer/versions/v008.md b/prompts/ComplexCodeReviewer/versions/v008.md new file mode 100644 index 0000000..50f329b --- /dev/null +++ b/prompts/ComplexCodeReviewer/versions/v008.md @@ -0,0 +1,7 @@ +You are a code review assistant with active tools: {{active_tools}}. Specialized in {{programming_language}}. Review the code with {{severity}} scrutiny focusing on {{review_focus}}: + +```{{programming_language}} +{{code_snippet}} +``` + +Provide feedback in: 'Summary', 'Critical Issues', 'Improvements', 'Positives'. diff --git a/prompts/SimpleChat/versions/v009.md b/prompts/SimpleChat/versions/v009.md new file mode 100644 index 0000000..f394f88 --- /dev/null +++ b/prompts/SimpleChat/versions/v009.md @@ -0,0 +1 @@ +You are a helpful AI assistant named {{assistant_name}}. Your goal is to provide clear and concise answers to {{user_name}}'s questions. diff --git a/prompts/TemplateDemo/versions/v008.md b/prompts/TemplateDemo/versions/v008.md new file mode 100644 index 0000000..c328f53 --- /dev/null +++ b/prompts/TemplateDemo/versions/v008.md @@ -0,0 +1,16 @@ +You are creating a {{content_type}} about {{theme}}. + +{% if difficulty == 'beginner' %} +Keep it simple and accessible for beginners. +{% elif difficulty == 'intermediate' %} +Include some advanced concepts but explain them clearly. +{% else %} +Don't hold back on technical details and advanced concepts. +{% endif %} + +{% if elements|length > 0 %} +Be sure to include the following elements: +{% for element in elements %} +- {{element}} +{% endfor %} +{% endif %} diff --git a/prompts/simple_chat/versions/v008.md b/prompts/simple_chat/versions/v008.md new file mode 100644 index 0000000..f25fba3 --- /dev/null +++ b/prompts/simple_chat/versions/v008.md @@ -0,0 +1,11 @@ +You are a {{personality}} assistant specialized in {{domain}}. + +Your role is to provide helpful, accurate, and engaging responses to user questions and requests. Always maintain a professional and friendly tone while adapting to the user's needs. + +Key guidelines: +- Be concise but thorough in your explanations +- Ask clarifying questions when needed +- Provide examples when helpful +- Stay focused on the {{domain}} domain when specified + +How can I help you today? \ No newline at end of file diff --git a/src/promptix/core/base.py b/src/promptix/core/base.py index e4231e4..f8f6f31 100644 --- a/src/promptix/core/base.py +++ b/src/promptix/core/base.py @@ -5,14 +5,15 @@ focused components and dependency injection for better testability and modularity. """ -from typing import Any, Dict, Optional, List +from typing import Any, Dict, List, Optional, Tuple, Union from .container import get_container from .components import ( PromptLoader, VariableValidator, TemplateRenderer, VersionManager, - ModelConfigBuilder + ModelConfigBuilder, + LayerComposer, ) from .exceptions import PromptNotFoundError, ConfigurationError, StorageError @@ -38,16 +39,16 @@ def __init__(self, container=None): @classmethod def get_prompt(cls, prompt_template: str, version: Optional[str] = None, **variables) -> str: """Get a prompt by name and fill in the variables. - + Args: prompt_template (str): The name of the prompt template to use - version (Optional[str]): Specific version to use (e.g. "v1"). + version (Optional[str]): Specific version to use (e.g. "v1"). If None, uses the latest live version. **variables: Variable key-value pairs to fill in the prompt template - + Returns: str: The rendered prompt - + Raises: PromptNotFoundError: If the prompt template is not found RequiredVariableError: If required variables are missing @@ -56,7 +57,118 @@ def get_prompt(cls, prompt_template: str, version: Optional[str] = None, **varia """ instance = cls() return instance.render_prompt(prompt_template, version, **variables) - + + @classmethod + def get_composed_prompt( + cls, + prompt_template: str, + version: Optional[str] = None, + layer_versions: Optional[Dict[str, str]] = None, + skip_layers: Optional[List[str]] = None, + _debug: bool = False, + **variables + ) -> Union[str, Tuple[str, Any]]: + """Get a prompt with layer composition. + + Layers are automatically selected based on variables that match + layer configuration in config.yaml. For example, if config defines + a layer with variable='oem', passing oem='honda' will apply the + honda layer. + + Args: + prompt_template: Name of the prompt template. + version: Base template version (None = current). + layer_versions: Override versions for specific layers. + skip_layers: Layer names to skip. + _debug: If True, returns tuple of (prompt, debug_info). + **variables: Template variables (also used for layer selection). + + Returns: + Fully composed and rendered prompt string. + If _debug is True, returns tuple of (prompt, CompositionDebugInfo). + + Raises: + PromptNotFoundError: If the prompt template is not found. + LayerRequiredError: If a required layer variable is not provided. + TemplateRenderError: If template rendering fails. + ConfigurationError: If configuration is invalid. + + Example: + prompt = Promptix.get_composed_prompt( + prompt_template="ServiceAgent", + store_name="Honda World", + store_type="automotive", # Selects automotive layer + oem="honda", # Selects honda layer + locale="es-MX" # Selects Spanish locale layer + ) + """ + instance = cls() + return instance.compose_prompt( + prompt_template=prompt_template, + version=version, + layer_versions=layer_versions, + skip_layers=skip_layers, + _debug=_debug, + **variables + ) + + def compose_prompt( + self, + prompt_template: str, + version: Optional[str] = None, + layer_versions: Optional[Dict[str, str]] = None, + skip_layers: Optional[List[str]] = None, + _debug: bool = False, + **variables + ) -> Union[str, Tuple[str, Any]]: + """Compose a prompt with layer overrides. + + Args: + prompt_template: Name of the prompt template. + version: Base template version (None = current). + layer_versions: Override versions for specific layers. + skip_layers: Layer names to skip. + _debug: If True, returns tuple of (prompt, debug_info). + **variables: Template variables (also used for layer selection). + + Returns: + Fully composed and rendered prompt string. + If _debug is True, returns tuple of (prompt, CompositionDebugInfo). + """ + layer_composer = self._container.get_typed("layer_composer", LayerComposer) + + return layer_composer.compose( + prompt_name=prompt_template, + variables=variables, + base_version=version, + layer_versions=layer_versions, + skip_layers=skip_layers, + _debug=_debug + ) + + @classmethod + def list_layers(cls, prompt_template: str) -> Dict[str, List[str]]: + """List available layers and their values for a prompt. + + Args: + prompt_template: Name of the prompt template. + + Returns: + Dict mapping layer names to lists of available values. + + Example: + layers = Promptix.list_layers("ServiceAgent") + # Returns: + # { + # "store_type": ["automotive", "powersports", "marine"], + # "oem": ["honda", "toyota", "harley"], + # "locale": ["en-US", "es-MX", "fr-CA"] + # } + """ + instance = cls() + layer_composer = instance._container.get_typed("layer_composer", LayerComposer) + return layer_composer.list_layers(prompt_template) + def render_prompt(self, prompt_template: str, version: Optional[str] = None, **variables) -> str: """Render a prompt with the provided variables. diff --git a/src/promptix/core/components/__init__.py b/src/promptix/core/components/__init__.py index 21be061..c21b09b 100644 --- a/src/promptix/core/components/__init__.py +++ b/src/promptix/core/components/__init__.py @@ -10,11 +10,13 @@ from .template_renderer import TemplateRenderer from .version_manager import VersionManager from .model_config_builder import ModelConfigBuilder +from .layer_composer import LayerComposer __all__ = [ "PromptLoader", - "VariableValidator", + "VariableValidator", "TemplateRenderer", "VersionManager", - "ModelConfigBuilder" + "ModelConfigBuilder", + "LayerComposer", ] diff --git a/src/promptix/core/components/layer_composer.py b/src/promptix/core/components/layer_composer.py new file mode 100644 index 0000000..842227e --- /dev/null +++ b/src/promptix/core/components/layer_composer.py @@ -0,0 +1,550 @@ +""" +Layer composition engine for multi-dimensional prompt customization. + +This component handles composing prompts by merging blocks from multiple layers, +enabling runtime-selected overrides based on variables like OEM, store type, locale, etc. +""" + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import yaml +from jinja2 import BaseLoader, Environment, TemplateError + +from ..exceptions import ( + ConfigurationError, + LayerNotFoundError, + LayerRequiredError, + TemplateRenderError, +) + + +@dataclass +class LayerConfig: + """Configuration for a single layer axis.""" + + name: str + variable: str + path: str + required: bool = False + default: Optional[str] = None + + +@dataclass +class BlockDefinition: + """A parsed block from a template.""" + + name: str + content: str + mode: str = "replace" # replace, append, prepend + + +@dataclass +class CompositionDebugInfo: + """Debug information about the composition process.""" + + base_template: str + layers_applied: List[Dict[str, Any]] = field(default_factory=list) + layers_skipped: List[str] = field(default_factory=list) + blocks_overridden: Dict[str, List[str]] = field(default_factory=dict) + + +class LayerComposer: + """ + Composes prompts by merging blocks from multiple layers. + + The composition algorithm: + 1. Load base template and extract all block definitions + 2. For each layer in merge_order: + a. Check if layer variable is provided + b. Load layer template if it exists + c. Extract block overrides from layer + d. Merge into accumulated blocks based on mode + 3. Reconstruct final template with merged blocks + 4. Render with Jinja2 variable substitution + + Example: + composer = LayerComposer(Path("prompts")) + result = composer.compose( + prompt_name="ServiceAgent", + variables={"store_type": "automotive", "oem": "honda"}, + ) + """ + + def __init__( + self, + prompts_dir: Path, + logger: Optional[Any] = None + ) -> None: + """ + Initialize the LayerComposer. + + Args: + prompts_dir: Path to the prompts directory. + logger: Optional logger instance for dependency injection. + """ + self.prompts_dir = Path(prompts_dir) + self._logger = logger + + # Regex pattern for block extraction + # Matches: {% block name %} or {% block name mode="append" %} + self._block_pattern = re.compile( + r'{%\s*block\s+(\w+)(?:\s+mode=["\'](\w+)["\'])?\s*%}' + r'(.*?)' + r'{%\s*endblock\s*%}', + re.DOTALL + ) + + # Pattern for super() calls + self._super_pattern = re.compile(r'{{\s*super\(\)\s*}}') + + # Caches for performance + self._config_cache: Dict[str, Dict[str, Any]] = {} + self._block_cache: Dict[str, Dict[str, BlockDefinition]] = {} + + def compose( + self, + prompt_name: str, + variables: Dict[str, Any], + base_version: Optional[str] = None, + layer_versions: Optional[Dict[str, str]] = None, + skip_layers: Optional[List[str]] = None, + _debug: bool = False + ) -> Any: + """ + Compose a prompt with layer overrides. + + Args: + prompt_name: Name of the prompt template. + variables: Variables for template rendering and layer selection. + base_version: Version of base template (None = current). + layer_versions: Version overrides for specific layers. + skip_layers: Layer names to skip. + _debug: If True, returns tuple of (prompt, debug_info). + + Returns: + Fully composed and rendered prompt string. + If _debug is True, returns tuple of (prompt, CompositionDebugInfo). + + Raises: + LayerRequiredError: If a required layer variable is not provided. + TemplateRenderError: If template rendering fails. + ConfigurationError: If configuration is invalid. + """ + layer_versions = layer_versions or {} + skip_layers = skip_layers or [] + + # Initialize debug info + debug_info = CompositionDebugInfo( + base_template=f"{prompt_name}/{'versions/' + base_version + '.md' if base_version else 'current.md'}" + ) + + # Load prompt config + prompt_dir = self.prompts_dir / prompt_name + config = self._load_config(prompt_dir) + composition_config = config.get("composition", {}) + + # If no composition config, fall back to base template only + if not composition_config: + base_content = self._load_template(prompt_dir, base_version) + result = self._render(base_content, variables, prompt_name) + if _debug: + return result, debug_info + return result + + # Load base template + base_content = self._load_template(prompt_dir, base_version) + + # Extract blocks from base + blocks = self._extract_blocks(base_content) + base_structure = base_content + + # Get layer configs + layer_configs = self._parse_layer_configs(composition_config) + merge_order = composition_config.get("merge_order", []) + + # Apply layers in order + for layer_name in merge_order: + if layer_name in skip_layers: + debug_info.layers_skipped.append(layer_name) + continue + + layer_config = next( + (lc for lc in layer_configs if lc.name == layer_name), + None + ) + if not layer_config: + continue + + # Get layer value from variables or default + layer_value = variables.get( + layer_config.variable, + layer_config.default + ) + + if not layer_value: + if layer_config.required: + raise LayerRequiredError( + layer_name=layer_name, + variable_name=layer_config.variable, + prompt_name=prompt_name + ) + debug_info.layers_skipped.append(layer_name) + continue + + # Load layer template + layer_version = layer_versions.get(layer_name) + layer_content = self._load_layer( + prompt_dir, + layer_config.path, + str(layer_value), + layer_version + ) + + if layer_content: + # Extract and merge layer blocks + layer_blocks = self._extract_blocks(layer_content) + blocks, modified_blocks = self._merge_blocks( + blocks, + layer_blocks, + layer_name + ) + + # Track applied layer + debug_info.layers_applied.append({ + "name": layer_name, + "value": layer_value, + "version": layer_version or "current" + }) + + # Track which blocks were modified + for block_name in modified_blocks: + if block_name not in debug_info.blocks_overridden: + debug_info.blocks_overridden[block_name] = [] + debug_info.blocks_overridden[block_name].append(layer_name) + else: + debug_info.layers_skipped.append(layer_name) + + # Reconstruct template with merged blocks + final_template = self._reconstruct_template(base_structure, blocks) + + # Render with Jinja2 + result = self._render(final_template, variables, prompt_name) + + if _debug: + return result, debug_info + return result + + def list_layers(self, prompt_name: str) -> Dict[str, List[str]]: + """ + List available layers and their values for a prompt. + + Args: + prompt_name: Name of the prompt template. + + Returns: + Dict mapping layer names to lists of available values. + """ + prompt_dir = self.prompts_dir / prompt_name + config = self._load_config(prompt_dir) + composition_config = config.get("composition", {}) + + if not composition_config: + return {} + + layer_configs = self._parse_layer_configs(composition_config) + result: Dict[str, List[str]] = {} + + for layer_config in layer_configs: + layer_path = prompt_dir / layer_config.path + if layer_path.exists() and layer_path.is_dir(): + values = [ + d.name for d in layer_path.iterdir() + if d.is_dir() and (d / "current.md").exists() + ] + result[layer_config.name] = sorted(values) + else: + result[layer_config.name] = [] + + return result + + def _extract_blocks(self, content: str) -> Dict[str, BlockDefinition]: + """ + Extract all block definitions from template content. + + Args: + content: Template content to parse. + + Returns: + Dict mapping block names to BlockDefinition objects. + """ + blocks: Dict[str, BlockDefinition] = {} + for match in self._block_pattern.finditer(content): + name = match.group(1) + mode = match.group(2) or "replace" + block_content = match.group(3).strip() + blocks[name] = BlockDefinition( + name=name, + content=block_content, + mode=mode + ) + return blocks + + def _merge_blocks( + self, + base_blocks: Dict[str, BlockDefinition], + layer_blocks: Dict[str, BlockDefinition], + layer_name: str + ) -> Tuple[Dict[str, BlockDefinition], List[str]]: + """ + Merge layer blocks into base blocks. + + Args: + base_blocks: Current accumulated blocks. + layer_blocks: Blocks from the layer to merge. + layer_name: Name of the layer (for logging). + + Returns: + Tuple of (merged blocks dict, list of modified block names). + """ + result = dict(base_blocks) + modified: List[str] = [] + + for name, layer_block in layer_blocks.items(): + if name not in result: + # New block from layer - warn but allow (for flexibility) + if self._logger: + self._logger.warning( + f"Layer '{layer_name}' defines block '{name}' " + f"not in base template - ignoring" + ) + continue + + base_block = result[name] + modified.append(name) + + if layer_block.mode == "replace": + # Check for super() calls + if self._super_pattern.search(layer_block.content): + # Replace all super() calls with parent content + merged_content = self._super_pattern.sub( + lambda _: base_block.content, + layer_block.content + ) + result[name] = BlockDefinition( + name=name, + content=merged_content, + mode="replace" + ) + else: + # Complete replacement + result[name] = BlockDefinition( + name=name, + content=layer_block.content, + mode="replace" + ) + + elif layer_block.mode == "append": + result[name] = BlockDefinition( + name=name, + content=f"{base_block.content}\n{layer_block.content}", + mode="replace" + ) + + elif layer_block.mode == "prepend": + result[name] = BlockDefinition( + name=name, + content=f"{layer_block.content}\n{base_block.content}", + mode="replace" + ) + + return result, modified + + def _reconstruct_template( + self, + structure: str, + blocks: Dict[str, BlockDefinition] + ) -> str: + """ + Replace block placeholders with merged content. + + Args: + structure: Original template structure. + blocks: Merged block definitions. + + Returns: + Template with blocks replaced by their content. + """ + result = structure + for name, block in blocks.items(): + # Replace block definition with content + pattern = re.compile( + r'{%\s*block\s+' + re.escape(name) + + r'(?:\s+mode=["\'](\w+)["\'])?\s*%}' + r'.*?' + r'{%\s*endblock\s*%}', + re.DOTALL + ) + result = pattern.sub(block.content, result) + return result + + def _load_template( + self, + prompt_dir: Path, + version: Optional[str] + ) -> str: + """ + Load base template content. + + Args: + prompt_dir: Path to the prompt directory. + version: Optional version to load. + + Returns: + Template content as string. + + Raises: + ConfigurationError: If template file not found. + """ + if version: + path = prompt_dir / "versions" / f"{version}.md" + else: + path = prompt_dir / "current.md" + + if not path.exists(): + raise ConfigurationError( + config_issue=f"Template not found: {path}", + config_path=str(path) + ) + + return path.read_text(encoding="utf-8") + + def _load_layer( + self, + prompt_dir: Path, + layer_path: str, + layer_value: str, + version: Optional[str] + ) -> Optional[str]: + """ + Load a layer template if it exists. + + Args: + prompt_dir: Path to the prompt directory. + layer_path: Relative path to layer directory. + layer_value: Value identifying which layer variant to load. + version: Optional version to load. + + Returns: + Layer template content, or None if not found. + """ + layer_dir = prompt_dir / layer_path / layer_value + + if version: + path = layer_dir / "versions" / f"{version}.md" + else: + path = layer_dir / "current.md" + + if not path.exists(): + return None + + return path.read_text(encoding="utf-8") + + def _load_config(self, prompt_dir: Path) -> Dict[str, Any]: + """ + Load prompt configuration with caching. + + Args: + prompt_dir: Path to the prompt directory. + + Returns: + Configuration dict. + """ + cache_key = str(prompt_dir) + if cache_key in self._config_cache: + return self._config_cache[cache_key] + + config_path = prompt_dir / "config.yaml" + if not config_path.exists(): + self._config_cache[cache_key] = {} + return {} + + try: + with open(config_path, encoding="utf-8") as f: + config = yaml.safe_load(f) or {} + self._config_cache[cache_key] = config + return config + except yaml.YAMLError as e: + raise ConfigurationError( + config_issue=f"Invalid YAML in config: {e}", + config_path=str(config_path) + ) + + def _parse_layer_configs( + self, + composition_config: Dict[str, Any] + ) -> List[LayerConfig]: + """ + Parse layer configurations from composition config. + + Args: + composition_config: The 'composition' section of config.yaml. + + Returns: + List of LayerConfig objects. + """ + layers: List[LayerConfig] = [] + for layer_dict in composition_config.get("layers", []): + layers.append(LayerConfig( + name=layer_dict["name"], + variable=layer_dict["variable"], + path=layer_dict["path"], + required=layer_dict.get("required", False), + default=layer_dict.get("default") + )) + return layers + + def _render( + self, + template: str, + variables: Dict[str, Any], + prompt_name: str + ) -> str: + """ + Render final template with Jinja2. + + Args: + template: Template string to render. + variables: Variables for substitution. + prompt_name: Name of prompt for error reporting. + + Returns: + Rendered template string. + + Raises: + TemplateRenderError: If rendering fails. + """ + env = Environment( + loader=BaseLoader(), + trim_blocks=True, + lstrip_blocks=True + ) + + try: + template_obj = env.from_string(template) + result = template_obj.render(**variables) + # Convert escaped newlines to actual line breaks + return result.replace("\\n", "\n") + except TemplateError as e: + raise TemplateRenderError( + prompt_name=prompt_name, + template_error=str(e), + variables=variables + ) + + def clear_cache(self) -> None: + """Clear all internal caches.""" + self._config_cache.clear() + self._block_cache.clear() diff --git a/src/promptix/core/container.py b/src/promptix/core/container.py index 5de4f1d..8c8aa2d 100644 --- a/src/promptix/core/container.py +++ b/src/promptix/core/container.py @@ -12,12 +12,14 @@ VariableValidator, TemplateRenderer, VersionManager, - ModelConfigBuilder + ModelConfigBuilder, + LayerComposer, ) from .adapters.openai import OpenAIAdapter from .adapters.anthropic import AnthropicAdapter from .adapters._base import ModelAdapter from .exceptions import MissingDependencyError, InvalidDependencyError +from .config import config T = TypeVar('T') @@ -57,7 +59,12 @@ def _setup_defaults(self) -> None: self.register_factory("model_config_builder", lambda: ModelConfigBuilder( logger=self.get("logger") )) - + + self.register_factory("layer_composer", lambda: LayerComposer( + prompts_dir=config.get_prompts_workspace_path(), + logger=self.get("logger") + )) + # Register adapters as singletons self.register_singleton("openai_adapter", OpenAIAdapter()) self.register_singleton("anthropic_adapter", AnthropicAdapter()) diff --git a/src/promptix/core/exceptions.py b/src/promptix/core/exceptions.py index 6316cac..eb88bd5 100644 --- a/src/promptix/core/exceptions.py +++ b/src/promptix/core/exceptions.py @@ -271,6 +271,57 @@ def __init__(self, tool_name: str, processing_error: str): super().__init__(message, details) +# === Layer Composition Errors === + +class LayerError(PromptixError): + """Base class for layer composition errors.""" + pass + + +class LayerNotFoundError(LayerError): + """Raised when a specified layer variant doesn't exist.""" + + def __init__( + self, + layer_name: str, + layer_value: str, + prompt_name: str, + available_values: Optional[List[str]] = None + ): + message = ( + f"Layer '{layer_name}' with value '{layer_value}' " + f"not found for prompt '{prompt_name}'" + ) + details = { + "layer_name": layer_name, + "layer_value": layer_value, + "prompt_name": prompt_name, + "available_values": available_values or [] + } + super().__init__(message, details) + + +class LayerRequiredError(LayerError): + """Raised when a required layer variable is not provided.""" + + def __init__( + self, + layer_name: str, + variable_name: str, + prompt_name: str + ): + message = ( + f"Required layer '{layer_name}' not provided for prompt '{prompt_name}'. " + f"Set the '{variable_name}' variable to select a layer value." + ) + details = { + "layer_name": layer_name, + "variable_name": variable_name, + "prompt_name": prompt_name + } + super().__init__(message, details) + + # === Dependency Injection Errors === class DependencyError(PromptixError): diff --git a/tests/conftest.py b/tests/conftest.py index 72792c2..a071676 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,9 @@ # Path to test prompts fixtures TEST_PROMPTS_DIR = Path(__file__).parent / "fixtures" / "test_prompts" +# Path to layer composition test fixtures +TEST_PROMPTS_LAYERS_DIR = Path(__file__).parent / "fixtures" / "test_prompts_layers" + # Available test prompt names (matching the folder structure) TEST_PROMPT_NAMES = ["SimpleChat", "CodeReviewer", "TemplateDemo"] @@ -128,6 +131,12 @@ def test_prompts_dir(): """Fixture providing path to test prompts directory.""" return TEST_PROMPTS_DIR + +@pytest.fixture +def test_prompts_layers_dir(): + """Fixture providing path to layer composition test fixtures.""" + return TEST_PROMPTS_LAYERS_DIR + def _load_prompts_from_directory(prompts_dir: Path) -> Dict[str, Any]: """Helper function to load prompts from a directory structure. diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/config.yaml b/tests/fixtures/test_prompts/CustomerSupportLayers/config.yaml new file mode 100644 index 0000000..b86fac9 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/config.yaml @@ -0,0 +1,57 @@ +# CustomerSupport configuration with layer composition +metadata: + name: "CustomerSupport" + description: "Customer support assistant with multi-dimensional customization" + author: "Test Suite" + version: "1.0.0" + +# Schema for variables +schema: + type: "object" + required: + - company_name + properties: + company_name: + type: string + product_line: + type: string + enum: + - software + - hardware + region: + type: string + tier: + type: string + default: basic + +# Layer composition configuration +composition: + # Define available layer axes + layers: + - name: product_line + variable: product_line + path: layers/product_line + required: false + + - name: region + variable: region + path: layers/region + required: false + + - name: tier + variable: tier + path: layers/tier + required: false + default: basic + + # Order matters - later layers override earlier ones + merge_order: + - product_line + - region + - tier + +# Configuration for the prompt +config: + model: "gpt-4o" + provider: "openai" + temperature: 0.7 diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/current.md b/tests/fixtures/test_prompts/CustomerSupportLayers/current.md new file mode 100644 index 0000000..b3cadd1 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/current.md @@ -0,0 +1,42 @@ +{% block identity %} +You are a helpful customer support assistant for {{ company_name }}. +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Answer product questions +- Troubleshoot common issues +- Process returns and exchanges +- Escalate complex cases +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- General product information +- Warranty policies +- Return procedures +{% endblock %} + +{% block response_style %} +Response Style: +- Be friendly and professional +- Provide clear step-by-step guidance +- Confirm understanding before proceeding +{% endblock %} + +{% block escalation %} +Escalation Policy: +- Escalate after 3 failed resolution attempts +- Always offer callback option +{% endblock %} + +{% block greeting %} +Standard Greeting: +Hello! Thank you for contacting {{ company_name }} support. How can I help you today? +{% endblock %} + +Context: +Company: {{ company_name }} +{% if product_line %}Product Line: {{ product_line }}{% endif %} +{% if region %}Region: {{ region }}{% endif %} +{% if tier %}Support Tier: {{ tier }}{% endif %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/hardware/current.md b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/hardware/current.md new file mode 100644 index 0000000..dce7c4a --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/hardware/current.md @@ -0,0 +1,23 @@ +{% block identity %} +You are a technical support specialist for {{ company_name }}, +specializing in hardware products. Help customers with setup, +maintenance, and troubleshooting of physical devices. +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- Hardware specifications and compatibility +- Physical setup and installation +- Warranty and repair procedures +- Replacement part information +- Safety guidelines +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Device setup assistance +- Hardware diagnostics +- Warranty claim processing +- Repair scheduling +- Replacement recommendations +{% endblock %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/software/current.md b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/software/current.md new file mode 100644 index 0000000..53042d3 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/product_line/software/current.md @@ -0,0 +1,23 @@ +{% block identity %} +You are a technical support specialist for {{ company_name }}, +specializing in software products. Help customers with installation, +configuration, and troubleshooting of software applications. +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- Software installation procedures +- System requirements and compatibility +- License activation and management +- Common error codes and solutions +- Integration with other tools +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Software installation guidance +- Configuration assistance +- Bug reporting and workarounds +- Feature explanations +- Update and upgrade support +{% endblock %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/eu/current.md b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/eu/current.md new file mode 100644 index 0000000..cfc10f2 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/eu/current.md @@ -0,0 +1,17 @@ +{% block identity %} +Vous etes un assistant de support client pour {{ company_name }}. +Aidez les clients avec leurs questions et problemes. +{% endblock %} + +{% block response_style %} +Response Style: +- Use British English spelling (colour, centre, etc.) +- Reference CET business hours +- Comply with GDPR requirements +- Maintain formal but friendly tone +{% endblock %} + +{% block greeting %} +Standard Greeting: +Good day! Thank you for contacting {{ company_name }} support. How may I assist you? +{% endblock %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/us/current.md b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/us/current.md new file mode 100644 index 0000000..5dfee07 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/region/us/current.md @@ -0,0 +1,12 @@ +{% block response_style %} +Response Style: +- Use American English spelling (color, center, etc.) +- Reference US business hours (9 AM - 5 PM EST) +- Mention US-specific policies when relevant +- Be friendly and conversational +{% endblock %} + +{% block greeting %} +Standard Greeting: +Hi there! Thanks for reaching out to {{ company_name }} support. What can I help you with today? +{% endblock %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/basic/current.md b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/basic/current.md new file mode 100644 index 0000000..4ae7a40 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/basic/current.md @@ -0,0 +1,15 @@ +{% block escalation %} +Escalation Policy: +- Escalate to senior support after 3 failed attempts +- Offer callback during business hours only +- Response time: within 24 hours +{% endblock %} + +{% block capabilities %} +{{ super() }} + +Basic Tier Limitations: +- Email support only +- Standard response times +- Self-service resources available +{% endblock %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/current.md b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/current.md new file mode 100644 index 0000000..3d6ace9 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/current.md @@ -0,0 +1,21 @@ +{% block escalation %} +Escalation Policy: +- Immediate escalation to dedicated account manager available +- 24/7 priority callback option +- Response time: within 1 hour +{% endblock %} + +{% block capabilities %} +{{ super() }} + +Premium Tier Benefits: +- Priority phone and chat support +- Dedicated account manager +- Proactive issue monitoring +- Custom solutions available +{% endblock %} + +{% block greeting %} +Standard Greeting: +Hello! Welcome to {{ company_name }} Premium Support. As a valued premium customer, you have priority access to our support team. How can I assist you today? +{% endblock %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/versions/v1.md b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/versions/v1.md new file mode 100644 index 0000000..f2c5da0 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/layers/tier/premium/versions/v1.md @@ -0,0 +1,13 @@ +{% block escalation %} +Escalation Policy (v1): +- Priority escalation available +- Fast response time +{% endblock %} + +{% block capabilities %} +{{ super() }} + +Premium Benefits (v1): +- Priority support +- Phone access +{% endblock %} diff --git a/tests/fixtures/test_prompts/CustomerSupportLayers/versions/v1.md b/tests/fixtures/test_prompts/CustomerSupportLayers/versions/v1.md new file mode 100644 index 0000000..02a71c0 --- /dev/null +++ b/tests/fixtures/test_prompts/CustomerSupportLayers/versions/v1.md @@ -0,0 +1,29 @@ +{% block identity %} +You are a customer support assistant for {{ company_name }} (v1). +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Answer questions +- Basic troubleshooting +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- Basic product info +{% endblock %} + +{% block response_style %} +Response Style: +- Be helpful +- Be concise +{% endblock %} + +{% block escalation %} +{% endblock %} + +{% block greeting %} +Hello! How can I help? +{% endblock %} + +Company: {{ company_name }} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/config.yaml b/tests/fixtures/test_prompts_layers/CustomerSupport/config.yaml new file mode 100644 index 0000000..b86fac9 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/config.yaml @@ -0,0 +1,57 @@ +# CustomerSupport configuration with layer composition +metadata: + name: "CustomerSupport" + description: "Customer support assistant with multi-dimensional customization" + author: "Test Suite" + version: "1.0.0" + +# Schema for variables +schema: + type: "object" + required: + - company_name + properties: + company_name: + type: string + product_line: + type: string + enum: + - software + - hardware + region: + type: string + tier: + type: string + default: basic + +# Layer composition configuration +composition: + # Define available layer axes + layers: + - name: product_line + variable: product_line + path: layers/product_line + required: false + + - name: region + variable: region + path: layers/region + required: false + + - name: tier + variable: tier + path: layers/tier + required: false + default: basic + + # Order matters - later layers override earlier ones + merge_order: + - product_line + - region + - tier + +# Configuration for the prompt +config: + model: "gpt-4o" + provider: "openai" + temperature: 0.7 diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/current.md b/tests/fixtures/test_prompts_layers/CustomerSupport/current.md new file mode 100644 index 0000000..b3cadd1 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/current.md @@ -0,0 +1,42 @@ +{% block identity %} +You are a helpful customer support assistant for {{ company_name }}. +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Answer product questions +- Troubleshoot common issues +- Process returns and exchanges +- Escalate complex cases +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- General product information +- Warranty policies +- Return procedures +{% endblock %} + +{% block response_style %} +Response Style: +- Be friendly and professional +- Provide clear step-by-step guidance +- Confirm understanding before proceeding +{% endblock %} + +{% block escalation %} +Escalation Policy: +- Escalate after 3 failed resolution attempts +- Always offer callback option +{% endblock %} + +{% block greeting %} +Standard Greeting: +Hello! Thank you for contacting {{ company_name }} support. How can I help you today? +{% endblock %} + +Context: +Company: {{ company_name }} +{% if product_line %}Product Line: {{ product_line }}{% endif %} +{% if region %}Region: {{ region }}{% endif %} +{% if tier %}Support Tier: {{ tier }}{% endif %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/hardware/current.md b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/hardware/current.md new file mode 100644 index 0000000..dce7c4a --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/hardware/current.md @@ -0,0 +1,23 @@ +{% block identity %} +You are a technical support specialist for {{ company_name }}, +specializing in hardware products. Help customers with setup, +maintenance, and troubleshooting of physical devices. +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- Hardware specifications and compatibility +- Physical setup and installation +- Warranty and repair procedures +- Replacement part information +- Safety guidelines +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Device setup assistance +- Hardware diagnostics +- Warranty claim processing +- Repair scheduling +- Replacement recommendations +{% endblock %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/software/current.md b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/software/current.md new file mode 100644 index 0000000..53042d3 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/product_line/software/current.md @@ -0,0 +1,23 @@ +{% block identity %} +You are a technical support specialist for {{ company_name }}, +specializing in software products. Help customers with installation, +configuration, and troubleshooting of software applications. +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- Software installation procedures +- System requirements and compatibility +- License activation and management +- Common error codes and solutions +- Integration with other tools +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Software installation guidance +- Configuration assistance +- Bug reporting and workarounds +- Feature explanations +- Update and upgrade support +{% endblock %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/eu/current.md b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/eu/current.md new file mode 100644 index 0000000..cfc10f2 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/eu/current.md @@ -0,0 +1,17 @@ +{% block identity %} +Vous etes un assistant de support client pour {{ company_name }}. +Aidez les clients avec leurs questions et problemes. +{% endblock %} + +{% block response_style %} +Response Style: +- Use British English spelling (colour, centre, etc.) +- Reference CET business hours +- Comply with GDPR requirements +- Maintain formal but friendly tone +{% endblock %} + +{% block greeting %} +Standard Greeting: +Good day! Thank you for contacting {{ company_name }} support. How may I assist you? +{% endblock %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/us/current.md b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/us/current.md new file mode 100644 index 0000000..5dfee07 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/region/us/current.md @@ -0,0 +1,12 @@ +{% block response_style %} +Response Style: +- Use American English spelling (color, center, etc.) +- Reference US business hours (9 AM - 5 PM EST) +- Mention US-specific policies when relevant +- Be friendly and conversational +{% endblock %} + +{% block greeting %} +Standard Greeting: +Hi there! Thanks for reaching out to {{ company_name }} support. What can I help you with today? +{% endblock %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/basic/current.md b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/basic/current.md new file mode 100644 index 0000000..4ae7a40 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/basic/current.md @@ -0,0 +1,15 @@ +{% block escalation %} +Escalation Policy: +- Escalate to senior support after 3 failed attempts +- Offer callback during business hours only +- Response time: within 24 hours +{% endblock %} + +{% block capabilities %} +{{ super() }} + +Basic Tier Limitations: +- Email support only +- Standard response times +- Self-service resources available +{% endblock %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/current.md b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/current.md new file mode 100644 index 0000000..3d6ace9 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/current.md @@ -0,0 +1,21 @@ +{% block escalation %} +Escalation Policy: +- Immediate escalation to dedicated account manager available +- 24/7 priority callback option +- Response time: within 1 hour +{% endblock %} + +{% block capabilities %} +{{ super() }} + +Premium Tier Benefits: +- Priority phone and chat support +- Dedicated account manager +- Proactive issue monitoring +- Custom solutions available +{% endblock %} + +{% block greeting %} +Standard Greeting: +Hello! Welcome to {{ company_name }} Premium Support. As a valued premium customer, you have priority access to our support team. How can I assist you today? +{% endblock %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/versions/v1.md b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/versions/v1.md new file mode 100644 index 0000000..f2c5da0 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/layers/tier/premium/versions/v1.md @@ -0,0 +1,13 @@ +{% block escalation %} +Escalation Policy (v1): +- Priority escalation available +- Fast response time +{% endblock %} + +{% block capabilities %} +{{ super() }} + +Premium Benefits (v1): +- Priority support +- Phone access +{% endblock %} diff --git a/tests/fixtures/test_prompts_layers/CustomerSupport/versions/v1.md b/tests/fixtures/test_prompts_layers/CustomerSupport/versions/v1.md new file mode 100644 index 0000000..02a71c0 --- /dev/null +++ b/tests/fixtures/test_prompts_layers/CustomerSupport/versions/v1.md @@ -0,0 +1,29 @@ +{% block identity %} +You are a customer support assistant for {{ company_name }} (v1). +{% endblock %} + +{% block capabilities %} +Support Capabilities: +- Answer questions +- Basic troubleshooting +{% endblock %} + +{% block knowledge %} +Product Knowledge: +- Basic product info +{% endblock %} + +{% block response_style %} +Response Style: +- Be helpful +- Be concise +{% endblock %} + +{% block escalation %} +{% endblock %} + +{% block greeting %} +Hello! How can I help? +{% endblock %} + +Company: {{ company_name }} diff --git a/tests/functional/test_layers_api.py b/tests/functional/test_layers_api.py new file mode 100644 index 0000000..c06cb3e --- /dev/null +++ b/tests/functional/test_layers_api.py @@ -0,0 +1,269 @@ +""" +Functional tests for layer composition API. + +Tests the public API methods get_composed_prompt() and list_layers(). +""" + +import pytest +from pathlib import Path +import tempfile +import shutil + +from promptix import Promptix +from promptix.core.container import Container, set_container, reset_container +from promptix.core.components import LayerComposer +from promptix.core.exceptions import LayerRequiredError + + +@pytest.fixture +def layers_container(test_prompts_layers_dir): + """Create a container configured for layer testing.""" + container = Container() + # Override the layer_composer with one pointing to test fixtures + container.override( + "layer_composer", + LayerComposer( + prompts_dir=test_prompts_layers_dir, + logger=container.get("logger") + ) + ) + set_container(container) + yield container + reset_container() + + +class TestGetComposedPrompt: + """Test public API for layer composition.""" + + def test_basic_composition(self, layers_container): + """Basic usage with auto-selected layers.""" + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Test Company" + ) + + assert isinstance(prompt, str) + assert "Test Company" in prompt + # Should have base content + assert "customer support assistant" in prompt.lower() + + def test_single_layer_selection(self, layers_container): + """Single layer is applied based on variable.""" + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="TechSoft Inc", + product_line="software" + ) + + assert "TechSoft Inc" in prompt + assert "technical support specialist" in prompt.lower() + assert "software" in prompt.lower() + + def test_multiple_layers_merge(self, layers_container): + """Multiple layers merge in order.""" + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Premium Hardware Co", + product_line="hardware", + tier="premium" + ) + + assert "Premium Hardware Co" in prompt + # Should have hardware content + assert "hardware" in prompt.lower() + # Should have premium tier content + assert "Premium" in prompt + # Premium tier benefits should be present + assert "Dedicated account manager" in prompt + + def test_region_layer_eu(self, layers_container): + """Region layer applies region-specific content.""" + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="European Tech", + region="eu" + ) + + assert "European Tech" in prompt + # EU content should be present (French greeting or GDPR) + assert "Vous etes un assistant" in prompt or "GDPR" in prompt + + def test_all_layers_combined(self, layers_container): + """All three layers applied together.""" + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Global Software EU", + product_line="software", + region="eu", + tier="premium" + ) + + assert "Global Software EU" in prompt + # EU identity from region layer + assert "Vous etes un assistant" in prompt or "GDPR" in prompt + # Premium tier benefits should still be there + assert "Premium" in prompt or "priority" in prompt.lower() + + def test_explicit_layer_versions(self, layers_container): + """Explicit version selection for layers.""" + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Test Store", + tier="premium", + layer_versions={"tier": "v1"} + ) + + # v1 version should be used (has "(v1)" marker) + assert "(v1)" in prompt or "v1" in prompt.lower() + + def test_skip_layers(self, layers_container): + """Skip specific layers.""" + # With region layer + prompt_with_region = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Test Store", + region="eu" + ) + + # Without region layer (skipped) + prompt_without_region = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Test Store", + region="eu", + skip_layers=["region"] + ) + + assert "Vous etes un assistant" in prompt_with_region or "GDPR" in prompt_with_region + # When skipped, EU-specific content should not appear + assert "Vous etes un assistant" not in prompt_without_region + + def test_debug_mode(self, layers_container): + """Debug mode returns composition info.""" + result = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Debug Test", + product_line="software", + tier="premium", + _debug=True + ) + + prompt, debug_info = result + assert isinstance(prompt, str) + assert "Debug Test" in prompt + + # Check debug info structure + assert hasattr(debug_info, "base_template") + assert hasattr(debug_info, "layers_applied") + assert hasattr(debug_info, "layers_skipped") + assert hasattr(debug_info, "blocks_overridden") + + # Should have applied at least 2 layers + applied_names = [layer["name"] for layer in debug_info.layers_applied] + assert "product_line" in applied_names + assert "tier" in applied_names + + def test_base_version_selection(self, layers_container): + """Base template version can be specified.""" + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + version="v1", + company_name="Version Test" + ) + + assert "Version Test" in prompt + # v1 has "(v1)" marker in identity block + assert "(v1)" in prompt + + +class TestListLayers: + """Test layer introspection functionality.""" + + def test_list_layers(self, layers_container): + """List available layers for a prompt.""" + layers = Promptix.list_layers("CustomerSupport") + + assert isinstance(layers, dict) + assert "product_line" in layers + assert "region" in layers + assert "tier" in layers + + # Check layer values + assert "software" in layers["product_line"] + assert "hardware" in layers["product_line"] + assert "us" in layers["region"] + assert "eu" in layers["region"] + assert "basic" in layers["tier"] + assert "premium" in layers["tier"] + + +class TestBackwardCompatibility: + """Ensure existing API still works.""" + + def test_get_prompt_unchanged(self, test_prompts_dir): + """get_prompt() still works without layers.""" + # Reset container to use default test prompts + reset_container() + + prompt = Promptix.get_prompt( + prompt_template="SimpleChat", + user_name="Test User", + assistant_name="Test Bot" + ) + + assert isinstance(prompt, str) + assert "Test User" in prompt or "Test Bot" in prompt + + +class TestErrorHandling: + """Test error scenarios.""" + + def test_missing_prompt_template(self, layers_container): + """Non-existent prompt raises error.""" + with pytest.raises(Exception): + Promptix.get_composed_prompt( + prompt_template="NonExistentPrompt", + company_name="Test" + ) + + def test_instance_method_compose_prompt(self, layers_container): + """Instance method compose_prompt works.""" + promptix = Promptix() + prompt = promptix.compose_prompt( + prompt_template="CustomerSupport", + company_name="Instance Test", + product_line="software" + ) + + assert "Instance Test" in prompt + assert "software" in prompt.lower() + + +class TestLayerMergeModes: + """Test different block merge modes.""" + + def test_super_call_includes_parent(self, layers_container): + """super() includes parent block content.""" + # Premium tier layer uses super() in capabilities block + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="Premium Test", + tier="premium" + ) + + # Should have both base capabilities AND premium-specific content + assert "Answer product questions" in prompt or "Troubleshoot" in prompt # From base + assert "Dedicated account manager" in prompt # From premium layer + + def test_replace_mode_overrides_completely(self, layers_container): + """Replace mode completely overrides parent.""" + # EU region completely replaces identity block with French text + prompt = Promptix.get_composed_prompt( + prompt_template="CustomerSupport", + company_name="EU Store", + region="eu" + ) + + # EU identity should replace English + assert "Vous etes un assistant" in prompt + # Should NOT have the English identity anymore + assert "You are a helpful customer support assistant" not in prompt diff --git a/tests/unit/test_layer_composition.py b/tests/unit/test_layer_composition.py new file mode 100644 index 0000000..00254d1 --- /dev/null +++ b/tests/unit/test_layer_composition.py @@ -0,0 +1,626 @@ +""" +Unit tests for LayerComposer component. + +Tests cover block extraction, merging, layer loading, and full composition. +""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch +import tempfile +import shutil + +from promptix.core.components.layer_composer import ( + LayerComposer, + LayerConfig, + BlockDefinition, + CompositionDebugInfo, +) +from promptix.core.exceptions import ( + LayerRequiredError, + ConfigurationError, + TemplateRenderError, +) + + +@pytest.fixture +def layers_fixture_path(): + """Get the path to the layers test fixtures.""" + return Path(__file__).parent.parent / "fixtures" / "test_prompts_layers" + + +@pytest.fixture +def layer_composer(layers_fixture_path): + """Create a LayerComposer instance with test fixtures.""" + return LayerComposer(prompts_dir=layers_fixture_path) + + +@pytest.fixture +def temp_prompts_dir(): + """Create a temporary prompts directory for isolated tests.""" + temp_dir = tempfile.mkdtemp() + yield Path(temp_dir) + shutil.rmtree(temp_dir) + + +class TestBlockExtraction: + """Test block extraction from templates.""" + + def test_extract_simple_block(self, layer_composer): + """Extract a basic block.""" + content = """{% block greeting %}Hello, World!{% endblock %}""" + blocks = layer_composer._extract_blocks(content) + + assert "greeting" in blocks + assert blocks["greeting"].name == "greeting" + assert blocks["greeting"].content == "Hello, World!" + assert blocks["greeting"].mode == "replace" + + def test_extract_block_with_mode(self, layer_composer): + """Extract block with mode='append'.""" + content = """{% block capabilities mode="append" %}Extra capability{% endblock %}""" + blocks = layer_composer._extract_blocks(content) + + assert "capabilities" in blocks + assert blocks["capabilities"].mode == "append" + assert blocks["capabilities"].content == "Extra capability" + + def test_extract_block_with_single_quotes(self, layer_composer): + """Extract block with mode using single quotes.""" + content = """{% block capabilities mode='prepend' %}First capability{% endblock %}""" + blocks = layer_composer._extract_blocks(content) + + assert "capabilities" in blocks + assert blocks["capabilities"].mode == "prepend" + + def test_extract_multiple_blocks(self, layer_composer): + """Extract several blocks from one template.""" + content = """ + {% block header %}Header content{% endblock %} + Some text in between + {% block body %}Body content{% endblock %} + {% block footer %}Footer content{% endblock %} + """ + blocks = layer_composer._extract_blocks(content) + + assert len(blocks) == 3 + assert "header" in blocks + assert "body" in blocks + assert "footer" in blocks + assert blocks["header"].content == "Header content" + assert blocks["body"].content == "Body content" + assert blocks["footer"].content == "Footer content" + + def test_extract_block_with_nested_jinja(self, layer_composer): + """Block containing {% if %} statements.""" + content = """{% block capabilities %} +{% if premium %} +- Premium Support +{% endif %} +- Standard Support +{% endblock %}""" + blocks = layer_composer._extract_blocks(content) + + assert "capabilities" in blocks + assert "{% if premium %}" in blocks["capabilities"].content + assert "- Premium Support" in blocks["capabilities"].content + + def test_extract_empty_block(self, layer_composer): + """Empty block definition.""" + content = """{% block escalation %}{% endblock %}""" + blocks = layer_composer._extract_blocks(content) + + assert "escalation" in blocks + assert blocks["escalation"].content == "" + + def test_extract_block_with_whitespace(self, layer_composer): + """Block with only whitespace.""" + content = """{% block escalation %} + + {% endblock %}""" + blocks = layer_composer._extract_blocks(content) + + assert "escalation" in blocks + # Content should be stripped + assert blocks["escalation"].content == "" + + def test_no_blocks(self, layer_composer): + """Template with no blocks returns empty dict.""" + content = """This is just a simple template with {{ variable }}.""" + blocks = layer_composer._extract_blocks(content) + + assert blocks == {} + + +class TestBlockMerging: + """Test block merge operations.""" + + def test_merge_replace_mode(self, layer_composer): + """Default replace completely overrides parent.""" + base_blocks = { + "greeting": BlockDefinition( + name="greeting", + content="Hello from base", + mode="replace" + ) + } + layer_blocks = { + "greeting": BlockDefinition( + name="greeting", + content="Hello from layer", + mode="replace" + ) + } + + result, modified = layer_composer._merge_blocks( + base_blocks, layer_blocks, "test_layer" + ) + + assert result["greeting"].content == "Hello from layer" + assert "greeting" in modified + + def test_merge_append_mode(self, layer_composer): + """Append adds after parent content.""" + base_blocks = { + "capabilities": BlockDefinition( + name="capabilities", + content="- Base Capability", + mode="replace" + ) + } + layer_blocks = { + "capabilities": BlockDefinition( + name="capabilities", + content="- Layer Capability", + mode="append" + ) + } + + result, modified = layer_composer._merge_blocks( + base_blocks, layer_blocks, "test_layer" + ) + + assert "- Base Capability" in result["capabilities"].content + assert "- Layer Capability" in result["capabilities"].content + # Base should come first + assert result["capabilities"].content.index("Base") < result["capabilities"].content.index("Layer") + + def test_merge_prepend_mode(self, layer_composer): + """Prepend adds before parent content.""" + base_blocks = { + "capabilities": BlockDefinition( + name="capabilities", + content="- Base Capability", + mode="replace" + ) + } + layer_blocks = { + "capabilities": BlockDefinition( + name="capabilities", + content="- Layer Capability", + mode="prepend" + ) + } + + result, modified = layer_composer._merge_blocks( + base_blocks, layer_blocks, "test_layer" + ) + + assert "- Base Capability" in result["capabilities"].content + assert "- Layer Capability" in result["capabilities"].content + # Layer should come first + assert result["capabilities"].content.index("Layer") < result["capabilities"].content.index("Base") + + def test_merge_with_super(self, layer_composer): + """super() is replaced with parent content.""" + base_blocks = { + "capabilities": BlockDefinition( + name="capabilities", + content="- Base Capability", + mode="replace" + ) + } + layer_blocks = { + "capabilities": BlockDefinition( + name="capabilities", + content="{{ super() }}\n- Layer Capability", + mode="replace" + ) + } + + result, modified = layer_composer._merge_blocks( + base_blocks, layer_blocks, "test_layer" + ) + + assert "- Base Capability" in result["capabilities"].content + assert "- Layer Capability" in result["capabilities"].content + assert "{{ super() }}" not in result["capabilities"].content + + def test_merge_multiple_super_calls(self, layer_composer): + """Multiple super() calls all get replaced.""" + base_blocks = { + "content": BlockDefinition( + name="content", + content="BASE", + mode="replace" + ) + } + layer_blocks = { + "content": BlockDefinition( + name="content", + content="Before {{ super() }} Middle {{ super() }} After", + mode="replace" + ) + } + + result, modified = layer_composer._merge_blocks( + base_blocks, layer_blocks, "test_layer" + ) + + assert result["content"].content == "Before BASE Middle BASE After" + + def test_merge_unknown_block_ignored(self, layer_composer): + """Layer block not in base is ignored.""" + mock_logger = Mock() + layer_composer._logger = mock_logger + + base_blocks = { + "existing": BlockDefinition( + name="existing", + content="Existing content", + mode="replace" + ) + } + layer_blocks = { + "new_block": BlockDefinition( + name="new_block", + content="New content", + mode="replace" + ) + } + + result, modified = layer_composer._merge_blocks( + base_blocks, layer_blocks, "test_layer" + ) + + assert "new_block" not in result + assert "existing" in result + mock_logger.warning.assert_called_once() + + def test_merge_preserves_unmodified_blocks(self, layer_composer): + """Blocks not in layer keep base content.""" + base_blocks = { + "header": BlockDefinition( + name="header", + content="Header content", + mode="replace" + ), + "footer": BlockDefinition( + name="footer", + content="Footer content", + mode="replace" + ) + } + layer_blocks = { + "header": BlockDefinition( + name="header", + content="New header", + mode="replace" + ) + } + + result, modified = layer_composer._merge_blocks( + base_blocks, layer_blocks, "test_layer" + ) + + assert result["header"].content == "New header" + assert result["footer"].content == "Footer content" + assert "header" in modified + assert "footer" not in modified + + +class TestLayerLoading: + """Test layer file resolution and loading.""" + + def test_load_current_version(self, layer_composer, layers_fixture_path): + """Load current.md when no version specified.""" + content = layer_composer._load_layer( + prompt_dir=layers_fixture_path / "CustomerSupport", + layer_path="layers/tier", + layer_value="premium", + version=None + ) + + assert content is not None + assert "Premium" in content + + def test_load_specific_version(self, layer_composer, layers_fixture_path): + """Load versions/v1.md when version='v1'.""" + content = layer_composer._load_layer( + prompt_dir=layers_fixture_path / "CustomerSupport", + layer_path="layers/tier", + layer_value="premium", + version="v1" + ) + + assert content is not None + assert "(v1)" in content or "v1" in content.lower() + + def test_missing_layer_optional(self, layer_composer, layers_fixture_path): + """Missing optional layer returns None.""" + content = layer_composer._load_layer( + prompt_dir=layers_fixture_path / "CustomerSupport", + layer_path="layers/region", + layer_value="nonexistent_region", + version=None + ) + + assert content is None + + def test_load_config(self, layer_composer, layers_fixture_path): + """Test config loading from prompt directory.""" + config = layer_composer._load_config( + layers_fixture_path / "CustomerSupport" + ) + + assert "composition" in config + assert "layers" in config["composition"] + assert "merge_order" in config["composition"] + + +class TestComposition: + """Test full composition pipeline.""" + + def test_compose_no_layers_config(self, temp_prompts_dir): + """Base template only when no layers configured.""" + # Create minimal prompt without composition config + prompt_dir = temp_prompts_dir / "SimplePrompt" + prompt_dir.mkdir(parents=True) + + config_content = """ +metadata: + name: SimplePrompt +config: + model: gpt-4 +""" + (prompt_dir / "config.yaml").write_text(config_content) + (prompt_dir / "current.md").write_text("Hello, {{ name }}!") + + composer = LayerComposer(prompts_dir=temp_prompts_dir) + result = composer.compose( + prompt_name="SimplePrompt", + variables={"name": "World"} + ) + + assert result == "Hello, World!" + + def test_compose_single_layer(self, layer_composer): + """Single layer overrides base blocks.""" + result = layer_composer.compose( + prompt_name="CustomerSupport", + variables={ + "company_name": "TechCorp", + "product_line": "software" + } + ) + + assert "TechCorp" in result + assert "software" in result.lower() + + def test_compose_multiple_layers_order(self, layer_composer): + """Later layers override earlier layers.""" + result = layer_composer.compose( + prompt_name="CustomerSupport", + variables={ + "company_name": "TechCorp", + "product_line": "software", + "tier": "premium" + } + ) + + # Should have premium-specific content from tier layer + assert "Premium" in result + + def test_compose_with_skip_layers(self, layer_composer): + """Skipped layers not applied.""" + result_with_region = layer_composer.compose( + prompt_name="CustomerSupport", + variables={ + "company_name": "TechCorp", + "region": "eu" + } + ) + + result_without_region = layer_composer.compose( + prompt_name="CustomerSupport", + variables={ + "company_name": "TechCorp", + "region": "eu" + }, + skip_layers=["region"] + ) + + # EU content should only be in result_with_region + assert "Vous etes" in result_with_region + assert "Vous etes" not in result_without_region + + def test_compose_with_layer_versions(self, layer_composer): + """Specific versions loaded for layers.""" + result = layer_composer.compose( + prompt_name="CustomerSupport", + variables={ + "company_name": "TechCorp", + "tier": "premium" + }, + layer_versions={"tier": "v1"} + ) + + # v1 version should be used + assert "(v1)" in result or "v1" in result.lower() + + def test_compose_variables_in_blocks(self, layer_composer): + """Jinja2 variables work in merged blocks.""" + result = layer_composer.compose( + prompt_name="CustomerSupport", + variables={ + "company_name": "My Custom Company" + } + ) + + assert "My Custom Company" in result + + def test_compose_debug_mode(self, layer_composer): + """Debug mode returns composition info.""" + result, debug_info = layer_composer.compose( + prompt_name="CustomerSupport", + variables={ + "company_name": "TechCorp", + "product_line": "software", + "tier": "premium" + }, + _debug=True + ) + + assert isinstance(debug_info, CompositionDebugInfo) + # At least 2 layers applied: product_line and tier + assert len(debug_info.layers_applied) >= 2 + assert any(l["name"] == "product_line" for l in debug_info.layers_applied) + assert any(l["name"] == "tier" for l in debug_info.layers_applied) + + +class TestLayerIntrospection: + """Test layer discovery functionality.""" + + def test_list_layers(self, layer_composer): + """List available layers for a prompt.""" + layers = layer_composer.list_layers("CustomerSupport") + + assert "product_line" in layers + assert "region" in layers + assert "tier" in layers + + assert "software" in layers["product_line"] + assert "hardware" in layers["product_line"] + assert "us" in layers["region"] + assert "eu" in layers["region"] + assert "basic" in layers["tier"] + assert "premium" in layers["tier"] + + def test_list_layers_no_composition(self, temp_prompts_dir): + """Return empty dict for prompts without composition config.""" + prompt_dir = temp_prompts_dir / "NoLayers" + prompt_dir.mkdir(parents=True) + (prompt_dir / "config.yaml").write_text("metadata:\n name: NoLayers") + (prompt_dir / "current.md").write_text("Simple prompt") + + composer = LayerComposer(prompts_dir=temp_prompts_dir) + layers = composer.list_layers("NoLayers") + + assert layers == {} + + +class TestErrorHandling: + """Test error scenarios.""" + + def test_required_layer_missing(self, temp_prompts_dir): + """Required layer variable not provided raises error.""" + prompt_dir = temp_prompts_dir / "RequiredLayer" + prompt_dir.mkdir(parents=True) + layers_dir = prompt_dir / "layers" / "region" / "us" + layers_dir.mkdir(parents=True) + + config_content = """ +metadata: + name: RequiredLayer +composition: + layers: + - name: region + variable: region + path: layers/region + required: true + merge_order: + - region +""" + (prompt_dir / "config.yaml").write_text(config_content) + (prompt_dir / "current.md").write_text("{% block content %}Base{% endblock %}") + (layers_dir / "current.md").write_text("{% block content %}US{% endblock %}") + + composer = LayerComposer(prompts_dir=temp_prompts_dir) + + with pytest.raises(LayerRequiredError) as exc_info: + composer.compose( + prompt_name="RequiredLayer", + variables={} # Missing required 'region' variable + ) + + assert "region" in str(exc_info.value) + + def test_invalid_config_yaml(self, temp_prompts_dir): + """Invalid YAML in config raises ConfigurationError.""" + prompt_dir = temp_prompts_dir / "InvalidConfig" + prompt_dir.mkdir(parents=True) + + (prompt_dir / "config.yaml").write_text("invalid: yaml: [unclosed") + (prompt_dir / "current.md").write_text("Test") + + composer = LayerComposer(prompts_dir=temp_prompts_dir) + + with pytest.raises(ConfigurationError): + composer.compose( + prompt_name="InvalidConfig", + variables={} + ) + + def test_template_not_found(self, temp_prompts_dir): + """Missing base template raises ConfigurationError.""" + prompt_dir = temp_prompts_dir / "NoTemplate" + prompt_dir.mkdir(parents=True) + (prompt_dir / "config.yaml").write_text("metadata:\n name: NoTemplate") + # No current.md created + + composer = LayerComposer(prompts_dir=temp_prompts_dir) + + with pytest.raises(ConfigurationError): + composer.compose( + prompt_name="NoTemplate", + variables={} + ) + + def test_template_render_error(self, temp_prompts_dir): + """Invalid Jinja2 syntax raises TemplateRenderError.""" + prompt_dir = temp_prompts_dir / "BadTemplate" + prompt_dir.mkdir(parents=True) + (prompt_dir / "config.yaml").write_text("metadata:\n name: BadTemplate") + (prompt_dir / "current.md").write_text("{{ undefined_filter | nonexistent }}") + + composer = LayerComposer(prompts_dir=temp_prompts_dir) + + with pytest.raises(TemplateRenderError): + composer.compose( + prompt_name="BadTemplate", + variables={} + ) + + +class TestCaching: + """Test caching functionality.""" + + def test_config_caching(self, layer_composer, layers_fixture_path): + """Config is cached after first load.""" + # Load config twice + config1 = layer_composer._load_config(layers_fixture_path / "CustomerSupport") + config2 = layer_composer._load_config(layers_fixture_path / "CustomerSupport") + + # Should be the same object due to caching + assert config1 is config2 + + def test_clear_cache(self, layer_composer, layers_fixture_path): + """Cache can be cleared.""" + # Load config to populate cache + layer_composer._load_config(layers_fixture_path / "CustomerSupport") + assert len(layer_composer._config_cache) > 0 + + layer_composer.clear_cache() + + assert len(layer_composer._config_cache) == 0 + assert len(layer_composer._block_cache) == 0