Taller 1 - jalvarez01/HanZi-Mentor GitHub Wiki
Módulo: Creación de Sesión de Estudio Adaptativa
Servicio: services/agente-tutor
Taller 01 — Refactorización Arquitectónica
La creación de una sesión de práctica era el flujo más enredado del proyecto. Todo ocurría dentro de la vista:
- Consultar el progreso del usuario en base de datos.
- Decidir la dificultad con una cadena de
if/elsesobre la tasa de acierto. - Verificar que el nivel HSK solicitado estuviera desbloqueado.
- Elegir caracteres de refuerzo ordenando manualmente un diccionario de errores.
- Elegir caracteres nuevos según el nivel.
- Crear la sesión, crear cada ejercicio uno por uno.
- Enviar la notificación al usuario.
Consecuencias concretas: la vista superaba las 80 líneas, no se podía testear sin base de datos ni sin gastar llamadas al modelo de IA, y cambiar de "mandar correo real" a "solo imprimir en consola" implicaba comentar líneas a mano antes de cada demo.
sequenceDiagram
participant C as Cliente (PWA)
participant V as CrearSesionEstudioView
participant S as SesionEstudioService
participant F as Factories
participant M as MotorTutor
participant B as SesionEstudioBuilder
participant N as Notificador
C->>V: POST /api/sesiones/
V->>V: Serializer valida forma del request
V->>S: crear_sesion_adaptativa(usuario, nivel)
S->>F: MotorTutorFactory.crear()
F-->>S: MotorTutorMock | MotorTutorLangGraph
S->>F: NotificadorFactory.crear()
F-->>S: NotificadorConsola | NotificadorEmail
S->>M: sugerir_dificultad(progreso)
M-->>S: dificultad
S->>B: para_usuario().en_nivel().con_dificultad()
S->>B: agregar_refuerzos().agregar_contenido_nuevo()
S->>B: build()
B->>B: validar invariantes
B-->>S: SesionEstudio persistida
S->>N: sesion_lista(usuario, sesion)
S-->>V: sesion
V-->>C: 201 Created
classDiagram
class CrearSesionEstudioView {
-service: SesionEstudioService
+post(request) Response
}
class SesionEstudioService {
-_motor: MotorTutor
-_notificador: Notificador
-_progreso: ProgresoRepository
+crear_sesion_adaptativa(usuario, nivel, duracion)
}
class SesionEstudioBuilder {
-_ejercicios: list
+para_usuario() self
+en_nivel() self
+con_dificultad() self
+agregar_refuerzos() self
+agregar_contenido_nuevo() self
-_validar()
+build() SesionEstudio
}
class MotorTutorFactory {
+crear()$ MotorTutor
}
class NotificadorFactory {
+crear()$ Notificador
}
class MotorTutor {
<<abstract>>
+sugerir_dificultad(progreso)*
+seleccionar_caracteres_nuevos(progreso, n)*
}
class Notificador {
<<abstract>>
+sesion_lista(usuario, sesion)*
}
CrearSesionEstudioView --> SesionEstudioService
SesionEstudioService --> SesionEstudioBuilder
SesionEstudioService --> MotorTutorFactory
SesionEstudioService --> NotificadorFactory
MotorTutorFactory ..> MotorTutor
NotificadorFactory ..> Notificador
MotorTutor <|-- MotorTutorMock
MotorTutor <|-- MotorTutorLangGraph
Notificador <|-- NotificadorConsola
Notificador <|-- NotificadorEmail
| Capa | Archivo | Responsabilidad |
|---|---|---|
| Interfaz | tutor/views.py |
Capturar el request y delegar. 12 líneas de cuerpo. |
| Aplicación | tutor/services.py |
Algoritmo de negocio. Orquesta Builder y Factory. |
| Dominio | tutor/domain/builders.py |
Construcción válida de la sesión (Builder). |
| Dominio | tutor/domain/exceptions.py |
Errores de negocio, independientes de HTTP. |
| Infraestructura | tutor/infra/factories.py |
Decide qué implementación concreta se usa. |
| Infraestructura | tutor/infra/motores.py |
Motor de tutoría: mock y real. |
| Infraestructura | tutor/infra/notificadores.py |
Notificación: consola y correo. |
| Datos | tutor/repositories.py |
Aísla el acceso a ProgresoUsuario. |
MotorTutorFactory y NotificadorFactory concentran toda la decisión sobre qué implementación instanciar. El cambio de comportamiento se controla desde variables de entorno, sin tocar una línea de código:
# .env — desarrollo
TUTOR_ENGINE=MOCK
NOTIFICADOR=CONSOLE
# .env — producción
TUTOR_ENGINE=REAL
NOTIFICADOR=EMAIL
[email protected]# infra/factories.py
class MotorTutorFactory:
@staticmethod
def crear() -> MotorTutor:
modo = os.getenv("TUTOR_ENGINE", "MOCK").upper()
if modo == "REAL":
from ..agent.graph import construir_agente
return MotorTutorLangGraph(agente=construir_agente())
return MotorTutorMock()Ganancia medible: correr la suite de tests dejó de costar llamadas a la API del modelo, y las demos en clase no dependen de que haya conexión a internet.
SesionEstudioBuilder usa Fluent Interface (cada método devuelve self) y garantiza la invariante central del dominio: nada se persiste hasta que la sesión sea válida.
# services.py — así se lee el algoritmo de negocio
sesion = (
SesionEstudioBuilder()
.para_usuario(usuario_id, progreso["nivel_max_desbloqueado"])
.en_nivel(nivel_hsk)
.con_dificultad(dificultad)
.con_duracion(duracion_min)
.agregar_refuerzos(refuerzos)
.agregar_contenido_nuevo(nuevos)
.build()
)build() ejecuta _validar() antes de tocar la base de datos y está envuelto en @transaction.atomic, de modo que una sesión sin sus ejercicios nunca puede quedar guardada a medias. Las reglas verificadas:
- La sesión tiene usuario y dificultad definidos.
- El nivel HSK está entre 1 y 6.
- El nivel solicitado está desbloqueado para ese usuario.
- Hay entre 3 y 20 ejercicios.
Responsabilidad Única. La vista traduce HTTP. El servicio decide el flujo. El builder construye. Las factories eligen. Cada archivo tiene una razón para cambiar.
Abierto/Cerrado. Agregar un NotificadorPush no requiere modificar el servicio: se crea la clase y se añade una rama en la factory.
Sustitución de Liskov. MotorTutorMock y MotorTutorLangGraph cumplen el mismo contrato MotorTutor; el servicio funciona igual con cualquiera.
Segregación de Interfaces. MotorTutor y Notificador son contratos mínimos, de dos y un método respectivamente. Nadie implementa métodos que no usa.
Inversión de Dependencias. El servicio depende de las abstracciones MotorTutor y Notificador, nunca de MotorTutorLangGraph o NotificadorEmail directamente. El constructor acepta las dependencias inyectadas:
def __init__(self, motor=None, notificador=None, progreso_repo=None):
self._motor = motor or MotorTutorFactory.crear()
self._notificador = notificador or NotificadorFactory.crear()
self._progreso = progreso_repo or ProgresoRepository()La inyección de dependencias permite probar el algoritmo completo con dobles, sin IA ni correos:
service = SesionEstudioService(
motor=MotorFalso(),
notificador=NotificadorEspia(),
progreso_repo=ProgresoFalso(),
)Casos cubiertos en tutor/tests.py:
- La sesión creada incluye ejercicios de refuerzo antes que contenido nuevo.
- El notificador se invoca exactamente una vez por sesión.
- Solicitar un nivel bloqueado lanza
NivelNoPermitidoErrory no persiste nada. - El builder rechaza sesiones con menos de tres ejercicios.
python manage.py test tutorPor qué Builder y no un constructor con parámetros. Una sesión se arma en varios pasos condicionales: puede tener refuerzos o no, contenido nuevo o no, dificultad calculada o forzada. Un constructor con siete argumentos opcionales sería ilegible y no podría validar el conjunto completo antes de guardar.
Por qué Factory y no inyectar desde settings.py. La factory concentra la decisión en un punto y permite construcción condicional real (el import de LangGraph solo ocurre si el modo es REAL, lo que evita cargar dependencias pesadas en desarrollo).
Costo asumido. La refactorización pasó de un archivo a ocho, y para un flujo trivial esto sería sobreingeniería. Se justifica aquí porque este es el flujo que más va a cambiar durante el semestre: el motor de tutoría todavía está en diseño y las reglas de dificultad se van a ajustar con datos de uso real.
Repository como capa extra. No lo pedía el taller, pero sin él el servicio quedaba acoplado al ORM de Django y los tests exigían base de datos. Con ProgresoRepository el algoritmo se prueba en memoria.
services/agente-tutor/tutor/
├── views.py # CBV delgada
├── services.py # Service Layer
├── repositories.py # Acceso a datos
├── serializers.py # Validación de forma
├── urls.py
├── models.py
├── tests.py
├── domain/
│ ├── builders.py # Patrón Builder
│ └── exceptions.py
└── infra/
├── factories.py # Patrón Factory
├── motores.py # MOCK | REAL
└── notificadores.py # CONSOLE | EMAIL