""" Prompt Loading Utility for Synthetic Society This utility provides centralized prompt loading from markdown files with template variable substitution support. """ import os from typing import Dict, Any, Optional from pathlib import Path class PromptLoaderError(Exception): """Exception raised for errors in prompt loading.""" pass class PromptLoader: """Centralized prompt loading service.""" def __init__(self, prompts_dir: Optional[str] = None): """ Initialize the prompt loader. Args: prompts_dir: Optional directory path for prompts. Defaults to backend/prompts relative to this file's location. """ if prompts_dir: self.prompts_dir = Path(prompts_dir) else: # Default to prompts directory in backend folder current_file = Path(__file__) backend_dir = current_file.parent.parent.parent # Go up from app/utils to backend self.prompts_dir = backend_dir / "prompts" if not self.prompts_dir.exists(): raise PromptLoaderError(f"Prompts directory not found: {self.prompts_dir}") def load_prompt(self, prompt_name: str, variables: Optional[Dict[str, Any]] = None) -> str: """ Load a prompt from a markdown file and substitute template variables. Args: prompt_name: Name of the prompt file (without .md extension) variables: Dictionary of variables to substitute in the template Returns: The prompt text with variables substituted Raises: PromptLoaderError: If the prompt file is not found or cannot be processed """ try: prompt_file = self.prompts_dir / f"{prompt_name}.md" if not prompt_file.exists(): raise PromptLoaderError(f"Prompt file not found: {prompt_file}") # Read the prompt content with open(prompt_file, 'r', encoding='utf-8') as f: prompt_content = f.read().strip() # Substitute variables if provided if variables: try: # Handle JSON examples by protecting them from template substitution # Look for JSON example blocks and temporarily replace them json_blocks = [] import re # Find JSON example blocks (between EXAMPLE_JSON_START and EXAMPLE_JSON_END) json_pattern = r'EXAMPLE_JSON_START\s*\n(.*?)\nEXAMPLE_JSON_END' matches = list(re.finditer(json_pattern, prompt_content, re.DOTALL)) # Replace in reverse order to avoid index shifting issues for i, match in enumerate(reversed(matches)): json_blocks.insert(0, match.group(1)) prompt_content = prompt_content[:match.start()] + f"__JSON_PLACEHOLDER_{len(matches)-1-i}__" + prompt_content[match.end():] # Also protect any remaining JSON-like patterns that might have been missed # This is a fallback for any {key: value} patterns json_fallback_pattern = r'\{[^{}]*"[^"]*"[^{}]*\}' fallback_matches = list(re.finditer(json_fallback_pattern, prompt_content)) for i, match in enumerate(reversed(fallback_matches)): json_blocks.insert(0, match.group(0)) prompt_content = prompt_content[:match.start()] + f"__JSON_FALLBACK_{len(fallback_matches)-1-i}__" + prompt_content[match.end():] # Now do the template substitution prompt_content = prompt_content.format(**variables) # Restore the JSON blocks for i, json_block in enumerate(json_blocks[:len(matches)]): prompt_content = prompt_content.replace(f"__JSON_PLACEHOLDER_{i}__", json_block) # Restore fallback blocks for i, json_block in enumerate(json_blocks[len(matches):]): fallback_index = len(json_blocks[len(matches):]) - 1 - i prompt_content = prompt_content.replace(f"__JSON_FALLBACK_{fallback_index}__", json_block) except KeyError as e: raise PromptLoaderError(f"Missing template variable in prompt '{prompt_name}': {e}") except ValueError as e: raise PromptLoaderError(f"Template formatting error in prompt '{prompt_name}': {e}") return prompt_content except Exception as e: if isinstance(e, PromptLoaderError): raise raise PromptLoaderError(f"Error loading prompt '{prompt_name}': {str(e)}") def list_available_prompts(self) -> list: """ List all available prompt files. Returns: List of prompt names (without .md extension) """ try: prompt_files = [] for file_path in self.prompts_dir.glob("*.md"): prompt_files.append(file_path.stem) return sorted(prompt_files) except Exception as e: raise PromptLoaderError(f"Error listing prompts: {str(e)}") def validate_prompt_variables(self, prompt_name: str, variables: Dict[str, Any]) -> bool: """ Validate that all required variables are provided for a prompt. Args: prompt_name: Name of the prompt file variables: Dictionary of variables to validate Returns: True if all required variables are present Raises: PromptLoaderError: If validation fails """ try: # Load the raw prompt to check for template variables prompt_file = self.prompts_dir / f"{prompt_name}.md" if not prompt_file.exists(): raise PromptLoaderError(f"Prompt file not found: {prompt_file}") with open(prompt_file, 'r', encoding='utf-8') as f: prompt_content = f.read() # Try to format with provided variables to see if any are missing try: # Handle JSON examples by protecting them from template substitution import re json_blocks = [] json_pattern = r'EXAMPLE_JSON_START\s*\n(.*?)\nEXAMPLE_JSON_END' matches = list(re.finditer(json_pattern, prompt_content, re.DOTALL)) # Replace in reverse order to avoid index shifting issues for i, match in enumerate(reversed(matches)): json_blocks.insert(0, match.group(1)) prompt_content = prompt_content[:match.start()] + f"__JSON_PLACEHOLDER_{len(matches)-1-i}__" + prompt_content[match.end():] # Also protect any remaining JSON-like patterns json_fallback_pattern = r'\{[^{}]*"[^"]*"[^{}]*\}' fallback_matches = list(re.finditer(json_fallback_pattern, prompt_content)) for i, match in enumerate(reversed(fallback_matches)): json_blocks.insert(0, match.group(0)) prompt_content = prompt_content[:match.start()] + f"__JSON_FALLBACK_{len(fallback_matches)-1-i}__" + prompt_content[match.end():] prompt_content.format(**variables) return True except KeyError as e: raise PromptLoaderError(f"Missing required variable for prompt '{prompt_name}': {e}") except Exception as e: if isinstance(e, PromptLoaderError): raise raise PromptLoaderError(f"Error validating prompt variables: {str(e)}") # Global prompt loader instance _prompt_loader = None def get_prompt_loader() -> PromptLoader: """ Get the global prompt loader instance. Returns: The global PromptLoader instance """ global _prompt_loader if _prompt_loader is None: _prompt_loader = PromptLoader() return _prompt_loader def load_prompt(prompt_name: str, variables: Optional[Dict[str, Any]] = None) -> str: """ Convenience function to load a prompt using the global loader. Args: prompt_name: Name of the prompt file (without .md extension) variables: Dictionary of variables to substitute in the template Returns: The prompt text with variables substituted """ return get_prompt_loader().load_prompt(prompt_name, variables)