HH/apps/surveys/routing.py
2026-04-08 17:13:35 +03:00

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