""" Routing evaluation service for dynamic survey flow. Evaluates QuestionRoutingRule conditions against user answers to determine the next question, skip logic, or early termination. """ import json import logging from dataclasses import dataclass from typing import Optional logger = logging.getLogger(__name__) @dataclass class RoutingResult: action: str # "next", "skip_to", "end_survey" next_question_id: Optional[str] = None is_complete: bool = False def evaluate_condition(operator, rule_value, answer_value): """ Evaluate a single routing rule condition. Args: operator: RoutingOperator value (e.g., "equals", "gt", "lt") rule_value: The value from the routing rule (JSON-serializable) answer_value: The user's actual answer (string or numeric) Returns: bool: Whether the condition matches """ if operator == "answered": return answer_value is not None and str(answer_value).strip() != "" if operator == "not_answered": return answer_value is None or str(answer_value).strip() == "" if answer_value is None: return False answer_str = str(answer_value).strip() if operator == "equals": return answer_str == str(rule_value) if operator == "not_equals": return answer_str != str(rule_value) if operator == "contains": return str(rule_value) in answer_str if operator in ("gt", "lt"): try: answer_num = float(answer_str) rule_num = float(rule_value) if operator == "gt": return answer_num > rule_num return answer_num < rule_num except (ValueError, TypeError): return False if operator == "in_list": if isinstance(rule_value, list): return answer_str in [str(v) for v in rule_value] return answer_str == str(rule_value) return False def get_next_question( current_question_id, answer_value, all_questions, routing_rules, answered_questions=None, ): """ Determine the next question after answering the current one. Args: current_question_id: UUID string of the just-answered question answer_value: The user's answer (string, number, or None) all_questions: Ordered list of SurveyQuestion dicts with routing data routing_rules: List of routing rule dicts for the template answered_questions: Set of question IDs already answered (for back nav) Returns: RoutingResult with next action and question ID """ question_map = {str(q["id"]): q for q in all_questions} question_ids = [str(q["id"]) for q in all_questions] if current_question_id not in question_map: return RoutingResult(action="next") rules_for_question = [r for r in routing_rules if str(r["source_question"]) == current_question_id] rules_for_question.sort(key=lambda r: r.get("order", 0)) for rule in rules_for_question: rule_value = rule.get("value") if isinstance(rule_value, str): try: rule_value = json.loads(rule_value) except (json.JSONDecodeError, TypeError): pass if evaluate_condition(rule["operator"], rule_value, answer_value): if rule["action"] == "end_survey": return RoutingResult(action="end_survey", is_complete=True) if rule["action"] == "skip_to" and rule.get("target_question"): target_id = str(rule["target_question"]) if target_id in question_map: return RoutingResult(action="skip_to", next_question_id=target_id) try: current_idx = question_ids.index(current_question_id) except ValueError: return RoutingResult(action="next") remaining = question_ids[current_idx + 1 :] for next_id in remaining: next_q = question_map[next_id] if next_q.get("is_conditional"): continues = skip_conditional_chain(next_id, question_map, question_ids, routing_rules, answered_questions) if continues: continue return RoutingResult(action="next", next_question_id=next_id) return RoutingResult(action="next", is_complete=True) def skip_conditional_chain(question_id, question_map, question_ids, routing_rules, answered_questions): """ Check if a conditional question should be skipped (no routing rule targets it based on current answers). Returns True if the question should be skipped, False if it should be shown. """ rules_targeting = [ r for r in routing_rules if str(r.get("target_question")) == question_id and r["action"] == "skip_to" ] if rules_targeting: return False incoming_show_rules = [r for r in routing_rules if str(r.get("target_question")) == question_id] if not incoming_show_rules: return True return False def resolve_question_path(all_questions, routing_rules): """ Pre-compute the full question path with routing rules. Returns the list of questions in order with routing metadata attached to each question. """ result = [] for q in all_questions: q_data = { "id": str(q.id), "text": q.text, "text_ar": q.text_ar, "question_type": q.question_type, "order": q.order, "is_required": q.is_required, "choices_json": q.choices_json or [], "is_conditional": q.is_conditional, "routing_rules": [], } for r in routing_rules: if str(r.source_question_id) == str(q.id): q_data["routing_rules"].append( { "id": str(r.id), "operator": r.operator, "value": r.value, "action": r.action, "target_question_id": str(r.target_question_id) if r.target_question_id else None, "order": r.order, } ) result.append(q_data) return result