opencompletion.com/activity_utils.py
Claude 22db7a9a8a
Run black formatter on all Python files
Format code according to black style guidelines for consistency
2025-11-10 19:37:05 +00:00

354 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,
}