Coverage for src/dataknobs_llm/template_utils.py: 99%

94 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-08 13:51 -0700

1"""Template rendering utilities for dataknobs-llm. 

2 

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

7 

8import re 

9from enum import Enum 

10from typing import Any, Dict 

11 

12 

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 

17 

18 

19def render_conditional_template(template: str, params: Dict[str, Any]) -> str: 

20 """Render a template with variable substitution and conditional sections. 

21 

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 

27 

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 

34 

35 Example: 

36 template = "Hello {{name}}((, you have {{count}} messages))" 

37 params = {"name": "Alice", "count": 5} 

38 result = "Hello Alice, you have 5 messages" 

39 

40 params = {"name": "Bob"} # no count 

41 result = "Hello Bob" # conditional section removed 

42 

43 Args: 

44 template: The template string 

45 params: Dictionary of parameters to substitute 

46 

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*)\}\}' 

54 

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) 

60 

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) 

71 

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

85 

86 return re.sub(var_pattern, replace_var, text) 

87 

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 

95 

96 def process_conditionals(text: str, params: Dict[str, Any]) -> str: 

97 """Process conditional sections recursively.""" 

98 result = text 

99 changed = True 

100 

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 

109 

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 

135 

136 if depth == 0: 

137 # Found a complete section 

138 content = result[start+2:end-2] 

139 

140 # Find ALL variables in this section (including nested) 

141 all_vars = find_all_variables(content) 

142 

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 

160 

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:] 

175 

176 changed = True 

177 break 

178 else: 

179 # Unmatched parentheses, leave as-is and move on 

180 start_pos = start + 1 

181 

182 return result 

183 

184 # First process all conditional sections 

185 result = process_conditionals(template, params) 

186 

187 # Then handle remaining variables outside of conditional sections 

188 result = replace_variable(result, params, in_conditional=False) 

189 

190 return result