Skip to content

Commit e27626a

Browse files
authored
Fix wizard end flow and backoffice payloads (#16)
Este PR corrige fallos al finalizar el wizard de postulación y estabiliza la integración con Backoffice. Se elimina el envío duplicado al cierre del wizard, dejando un único flujo de envío. Se normaliza el payload de caso para hacerlo más robusto (descripcion, consentimiento_datos, id_estado). Se agrega id_convocatoria opcional vía configuración. Se sanitiza datos_chatbot para evitar errores de serialización. Se actualiza .env.example con nuevas variables de Backoffice. Resultado: menos errores 500 al final del wizard y payloads consistentes tanto para has_idea=NO como para has_idea=SI.
1 parent e45ed2c commit e27626a

File tree

3 files changed

+124
-67
lines changed

3 files changed

+124
-67
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ BACKOFFICE_BASE_URL=http://localhost:8000
1717
BACKOFFICE_ADMIN_EMAIL=admin@ithaka.com
1818
BACKOFFICE_ADMIN_PASSWORD=admin123
1919
BACKOFFICE_INTEGRATION_ENABLED=true
20+
BACKOFFICE_DEFAULT_ID_ESTADO=1
2021

2122
# Other environment variables (add as needed)
2223
# TWILIO_ACCOUNT_SID=your_twilio_sid

app/agents/wizard_node.py

Lines changed: 32 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
WizardAgent -- routable node that guides users through the postulación flow.
2+
WizardAgent -- routable node that guides users through the postulacion flow.
33
44
Wraps the wizard sub-graph (wizard_workflow/wizard_graph) behind the
55
standard AgentNode interface so the supervisor can route to it by
@@ -13,14 +13,14 @@
1313
import yaml
1414

1515
from .base import AgentNode
16-
from ..graph.state import ConversationState
17-
from .wizard_workflow.wizard_graph import wizard_graph
1816
from ..db.config.database import SessionLocal
19-
from ..services import conversation_service, postulation_service
17+
from ..graph.state import ConversationState
18+
from ..services import conversation_service
2019
from ..services.backoffice_service import (
2120
BackofficeIntegrationDisabled,
2221
send_postulation_to_backoffice,
2322
)
23+
from .wizard_workflow.wizard_graph import wizard_graph
2424

2525
logger = logging.getLogger(__name__)
2626

@@ -58,40 +58,28 @@ async def __call__(self, state: ConversationState) -> ConversationState:
5858
wizard_state = dict(wizard_state)
5959
wizard_state["messages"] = state.get("messages", [])
6060

61-
logger.debug(f"[WIZARD_NODE] Wizard state before sub-graph invoke:")
62-
logger.debug(f"[WIZARD_NODE] session_id={wizard_state.get('wizard_session_id')}")
63-
logger.debug(f"[WIZARD_NODE] current_question={wizard_state.get('current_question')}")
64-
logger.debug(f"[WIZARD_NODE] wizard_status={wizard_state.get('wizard_status')}")
65-
logger.debug(f"[WIZARD_NODE] awaiting_answer={wizard_state.get('awaiting_answer')}")
66-
logger.debug(f"[WIZARD_NODE] completed={wizard_state.get('completed')}")
67-
logger.debug(f"[WIZARD_NODE] answers count={len(wizard_state.get('answers', []))}")
68-
logger.debug(f"[WIZARD_NODE] messages count={len(wizard_state.get('messages', []))}")
69-
for i, m in enumerate(wizard_state.get("messages", [])):
70-
logger.debug(f"[WIZARD_NODE] msg[{i}] type={m.type} content={m.content[:100]!r}...")
61+
logger.debug("[WIZARD_NODE] Wizard state before sub-graph invoke:")
62+
logger.debug("[WIZARD_NODE] session_id=%s", wizard_state.get("wizard_session_id"))
63+
logger.debug("[WIZARD_NODE] current_question=%s", wizard_state.get("current_question"))
64+
logger.debug("[WIZARD_NODE] wizard_status=%s", wizard_state.get("wizard_status"))
65+
logger.debug("[WIZARD_NODE] awaiting_answer=%s", wizard_state.get("awaiting_answer"))
66+
logger.debug("[WIZARD_NODE] completed=%s", wizard_state.get("completed"))
67+
logger.debug("[WIZARD_NODE] answers count=%d", len(wizard_state.get("answers", [])))
68+
logger.debug("[WIZARD_NODE] messages count=%d", len(wizard_state.get("messages", [])))
69+
for i, msg in enumerate(wizard_state.get("messages", [])):
70+
logger.debug("[WIZARD_NODE] msg[%d] type=%s content=%r...", i, msg.type, msg.content[:100])
7171

7272
result = await wizard_graph.ainvoke(wizard_state)
7373

7474
response_content = (
75-
result.get("messages", [])[-1].content
76-
if result.get("messages")
77-
else ""
75+
result.get("messages", [])[-1].content if result.get("messages") else ""
7876
)
7977

80-
logger.debug(f"[WIZARD_NODE] Sub-graph result:")
81-
logger.debug(f"[WIZARD_NODE] current_question={result.get('current_question')}")
82-
logger.debug(f"[WIZARD_NODE] completed={result.get('completed')}")
83-
logger.debug(f"[WIZARD_NODE] wizard_status={result.get('wizard_status')}")
84-
logger.debug(f"[WIZARD_NODE] response (first 200 chars): {response_content[:200]!r}")
85-
86-
# --- Envío a API externa ---
87-
if result.get("completed"):
88-
try:
89-
submission = await postulation_service.submit_postulation(
90-
result.get("wizard_responses", {})
91-
)
92-
logger.info(f"[WIZARD_NODE] Postulation submitted to external API: {submission}")
93-
except Exception as e:
94-
logger.error(f"[WIZARD_NODE] Error submitting postulation to external API: {e}", exc_info=True)
78+
logger.debug("[WIZARD_NODE] Sub-graph result:")
79+
logger.debug("[WIZARD_NODE] current_question=%s", result.get("current_question"))
80+
logger.debug("[WIZARD_NODE] completed=%s", result.get("completed"))
81+
logger.debug("[WIZARD_NODE] wizard_status=%s", result.get("wizard_status"))
82+
logger.debug("[WIZARD_NODE] response (first 200 chars): %r", response_content[:200])
9583

9684
# --- Persistencia en DB ---
9785
conv_id = state.get("conversation_id")
@@ -133,41 +121,41 @@ async def __call__(self, state: ConversationState) -> ConversationState:
133121
except Exception:
134122
await db_session.rollback()
135123
raise
136-
except Exception as e:
137-
logger.error(f"[WIZARD_NODE] Error al persistir en DB: {e}", exc_info=True)
124+
except Exception as exc:
125+
logger.error("[WIZARD_NODE] Error al persistir en DB: %s", exc, exc_info=True)
138126

139127
# --- Enviar al Backoffice API cuando el wizard termina ---
140128
logger.info(
141-
"[WIZARD_NODE] Evaluando envío al Backoffice: completed=%s, has_wizard_responses=%s, email=%s",
129+
"[WIZARD_NODE] Evaluando envio al Backoffice: completed=%s, has_wizard_responses=%s, email=%s",
142130
bool(result.get("completed")),
143131
bool(wizard_responses),
144132
email,
145133
)
146134

147135
if result.get("completed") and wizard_responses:
148136
logger.info(
149-
"[WIZARD_NODE] Iniciando envío al Backoffice para email=%s con %d campos en wizard_responses",
137+
"[WIZARD_NODE] Iniciando envio al Backoffice para email=%s con %d campos en wizard_responses",
150138
email,
151139
len(wizard_responses),
152140
)
153141
try:
154142
id_emp, id_caso = await send_postulation_to_backoffice(wizard_responses)
155143
logger.info(
156-
"[WIZARD_NODE] Postulación enviada al backoffice: id_emprendedor=%s, id_caso=%s",
144+
"[WIZARD_NODE] Postulacion enviada al backoffice: id_emprendedor=%s, id_caso=%s",
157145
id_emp,
158146
id_caso,
159147
)
160148
except BackofficeIntegrationDisabled:
161-
logger.debug("[WIZARD_NODE] Integración backoffice desactivada, omitiendo envío.")
162-
except Exception as e:
149+
logger.debug("[WIZARD_NODE] Integracion backoffice desactivada, omitiendo envio.")
150+
except Exception as exc:
163151
logger.error(
164-
"[WIZARD_NODE] Error al enviar postulación al backoffice: %s",
165-
e,
152+
"[WIZARD_NODE] Error al enviar postulacion al backoffice: %s",
153+
exc,
166154
exc_info=True,
167155
)
168156
else:
169157
logger.debug(
170-
"[WIZARD_NODE] No se envía al Backoffice: completed=%s, has_wizard_responses=%s",
158+
"[WIZARD_NODE] No se envia al Backoffice: completed=%s, has_wizard_responses=%s",
171159
bool(result.get("completed")),
172160
bool(wizard_responses),
173161
)
@@ -176,9 +164,7 @@ async def __call__(self, state: ConversationState) -> ConversationState:
176164
**state,
177165
"wizard_state": result,
178166
"messages": result.get("messages", []),
179-
"agent_context": {
180-
"response": response_content
181-
},
167+
"agent_context": {"response": response_content},
182168
"conversation_id": conv_id,
183169
}
184170

