Skip to content

Commit c9d848a

Browse files
committed
conditionals implemented
1 parent 6e5ae40 commit c9d848a

File tree

7 files changed

+250
-32
lines changed

7 files changed

+250
-32
lines changed

app/agents/validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from .base import AgentNode
1919
from ..graph.state import ConversationState
20-
from utils.validators import (
20+
from ..utils.validators import (
2121
ValidationError,
2222
validate_ci,
2323
validate_email,

app/agents/wizard_workflow/nodes.py

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,130 @@
44

55
from app.config.questions import WIZARD_QUESTIONS
66
from app.graph.state import WizardState
7+
from app.utils.validators import ValidationError, validate_ci, validate_email, validate_phone
78

89
logger = 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+
11111
def 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\nIntenta 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,

app/agents/wizard_workflow/wizard_graph.py

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,35 @@
11
import logging
22

3-
from langgraph.graph import StateGraph, END
43
from langchain_core.messages import AIMessage
4+
from langgraph.graph import END, StateGraph
55

66
from app.agents.wizard_workflow.nodes import ask_question_node, store_answer_node
77
from app.graph.state import WizardState
88

99
logger = logging.getLogger(__name__)
1010

11+
1112
def should_continue_after_store(state: WizardState) -> str:
12-
"""Decide si continuar con el wizard o terminar después de guardar respuesta"""
13+
"""Decide si continuar con el wizard o terminar despues de guardar respuesta."""
14+
if state.get("awaiting_answer", False):
15+
logger.debug("[WIZARD_GRAPH] should_continue_after_store: awaiting_answer=True -> finish")
16+
return "finish"
17+
1318
completed = state.get("completed", False)
1419
decision = "completion_message" if completed else "ask_question"
1520
logger.debug(f"[WIZARD_GRAPH] should_continue_after_store: completed={completed} -> {decision}")
1621
return decision
1722

23+
1824
def should_ask_or_store(state: WizardState) -> str:
19-
"""Decide si hacer pregunta o guardar respuesta basado en si hay respuesta del usuario"""
25+
"""Decide si hacer pregunta o guardar respuesta basado en si hay respuesta del usuario."""
2026
# If the wizard hasn't asked a question yet in this turn, never treat an
21-
# existing human message as an answer just ask the first question.
27+
# existing human message as an answer; just ask the first question.
2228
if not state.get("awaiting_answer", False):
2329
logger.debug("[WIZARD_GRAPH] should_ask_or_store: awaiting_answer=False -> ask_question")
2430
return "ask_question"
2531

2632
messages = state.get("messages", [])
27-
2833
if not messages:
2934
logger.debug("[WIZARD_GRAPH] should_ask_or_store: no messages -> ask_question")
3035
return "ask_question"
@@ -33,65 +38,77 @@ def should_ask_or_store(state: WizardState) -> str:
3338
last_msg_content = messages[-1].content[:80] if messages[-1].content else "(empty)"
3439

3540
if last_msg_type == "ai":
36-
logger.debug(f"[WIZARD_GRAPH] should_ask_or_store: last msg is AI -> ask_question "
37-
f"(content={last_msg_content!r})")
41+
logger.debug(
42+
f"[WIZARD_GRAPH] should_ask_or_store: last msg is AI -> ask_question "
43+
f"(content={last_msg_content!r})"
44+
)
3845
return "ask_question"
3946

4047
if last_msg_type == "human":
41-
logger.debug(f"[WIZARD_GRAPH] should_ask_or_store: last msg is human -> store_answer "
42-
f"(content={last_msg_content!r})")
48+
logger.debug(
49+
f"[WIZARD_GRAPH] should_ask_or_store: last msg is human -> store_answer "
50+
f"(content={last_msg_content!r})"
51+
)
4352
return "store_answer"
4453

4554
logger.debug(f"[WIZARD_GRAPH] should_ask_or_store: unknown msg type={last_msg_type!r} -> ask_question")
4655
return "ask_question"
4756

57+
4858
def completion_message_node(state: WizardState):
49-
"""Nodo que genera el mensaje de finalización del wizard"""
59+
"""Nodo que genera el mensaje de finalizacion del wizard."""
5060
logger.debug("[WIZARD_GRAPH] completion_message_node: generating completion message")
51-
completion_msg = "¡Muchas gracias por completar el formulario de postulación de Ithaka! 🎉\n\nHemos registrado todas tus respuestas. Nuestro equipo revisará tu postulación y te contactaremos a la brevedad.\n\n¡Esperamos poder acompañarte en tu emprendimiento!"
61+
completion_msg = (
62+
"Muchas gracias por completar el formulario de postulacion de Ithaka!\n\n"
63+
"Hemos registrado todas tus respuestas. Nuestro equipo revisara tu postulacion "
64+
"y te contactara a la brevedad.\n\n"
65+
"Esperamos poder acompanarte en tu emprendimiento!"
66+
)
5267

5368
return {
5469
**state,
5570
"messages": [AIMessage(content=completion_msg)],
56-
"wizard_status": "COMPLETED"
71+
"wizard_status": "COMPLETED",
5772
}
5873

74+
5975
builder = StateGraph(WizardState)
6076

6177
# Agregar nodos
6278
builder.add_node("ask_question", ask_question_node)
6379
builder.add_node("store_answer", store_answer_node)
64-
builder.add_node("completion_message", completion_message_node) # Nuevo nodo
65-
builder.add_node("finish", lambda state: {**state, "completed": True})
80+
builder.add_node("completion_message", completion_message_node)
81+
builder.add_node("finish", lambda state: {**state, "completed": state.get("completed", False)})
6682

6783
# Punto de entrada condicional
6884
builder.set_entry_point("entry")
69-
builder.add_node("entry", lambda state: state) # Nodo dummy para decidir flujo inicial
85+
builder.add_node("entry", lambda state: state)
7086

7187
# Desde entry, decidir si hacer pregunta o guardar respuesta
7288
builder.add_conditional_edges(
7389
"entry",
7490
should_ask_or_store,
7591
{
7692
"ask_question": "ask_question",
77-
"store_answer": "store_answer"
78-
}
93+
"store_answer": "store_answer",
94+
},
7995
)
8096

81-
# Después de hacer pregunta, terminar (esperar respuesta del usuario)
97+
# Despues de hacer pregunta, terminar (esperar respuesta del usuario)
8298
builder.add_edge("ask_question", "finish")
8399

84-
# Después de guardar respuesta, decidir si continuar o mostrar mensaje final
100+
# Despues de guardar respuesta, decidir si continuar o mostrar mensaje final
85101
builder.add_conditional_edges(
86102
"store_answer",
87103
should_continue_after_store,
88104
{
89105
"ask_question": "ask_question",
90-
"completion_message": "completion_message" # Ir a mensaje final
91-
}
106+
"completion_message": "completion_message",
107+
"finish": "finish",
108+
},
92109
)
93110

94-
# Después del mensaje de finalización, terminar
111+
# Despues del mensaje de finalizacion, terminar
95112
builder.add_edge("completion_message", "finish")
96113

97114
# finish termina el flujo

0 commit comments

Comments
 (0)