- Created 58 unit tests covering all 8 utility functions - Tests cover template rendering, metadata conditions, conditional content, navigation, weighted random, progressive hints, and context creation - Fixed operator precedence bug: _not_contains and _not_exists must be checked before _contains and _exists to prevent false matches - All tests passing (58/58)
352 lines
11 KiB
Python
352 lines
11 KiB
Python
"""
|
|
Utility functions for OpenCompletion Activity System v2.0
|
|
|
|
Features:
|
|
- Template variable rendering ({{metadata.key}}, {{current_attempt}}, etc.)
|
|
- Advanced metadata conditions (gte, lt, contains, regex, etc.)
|
|
- Conditional content blocks (show_if)
|
|
- Conditional navigation (if/elif/else)
|
|
- Weighted random selection
|
|
- Progressive hints
|
|
"""
|
|
|
|
import re
|
|
import random
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
|
|
def render_template(text: str, context: Dict[str, Any]) -> str:
|
|
"""
|
|
Render template variables in text using {{variable}} syntax.
|
|
|
|
Supports:
|
|
- {{metadata.key}} - Access metadata values
|
|
- {{current_attempt}} - Current attempt number
|
|
- {{max_attempts}} - Maximum attempts
|
|
- {{attempts_remaining}} - Remaining attempts
|
|
- {{current_section}} - Current section ID
|
|
- {{current_step}} - Current step ID
|
|
- {{username}} - Last responding username
|
|
|
|
Args:
|
|
text: Text containing {{variable}} templates
|
|
context: Dictionary with metadata, attempts, section/step info
|
|
|
|
Returns:
|
|
Text with variables replaced
|
|
"""
|
|
if not isinstance(text, str):
|
|
return text
|
|
|
|
# Find all {{variable}} patterns
|
|
pattern = r'\{\{([^}]+)\}\}'
|
|
|
|
def replace_variable(match):
|
|
var_name = match.group(1).strip()
|
|
|
|
# Handle metadata.key syntax
|
|
if var_name.startswith('metadata.'):
|
|
key = var_name[9:] # Remove 'metadata.' prefix
|
|
metadata = context.get('metadata', {})
|
|
value = metadata.get(key, f'{{{{metadata.{key}}}}}') # Keep original if not found
|
|
return str(value) if value is not None else ''
|
|
|
|
# Handle built-in variables
|
|
value = context.get(var_name, f'{{{{{var_name}}}}}') # Keep original if not found
|
|
return str(value) if value is not None else ''
|
|
|
|
return re.sub(pattern, replace_variable, text)
|
|
|
|
|
|
def evaluate_condition(metadata: Dict[str, Any], condition_key: str, condition_value: Any) -> bool:
|
|
"""
|
|
Evaluate a single condition against metadata.
|
|
|
|
Supports operators:
|
|
- key: value - Equality
|
|
- key_ne: value - Not equal
|
|
- key_gt: value - Greater than
|
|
- key_gte: value - Greater than or equal
|
|
- key_lt: value - Less than
|
|
- key_lte: value - Less than or equal
|
|
- key_between: [min, max] - Between (inclusive)
|
|
- key_contains: value - Comma-separated list contains value
|
|
- key_not_contains: value - List does NOT contain value
|
|
- key_matches: pattern - Regex match
|
|
- key_exists: true/false - Key existence check
|
|
- key_not_exists: true/false - Key non-existence check
|
|
|
|
Args:
|
|
metadata: Metadata dictionary to check
|
|
condition_key: Condition key (may have operator suffix)
|
|
condition_value: Expected value
|
|
|
|
Returns:
|
|
True if condition met, False otherwise
|
|
"""
|
|
# Check for operator suffixes
|
|
if condition_key.endswith('_ne'):
|
|
key = condition_key[:-3]
|
|
return metadata.get(key) != condition_value
|
|
|
|
elif condition_key.endswith('_gt'):
|
|
key = condition_key[:-3]
|
|
try:
|
|
return float(metadata.get(key, 0)) > float(condition_value)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
elif condition_key.endswith('_gte'):
|
|
key = condition_key[:-4]
|
|
try:
|
|
return float(metadata.get(key, 0)) >= float(condition_value)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
elif condition_key.endswith('_lt'):
|
|
key = condition_key[:-3]
|
|
try:
|
|
return float(metadata.get(key, 0)) < float(condition_value)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
elif condition_key.endswith('_lte'):
|
|
key = condition_key[:-4]
|
|
try:
|
|
return float(metadata.get(key, 0)) <= float(condition_value)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
elif condition_key.endswith('_between'):
|
|
key = condition_key[:-8]
|
|
if not isinstance(condition_value, list) or len(condition_value) != 2:
|
|
return False
|
|
try:
|
|
val = float(metadata.get(key, 0))
|
|
return float(condition_value[0]) <= val <= float(condition_value[1])
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
elif condition_key.endswith('_not_contains'):
|
|
key = condition_key[:-13]
|
|
value_str = str(metadata.get(key, ''))
|
|
items = [item.strip() for item in value_str.split(',') if item.strip()]
|
|
return str(condition_value) not in items
|
|
|
|
elif condition_key.endswith('_contains'):
|
|
key = condition_key[:-9]
|
|
value_str = str(metadata.get(key, ''))
|
|
# Split by comma and check if condition_value is in list
|
|
items = [item.strip() for item in value_str.split(',') if item.strip()]
|
|
return str(condition_value) in items
|
|
|
|
elif condition_key.endswith('_matches'):
|
|
key = condition_key[:-8]
|
|
value_str = str(metadata.get(key, ''))
|
|
try:
|
|
return bool(re.search(str(condition_value), value_str))
|
|
except re.error:
|
|
return False
|
|
|
|
elif condition_key.endswith('_not_exists'):
|
|
key = condition_key[:-11]
|
|
if condition_value:
|
|
return key not in metadata
|
|
else:
|
|
return key in metadata
|
|
|
|
elif condition_key.endswith('_exists'):
|
|
key = condition_key[:-7]
|
|
if condition_value:
|
|
return key in metadata
|
|
else:
|
|
return key not in metadata
|
|
|
|
else:
|
|
# Simple equality check
|
|
return metadata.get(condition_key) == condition_value
|
|
|
|
|
|
def check_conditions(metadata: Dict[str, Any], conditions: Dict[str, Any]) -> bool:
|
|
"""
|
|
Check if ALL conditions are met (AND logic).
|
|
|
|
Args:
|
|
metadata: Metadata dictionary
|
|
conditions: Dictionary of condition_key: condition_value pairs
|
|
|
|
Returns:
|
|
True if all conditions met, False otherwise
|
|
"""
|
|
if not conditions:
|
|
return True
|
|
|
|
return all(
|
|
evaluate_condition(metadata, key, value)
|
|
for key, value in conditions.items()
|
|
)
|
|
|
|
|
|
def filter_content_blocks(
|
|
content_blocks: List[Union[str, Dict[str, Any]]],
|
|
metadata: Dict[str, Any],
|
|
context: Dict[str, Any]
|
|
) -> List[str]:
|
|
"""
|
|
Filter and render content blocks based on show_if conditions.
|
|
|
|
Content blocks can be:
|
|
- Simple strings: Always shown
|
|
- Objects with 'text' and 'show_if': Conditionally shown
|
|
|
|
Args:
|
|
content_blocks: List of content blocks (strings or dicts)
|
|
metadata: Metadata dictionary for condition evaluation
|
|
context: Template rendering context
|
|
|
|
Returns:
|
|
List of rendered text strings that passed conditions
|
|
"""
|
|
result = []
|
|
|
|
for block in content_blocks:
|
|
if isinstance(block, str):
|
|
# Simple string - always show, just render templates
|
|
rendered = render_template(block, context)
|
|
result.append(rendered)
|
|
|
|
elif isinstance(block, dict):
|
|
# Conditional block - check show_if condition
|
|
text = block.get('text', '')
|
|
show_if = block.get('show_if', {})
|
|
|
|
# Check if conditions are met
|
|
if check_conditions(metadata, show_if):
|
|
rendered = render_template(text, context)
|
|
result.append(rendered)
|
|
|
|
return result
|
|
|
|
|
|
def resolve_conditional_navigation(
|
|
next_section_and_step: Union[str, List[Dict[str, Any]]],
|
|
metadata: Dict[str, Any]
|
|
) -> Optional[str]:
|
|
"""
|
|
Resolve conditional navigation (if/elif/else structure).
|
|
|
|
Args:
|
|
next_section_and_step: Either a string or list of conditional branches
|
|
metadata: Metadata dictionary for condition evaluation
|
|
|
|
Returns:
|
|
Resolved "section:step" string or None
|
|
"""
|
|
# Simple string - return as-is
|
|
if isinstance(next_section_and_step, str):
|
|
return next_section_and_step
|
|
|
|
# Conditional branches
|
|
if isinstance(next_section_and_step, list):
|
|
for branch in next_section_and_step:
|
|
if 'if' in branch:
|
|
# if branch
|
|
if check_conditions(metadata, branch['if']):
|
|
return branch.get('goto')
|
|
|
|
elif 'elif' in branch:
|
|
# elif branch
|
|
if check_conditions(metadata, branch['elif']):
|
|
return branch.get('goto')
|
|
|
|
elif 'else' in branch:
|
|
# else branch - always taken if reached
|
|
return branch.get('goto')
|
|
|
|
return None
|
|
|
|
|
|
def select_weighted_random(weighted_options: List[Dict[str, Any]]) -> Any:
|
|
"""
|
|
Select a random value from weighted options.
|
|
|
|
Args:
|
|
weighted_options: List of dicts with 'value' and 'weight' keys
|
|
|
|
Returns:
|
|
Selected value
|
|
"""
|
|
if not weighted_options:
|
|
return None
|
|
|
|
# Extract values and weights
|
|
values = [opt['value'] for opt in weighted_options]
|
|
weights = [opt.get('weight', 1) for opt in weighted_options]
|
|
|
|
# Use random.choices for weighted selection
|
|
selected = random.choices(values, weights=weights, k=1)
|
|
return selected[0]
|
|
|
|
|
|
def get_progressive_hint(
|
|
hints: List[Dict[str, Any]],
|
|
current_attempt: int,
|
|
context: Dict[str, Any]
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get the hint for the current attempt number, if one exists.
|
|
|
|
Args:
|
|
hints: List of hint dicts with 'attempt', 'text', 'counts_as_attempt' keys
|
|
current_attempt: Current attempt number (1, 2, 3, ...)
|
|
context: Template rendering context
|
|
|
|
Returns:
|
|
Hint dict with rendered text, or None if no hint for this attempt
|
|
"""
|
|
if not hints:
|
|
return None
|
|
|
|
for hint in hints:
|
|
if hint.get('attempt') == current_attempt:
|
|
# Render template variables in hint text
|
|
hint_text = render_template(hint.get('text', ''), context)
|
|
return {
|
|
'text': hint_text,
|
|
'counts_as_attempt': hint.get('counts_as_attempt', False)
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
def create_template_context(
|
|
metadata: Dict[str, Any],
|
|
current_attempt: int,
|
|
max_attempts: int,
|
|
current_section: str,
|
|
current_step: str,
|
|
username: str = "User"
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a template rendering context with all built-in variables.
|
|
|
|
Args:
|
|
metadata: Activity metadata
|
|
current_attempt: Current attempt number
|
|
max_attempts: Maximum attempts allowed
|
|
current_section: Current section ID
|
|
current_step: Current step ID
|
|
username: Username of last responder
|
|
|
|
Returns:
|
|
Context dictionary for template rendering
|
|
"""
|
|
return {
|
|
'metadata': metadata,
|
|
'current_attempt': current_attempt,
|
|
'max_attempts': max_attempts,
|
|
'attempts_remaining': max(0, max_attempts - current_attempt),
|
|
'current_section': current_section,
|
|
'current_step': current_step,
|
|
'username': username
|
|
}
|