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

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

2

3 This module provides shared template rendering functions used by both the LLM

4 and prompt library components. By keeping these utilities separate, we avoid

5 circular dependencies between the llm and prompts packages.

6"""

7

8 import re

9 from enum import Enum

10 from typing import Any, Dict

11

12

13 class 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

19 def 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