semblance/backend/app/utils/prompt_loader.py
2025-12-19 19:26:16 +00:00

207 lines
No EOL
8.8 KiB
Python
Executable file

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