Files
AICycling_mcp/templates_manager.py

177 lines
7.5 KiB
Python

import os
import logging
import yaml
from pathlib import Path
from template_validator import TemplateValidator
logger = logging.getLogger(__name__)
class TemplateManager:
"""Manages prompt templates for the cycling analyzer with inheritance, versioning, and validation"""
def __init__(self, templates_dir: str):
self.templates_dir = Path(templates_dir)
self.templates_dir.mkdir(exist_ok=True)
self.validator = TemplateValidator(str(self.templates_dir))
self.components_dir = self.templates_dir / "base"
self.versions_dir = self.templates_dir / "versions"
self.versions_dir.mkdir(exist_ok=True)
def list_templates(self) -> list[str]:
"""List available template files, including versioned ones"""
templates = []
# Base templates
for f in self.templates_dir.glob("*.txt"):
templates.append(f.name)
# Workflows and subdirs
for f in self.templates_dir.rglob("*.txt"):
if f.parent.name in ["workflows", "base"]:
rel_path = f.relative_to(self.templates_dir)
templates.append(str(rel_path))
# Versions
for ver_dir in self.versions_dir.iterdir():
if ver_dir.is_dir():
for f in ver_dir.glob("*.txt"):
templates.append(f"{ver_dir.name}@{f.stem}")
return sorted(set(templates))
def _resolve_path(self, template_name: str) -> Path:
"""Resolve template path, handling versions and subdirs"""
# Handle versioned
if '@' in template_name:
name, version = template_name.rsplit('@', 1)
ver_path = self.versions_dir / name / f"{version}.txt"
if ver_path.exists():
return ver_path
# Handle subdir paths like workflows/xxx.txt or base/yyy.txt
if '/' in template_name:
path = self.templates_dir / template_name
if path.exists():
return path
# Plain name
path = self.templates_dir / f"{template_name}.txt"
if path.exists():
return path
raise FileNotFoundError(f"Template '{template_name}' not found")
def _parse_frontmatter(self, content: str) -> tuple[dict, str]:
"""Parse YAML frontmatter."""
frontmatter = {}
body = content
if content.startswith("---\n"):
end = content.find("\n---\n")
if end != -1:
try:
frontmatter = yaml.safe_load(content[4:end]) or {}
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML frontmatter in {template_name}: {e}")
body = content[end + 5:]
return frontmatter, body
def _load_and_compose(self, template_name: str, visited: set = None, **kwargs) -> str:
"""Recursively load and compose template with inheritance and includes."""
if visited is None:
visited = set()
if template_name in visited:
raise ValueError(f"Inheritance cycle detected for {template_name}")
visited.add(template_name)
path = self._resolve_path(template_name)
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
frontmatter, body = self._parse_frontmatter(content)
# Handle extends
extends = frontmatter.get('extends')
if extends:
base_content = self._load_and_compose(extends, visited, **kwargs)
# Simple override: child body replaces base, but could be more sophisticated
body = base_content.replace("{body}", body, 1) if "{body}" in base_content else base_content + "\n\n" + body
# Handle includes
includes = frontmatter.get('includes', [])
for inc in includes:
inc_path = self.components_dir / inc
if inc_path.exists():
with open(inc_path, 'r') as f:
inc_content = f.read().format(**kwargs)
body += f"\n\n{inc_content}"
else:
logger.warning(f"Include '{inc}' not found")
# Dynamic selection based on kwargs
dynamic_includes = []
if 'workout_data' in kwargs:
dynamic_includes.append('data_sections/workout_data.txt')
if 'user_info' in kwargs:
dynamic_includes.append('data_sections/user_info.txt')
if 'training_rules' in kwargs:
dynamic_includes.append('data_sections/training_rules.txt')
# Add more as needed
for dinc in dynamic_includes:
if dinc not in includes: # Avoid duplicates
dinc_path = self.components_dir / dinc
if dinc_path.exists():
with open(dinc_path, 'r') as f:
dinc_content = f.read().format(**kwargs)
body += f"\n\n{dinc_content}"
# Replace section placeholders
import re
section_pattern = re.compile(r'\{(\w+_section|\w+)\}')
sections_map = {
'activity_summary_section': 'data_sections/activity_summary.txt',
'user_info_section': 'data_sections/user_info.txt',
'training_rules_section': 'data_sections/training_rules.txt',
'workout_data_section': 'data_sections/workout_data.txt',
'workouts_data': 'data_sections/workouts_data.txt',
'available_tools_section': 'data_sections/available_tools.txt',
'recent_data_section': 'data_sections/recent_data.txt',
'assessment_points': 'analysis_frameworks/assessment_points.txt',
'performance_analysis': 'analysis_frameworks/performance_analysis.txt',
'data_gathering': 'analysis_frameworks/data_gathering.txt',
}
for match in section_pattern.finditer(body):
placeholder = match.group(0)
section_name = match.group(1)
if section_name in sections_map:
section_file = sections_map[section_name]
section_path = self.components_dir / section_file
if section_path.exists():
with open(section_path, 'r', encoding='utf-8') as f:
section_content = f.read().format(**kwargs)
body = body.replace(placeholder, section_content)
return body
def get_template(self, template_name: str, **kwargs) -> str:
"""Load, compose, validate, and format a template."""
# Validate first
validation = self.validator.full_validate(template_name)
if not validation["valid"]:
raise ValueError(f"Template validation failed: {validation['errors']}")
# Compose
composed_content = self._load_and_compose(template_name, **kwargs)
# Debug logging
logger.debug(f"Loading template: {template_name}")
logger.debug(f"Composed content length: {len(composed_content)}")
logger.debug(f"Available kwargs: {list(kwargs.keys())}")
# Format
try:
formatted_template = composed_content.format(**kwargs)
return formatted_template
except KeyError as e:
logger.error(f"Missing variable in template '{template_name}': {e}")
logger.error(f"Available kwargs: {list(kwargs.keys())}")
raise ValueError(f"Missing variable in template '{template_name}': {e}")
except Exception as e:
raise ValueError(f"Error formatting template '{template_name}': {e}")