@@ -191,5 +177,5 @@ async def __call__(self, state: ConversationState) -> ConversationState:
191177

192178

193179
async def handle_wizard_flow(state: ConversationState) -> ConversationState:
194-
"""Función wrapper para LangGraph."""
180+
"""Funcion wrapper para LangGraph."""
195181
return await wizard_agent(state)

app/services/backoffice_service.py

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
Usado cuando el wizard de postulación termina (estado COMPLETED).
66
"""
77

8+
import json
89
import logging
910
import os
1011
from typing import Any
@@ -20,6 +21,21 @@
2021
BACKOFFICE_ADMIN_PASSWORD = os.getenv("BACKOFFICE_ADMIN_PASSWORD", "admin123")
2122

2223

24+
def _read_default_id_estado() -> int:
25+
raw = (os.getenv("BACKOFFICE_DEFAULT_ID_ESTADO") or "1").strip()
26+
try:
27+
return int(raw)
28+
except ValueError:
29+
logger.warning(
30+
"[BACKOFFICE] BACKOFFICE_DEFAULT_ID_ESTADO invalido=%r, se usa 1 por defecto.",
31+
raw,
32+
)
33+
return 1
34+
35+
36+
BACKOFFICE_DEFAULT_ID_ESTADO = _read_default_id_estado()
37+
38+
2339
def _parse_full_name(full_name: str) -> tuple[str, str]:
2440
"""Convierte 'Apellido, Nombre' o 'Nombre Apellido' en (nombre, apellido)."""
2541
if not (full_name or full_name.strip()):
@@ -38,7 +54,7 @@ def _parse_full_name(full_name: str) -> tuple[str, str]:
3854

3955
def _parse_location(location: str) -> tuple[str, str]:
4056
"""Intenta extraer país y ciudad de un string 'País, Ciudad' o similar."""
41-
if not (location or location.strip()):
57+
if not location or not location.strip():
4258
return "", ""
4359
s = location.strip()
4460
if "," in s:
@@ -47,6 +63,59 @@ def _parse_location(location: str) -> tuple[str, str]:
4763
return s, ""
4864

4965

66+
def _resolve_id_convocatoria(id_convocatoria: int | None) -> int | None:
67+
"""Resolve id_convocatoria from argument or env, if present and valid."""
68+
if id_convocatoria is not None:
69+
return id_convocatoria
70+
71+
raw = (os.getenv("BACKOFFICE_ID_CONVOCATORIA") or "").strip()
72+
if not raw:
73+
return None
74+
75+
try:
76+
return int(raw)
77+
except ValueError:
78+
logger.warning(
79+
"[BACKOFFICE] BACKOFFICE_ID_CONVOCATORIA invalido=%r, se ignora.",
80+
raw,
81+
)
82+
return None
83+
84+
85+
def _build_caso_description(wizard_responses: dict[str, Any]) -> str:
86+
"""Build a non-empty case description from wizard fields."""
87+
desc_parts = []
88+
if wizard_responses.get("problem_description"):
89+
desc_parts.append(f"Problema: {str(wizard_responses.get('problem_description'))[:1000]}")
90+
if wizard_responses.get("solution_description"):
91+
desc_parts.append(f"Solucion: {str(wizard_responses.get('solution_description'))[:1000]}")
92+
if wizard_responses.get("motivation"):
93+
desc_parts.append(f"Motivacion: {str(wizard_responses.get('motivation'))[:500]}")
94+
if wizard_responses.get("additional_comments"):
95+
desc_parts.append(f"Comentarios: {str(wizard_responses.get('additional_comments'))[:1000]}")
96+
97+
if desc_parts:
98+
return "\n\n".join(desc_parts)
99+
return "Postulacion generada desde ChatBot."
100+
101+
102+
def _sanitize_chatbot_data(wizard_responses: dict[str, Any]) -> dict[str, Any]:
103+
"""Keep JSON-serializable values for datos_chatbot."""
104+
sanitized: dict[str, Any] = {}
105+
for key, value in (wizard_responses or {}).items():
106+
if value is None or value == "":
107+
continue
108+
if isinstance(value, (str, int, float, bool, list, dict)):
109+
sanitized[key] = value
110+
continue
111+
try:
112+
json.dumps(value)
113+
sanitized[key] = value
114+
except (TypeError, ValueError):
115+
sanitized[key] = str(value)
116+
return sanitized
117+
118+
50119
def build_emprendedor_payload(wizard_responses: dict[str, Any]) -> dict[str, Any]:
51120
"""Mapea wizard_responses al body de POST /api/v1/emprendedores/."""
52121
full_name = (wizard_responses.get("full_name") or "").strip()
@@ -97,8 +166,8 @@ def build_caso_payload(
97166
id_convocatoria: int | None = None,
98167
) -> dict[str, Any]:
99168
"""Mapea wizard_responses al body de POST /api/v1/casos/."""
100-
# nombre_caso: usar problema/idea si existe, sino nombre genérico
101-
nombre_caso = "Postulación desde ChatBot"
169+
# nombre_caso: usar problema/idea si existe, sino nombre generico
170+
nombre_caso = "Postulacion desde ChatBot"
102171
if wizard_responses.get("problem_description"):
103172
raw = str(wizard_responses.get("problem_description"))[:200]
104173
nombre_caso = raw if raw else nombre_caso
@@ -109,26 +178,25 @@ def build_caso_payload(
109178
payload: dict[str, Any] = {
110179
"nombre_caso": nombre_caso[:200],
111180
"id_emprendedor": id_emprendedor,
181+
"descripcion": _build_caso_description(wizard_responses),
182+
"consentimiento_datos": True,
183+
"id_estado": BACKOFFICE_DEFAULT_ID_ESTADO,
112184
}
113-
# descripcion: resumen opcional
114-
desc_parts = []
115-
if wizard_responses.get("solution_description"):
116-
desc_parts.append(str(wizard_responses.get("solution_description"))[:1000])
117-
if wizard_responses.get("motivation"):
118-
desc_parts.append(f"Motivación: {str(wizard_responses.get('motivation'))[:500]}")
119-
if desc_parts:
120-
payload["descripcion"] = "\n\n".join(desc_parts)
121-
# datos_chatbot: respuestas estructuradas del wizard (recomendado por la integración)
122-
datos: dict[str, Any] = {k: v for k, v in wizard_responses.items() if v is not None and v != ""}
185+
186+
# datos_chatbot: respuestas estructuradas del wizard (recomendado por la integracion)
187+
datos = _sanitize_chatbot_data(wizard_responses)
123188
if datos:
124189
payload["datos_chatbot"] = datos
125-
if id_convocatoria is not None:
126-
payload["id_convocatoria"] = id_convocatoria
190+
191+
resolved_convocatoria = _resolve_id_convocatoria(id_convocatoria)
192+
if resolved_convocatoria is not None:
193+
payload["id_convocatoria"] = resolved_convocatoria
127194

128195
logger.debug(
129-
"[BACKOFFICE] build_caso_payload: id_emprendedor=%s, id_convocatoria=%s, keys=%s",
196+
"[BACKOFFICE] build_caso_payload: id_emprendedor=%s, id_convocatoria=%s, id_estado=%s, keys=%s",
130197
id_emprendedor,
131-
id_convocatoria,
198+
resolved_convocatoria,
199+
payload.get("id_estado"),
132200
list(payload.keys()),
133201
)
134202

@@ -172,12 +240,14 @@ async def send_postulation_to_backoffice(
172240
"""
173241
Ejecuta el flujo: login -> crear emprendedor -> crear caso.
174242
Devuelve (id_emprendedor, id_caso).
175-
Lanza excepción si la API no está configurada o falla.
243+
Lanza excepcion si la API no esta configurada o falla.
176244
"""
245+
resolved_convocatoria = _resolve_id_convocatoria(id_convocatoria)
246+
177247
logger.info(
178248
"[BACKOFFICE] send_postulation_to_backoffice llamado: base_url=%s, id_convocatoria=%s, total_campos_respuestas=%s",
179249
BACKOFFICE_BASE_URL,
180-
id_convocatoria,
250+
resolved_convocatoria,
181251
len(wizard_responses or {}),
182252
)
183253
disabled = os.getenv("BACKOFFICE_INTEGRATION_ENABLED", "true").lower() in ("0", "false", "no")
@@ -192,7 +262,7 @@ async def send_postulation_to_backoffice(
192262
async with aiohttp.ClientSession() as session:
193263
token = await _get_access_token(session)
194264
headers["Authorization"] = f"Bearer {token}"
195-
logger.debug("[BACKOFFICE] Autenticado contra Backoffice, iniciando creación de emprendedor.")
265+
logger.debug("[BACKOFFICE] Autenticado contra Backoffice, iniciando creacion de emprendedor.")
196266

197267
# 1) Crear emprendedor
198268
emprendedor_body = build_emprendedor_payload(wizard_responses)
@@ -223,7 +293,7 @@ async def send_postulation_to_backoffice(
223293
logger.info("[BACKOFFICE] Emprendedor creado id_emprendedor=%s", id_emprendedor)
224294

225295
# 2) Crear caso
226-
caso_body = build_caso_payload(wizard_responses, id_emprendedor, id_convocatoria)
296+
caso_body = build_caso_payload(wizard_responses, id_emprendedor, resolved_convocatoria)
227297
url_caso = f"{BACKOFFICE_BASE_URL}{BACKOFFICE_API_PREFIX}/casos/"
228298
logger.debug(
229299
"[BACKOFFICE] POST /casos: url=%s, payload_keys=%s",

0 commit comments

Comments
 (0)