Coverage for src / dataknobs_llm / template_utils.py: 7%
94 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-15 10:28 -0700
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-15 10:28 -0700
1"""Template rendering utilities for dataknobs-llm.
3This module provides shared template rendering functions used by both the LLM
4and prompt library components. By keeping these utilities separate, we avoid
5circular dependencies between the llm and prompts packages.
6"""
8import re
9from enum import Enum
10from typing import Any, Dict
13class TemplateStrategy(Enum):
14 """Template rendering strategies."""
15 SIMPLE = "simple" # Python str.format() with {variable} syntax
16 CONDITIONAL = "conditional" # Advanced with {{variable}} and ((conditional)) syntax
19def render_conditional_template(template: str, params: Dict[str, Any]) -> str:
20 """Render a template with variable substitution and conditional sections.
22 Variable substitution:
23 - {{variable}} syntax for placeholders
24 - Variables in params dict are replaced with their values
25 - Variables not in params are left unchanged ({{variable}} remains as-is)
26 - Whitespace handling: {{ var }} -> " value " when substituted, " {{var}} " when not
28 Conditional sections:
29 - ((optional content)) syntax for conditional blocks
30 - Section is removed if all {{variables}} inside are empty/None/missing
31 - Section is rendered (without parentheses) if any variable has a value
32 - Variables inside conditionals are replaced with empty strings if missing
33 - Nested conditionals are processed recursively
35 Example:
36 template = "Hello {{name}}((, you have {{count}} messages))"
37 params = {"name": "Alice", "count": 5}
38 result = "Hello Alice, you have 5 messages"
40 params = {"name": "Bob"} # no count
41 result = "Hello Bob" # conditional section removed
43 Args:
44 template: The template string
45 params: Dictionary of parameters to substitute
47 Returns:
48 The rendered template
49 """
50 def replace_variable(text: str, params: Dict[str, Any], in_conditional: bool = False) -> str:
51 """Replace variables in text with proper whitespace handling."""
52 # Pattern to match variables with optional whitespace
53 var_pattern = r'\{\{(\s*)(\w+)(\s*)\}\}'
55 def replace_var(match):
56 """Replace a single variable with whitespace handling."""
57 prefix_ws = match.group(1)
58 var_name = match.group(2)
59 suffix_ws = match.group(3)
61 if var_name not in params:
62 if in_conditional:
63 # In conditional sections, missing variables become empty
64 return ""
65 else:
66 # Outside conditionals, preserve the pattern but move whitespace outside
67 if prefix_ws or suffix_ws:
68 return f"{prefix_ws}{{{{{var_name}}}}}{suffix_ws}"
69 else:
70 return match.group(0)
72 value = params[var_name]
73 if value is None:
74 if in_conditional:
75 return ""
76 else:
77 # Move whitespace outside for None values
78 if prefix_ws or suffix_ws:
79 return f"{prefix_ws}{{{{{var_name}}}}}{suffix_ws}"
80 else:
81 return ""
82 else:
83 # Preserve whitespace when substituting
84 return f"{prefix_ws}{value!s}{suffix_ws}"
86 return re.sub(var_pattern, replace_var, text)
88 def find_all_variables(text: str) -> set:
89 """Find all variables in text, including nested conditionals."""
90 var_pattern = r'\{\{(\s*)(\w+)(\s*)\}\}'
91 variables = set()
92 for match in re.finditer(var_pattern, text):
93 variables.add(match.group(2))
94 return variables
96 def process_conditionals(text: str, params: Dict[str, Any]) -> str:
97 """Process conditional sections recursively."""
98 result = text
99 changed = True
101 while changed:
102 changed = False
103 # Find the first (( ... )) section
104 start_pos = 0
105 while True:
106 start = result.find('((', start_pos)
107 if start == -1:
108 break
110 # Find matching )) - must track ALL parens for correct nesting
111 depth = 1
112 paren_depth = 0 # Track single parentheses
113 end = start + 2
114 while end < len(result) and depth > 0:
115 if result[end:end+2] == '((':
116 depth += 1
117 end += 2
118 elif result[end:end+2] == '))':
119 # Only count as )) if we're not inside single parens
120 if paren_depth == 0:
121 depth -= 1
122 end += 2
123 else:
124 # This is ) followed by another )
125 paren_depth -= 1
126 end += 1
127 elif result[end] == '(':
128 paren_depth += 1
129 end += 1
130 elif result[end] == ')':
131 paren_depth -= 1
132 end += 1
133 else:
134 end += 1
136 if depth == 0:
137 # Found a complete section
138 content = result[start+2:end-2]
140 # Find ALL variables in this section (including nested)
141 all_vars = find_all_variables(content)
143 if all_vars:
144 # Check if all variables are empty/missing
145 has_value = False
146 for var_name in all_vars:
147 if var_name in params:
148 value = params[var_name]
149 if value is not None:
150 if isinstance(value, str):
151 # For strings, check if non-empty after stripping
152 if value.strip():
153 has_value = True
154 break
155 else:
156 # For non-strings, any truthy value counts
157 if value:
158 has_value = True
159 break
161 if not has_value:
162 # Remove the entire section - all variables are empty/missing
163 result = result[:start] + result[end:]
164 else:
165 # At least one variable has a value, process nested conditionals
166 processed_content = process_conditionals(content, params)
167 # Then substitute variables in the processed content
168 rendered = replace_variable(processed_content, params, in_conditional=True)
169 result = result[:start] + rendered + result[end:]
170 else:
171 # No variables in this section, keep the content as-is
172 # But still process any nested conditionals
173 processed_content = process_conditionals(content, params)
174 result = result[:start] + processed_content + result[end:]
176 changed = True
177 break
178 else:
179 # Unmatched parentheses, leave as-is and move on
180 start_pos = start + 1
182 return result
184 # First process all conditional sections
185 result = process_conditionals(template, params)
187 # Then handle remaining variables outside of conditional sections
188 result = replace_variable(result, params, in_conditional=False)
190 return result