186 lines
6.0 KiB
Python
186 lines
6.0 KiB
Python
"""
|
|
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
|