44
55from app .config .questions import WIZARD_QUESTIONS
66from app .graph .state import WizardState
7+ from app .utils .validators import ValidationError , validate_ci , validate_email , validate_phone
78
89logger = logging .getLogger (__name__ )
910
1011
12+ def _normalize_answer (value ):
13+ if isinstance (value , str ):
14+ return value .strip ().lower ()
15+ return value
16+
17+
18+ def _is_question_applicable (question_cfg : dict , wizard_responses : dict ) -> bool :
19+ """Return True when a question should be shown based on conditional metadata."""
20+ if not question_cfg .get ("conditional" , False ):
21+ return True
22+
23+ condition_field = question_cfg .get ("condition_field" )
24+ condition_values = question_cfg .get ("condition_values" , [])
25+ if not condition_field :
26+ return True
27+
28+ current_value = wizard_responses .get (condition_field )
29+ normalized_current = _normalize_answer (current_value )
30+ normalized_allowed = {_normalize_answer (v ) for v in condition_values }
31+ return normalized_current in normalized_allowed
32+
33+
34+ def _get_next_question_index (current_q : int , wizard_responses : dict ):
35+ """Return next applicable question index after current_q, or None if finished."""
36+ for q_idx in sorted (k for k in WIZARD_QUESTIONS .keys () if k > current_q ):
37+ q_cfg = WIZARD_QUESTIONS .get (q_idx , {})
38+ if _is_question_applicable (q_cfg , wizard_responses ):
39+ return q_idx
40+ return None
41+
42+
43+ def _normalize_option_answer (answer : str , options : list [str ]):
44+ """Return canonical option from options if answer matches (case-insensitive)."""
45+ normalized = _normalize_answer (answer )
46+ for option in options :
47+ if normalized == _normalize_answer (option ):
48+ return option
49+ return answer
50+
51+
52+ def _validate_wizard_answer (question_cfg : dict , answer : str ):
53+ validation = question_cfg .get ("validation" )
54+ required = bool (question_cfg .get ("required" , False ))
55+ cleaned = (answer or "" ).strip ()
56+
57+ if not cleaned :
58+ if required :
59+ raise ValidationError ("Este campo es obligatorio." )
60+ return cleaned
61+
62+ if validation == "email" :
63+ validate_email (cleaned )
64+ return cleaned .lower ()
65+ if validation == "phone" :
66+ validate_phone (cleaned )
67+ return cleaned
68+ if validation == "ci" :
69+ validate_ci (cleaned )
70+ return cleaned
71+ if validation == "yes_no" :
72+ options = question_cfg .get ("options" , ["NO" , "SI" ])
73+ canonical = _normalize_option_answer (cleaned , options )
74+ if _normalize_answer (canonical ) not in {_normalize_answer (o ) for o in options }:
75+ raise ValidationError (f"Respuesta invalida. Opciones validas: { ', ' .join (options )} ." )
76+ return canonical
77+ if validation in {"campus" , "ucu_relation" , "faculty" , "discovery_method" , "project_stage" , "support_needed" }:
78+ options = question_cfg .get ("options" , [])
79+ if options :
80+ canonical = _normalize_option_answer (cleaned , options )
81+ if _normalize_answer (canonical ) not in {_normalize_answer (o ) for o in options }:
82+ raise ValidationError (f"Respuesta invalida. Opciones validas: { ', ' .join (options )} ." )
83+ return canonical
84+ return cleaned
85+ if validation == "text_min_length" :
86+ min_length = int (question_cfg .get ("min_length" , 1 ))
87+ if len (cleaned ) < min_length :
88+ raise ValidationError (f"La respuesta debe tener al menos { min_length } caracteres." )
89+ return cleaned
90+ if validation == "name" :
91+ if len (cleaned ) < 3 :
92+ raise ValidationError ("Ingresa nombre y apellido." )
93+ return cleaned
94+ if validation == "location" :
95+ if len (cleaned ) < 3 :
96+ raise ValidationError ("Ingresa pais y localidad." )
97+ return cleaned
98+ if validation in {"optional_text" , "rubrica" }:
99+ return cleaned
100+
101+ return cleaned
102+
103+
104+ def _get_current_or_next_applicable_question (current_q : int , wizard_responses : dict ):
105+ q_cfg = WIZARD_QUESTIONS .get (current_q , {})
106+ if q_cfg and _is_question_applicable (q_cfg , wizard_responses ):
107+ return current_q
108+ return _get_next_question_index (current_q - 1 , wizard_responses )
109+
110+
11111def ask_question_node (state : WizardState ):
12- i = state ["current_question" ]
112+ wizard_responses = dict (state .get ("wizard_responses" , {}))
113+ current_q = state ["current_question" ]
114+ i = _get_current_or_next_applicable_question (current_q , wizard_responses )
115+ if i is None :
116+ return {
117+ ** state ,
118+ "completed" : True ,
119+ "awaiting_answer" : False ,
120+ }
121+
13122 question = WIZARD_QUESTIONS [i ]["text" ]
14123 logger .debug ("-" * 60 )
15124 logger .debug (f"[WIZARD/ask_question] Asking question #{ i } " )
16125 logger .debug (f"[WIZARD/ask_question] Question type: { WIZARD_QUESTIONS [i ].get ('type' )} " )
17126 logger .debug (f"[WIZARD/ask_question] Question text: { question [:120 ]!r} ..." )
18127 return {
128+ ** state ,
19129 "messages" : [AIMessage (content = question )],
130+ "current_question" : i ,
20131 "awaiting_answer" : True ,
21132 }
22133
@@ -25,25 +136,37 @@ def store_answer_node(state: WizardState):
25136 user_message = [m .content for m in state ["messages" ] if m .type == "human" ][- 1 ]
26137
27138 current_q = state ["current_question" ]
28- new_index = current_q + 1
29- is_completed = new_index > max (WIZARD_QUESTIONS .keys ())
30-
31139 q_config = WIZARD_QUESTIONS .get (current_q , {})
32140 field_name = q_config .get ("field_name" , str (current_q ))
141+ normalized_answer = user_message
142+
143+ try :
144+ normalized_answer = _validate_wizard_answer (q_config , user_message )
145+ except ValidationError as exc :
146+ logger .debug (f"[WIZARD/store_answer] Validation failed for question #{ current_q } : { exc } " )
147+ return {
148+ ** state ,
149+ "messages" : [AIMessage (content = f"El dato no es valido: { exc } \n \n Intenta nuevamente." )],
150+ "awaiting_answer" : True ,
151+ "completed" : False ,
152+ }
33153
34154 wizard_responses = dict (state .get ("wizard_responses" , {}))
35- wizard_responses [field_name ] = user_message
155+ wizard_responses [field_name ] = normalized_answer
156+ next_q = _get_next_question_index (current_q , wizard_responses )
157+ is_completed = next_q is None
158+ new_index = next_q if next_q is not None else current_q
36159
37160 logger .debug ("-" * 60 )
38161 logger .debug (f"[WIZARD/store_answer] Storing answer for question #{ current_q } (field={ field_name !r} )" )
39- logger .debug (f"[WIZARD/store_answer] User answer: { user_message [:200 ]!r} " )
40- logger .debug (f"[WIZARD/store_answer] Next question index: { new_index } " )
162+ logger .debug (f"[WIZARD/store_answer] User answer: { normalized_answer [:200 ]!r} " )
163+ logger .debug (f"[WIZARD/store_answer] Next applicable question index: { new_index } " )
41164 logger .debug (f"[WIZARD/store_answer] Is completed: { is_completed } " )
42165 logger .debug (f"[WIZARD/store_answer] Total answers so far: { len (state .get ('answers' , []))} " )
43166
44167 return {
45168 ** state ,
46- "answers" : state .get ("answers" , []) + [user_message ],
169+ "answers" : state .get ("answers" , []) + [normalized_answer ],
47170 "wizard_responses" : wizard_responses ,
48171 "current_question" : new_index ,
49172 "completed" : is_completed ,
0 commit comments