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