v2 — Cursos dinámicos
Estado: spec de implementación. Rama
feat/dynamic-courses.Prioridad documental:
v1-scope.mdsigue siendo la verdad del camino v1. Este documento define el camino v2 y sólo aplica cuando el feature flag está activo. Donde este documento contradice aarchitecture.md,content-generation.mdoscreens.md, gana este documento para el camino v2; el camino v1 no se toca.Requisito duro: el camino estático de v1 debe seguir funcionando para cualquier curso que no haya optado por v2. Ningún cambio de este documento puede alterar el comportamiento de
GET /api/v1/courses/{id}, del pipelinebuild_content_graph()ni del render markdown existente para un curso cuyodelivery_modeno seadynamic(ver §10).Arquitectura episódica vigente: la separación entre constitución persistida del curso,
EpisodeBriefgenerado on-the-fly, selección neutral de capacidades,LearningExperiencey evidencia server-owned se especifica enlearning-experience-architecture.md. Este documento sigue describiendo persistencia, entrega y compatibilidad del runtime v2. Las secciones que prescribenScreenScheme, una fórmula fija de pantalla o agrupación por pasos aplican sólo al fallbacklegacy_stepper; no restringen el caminoepisode.shell_modelo decide el servidor. La migración conservasrc.services.course_delivery.resolve_deliverycomo único selector v1/v2.
1. Objetivo y alcance
1.1 El cambio
Hoy un curso se genera “del tirón”: el admin sube un documento, un grafo LangGraph de 7 nodos escribe todo el curso en Markdown, y todos los empleados leen exactamente el mismo texto.
v2 parte la generación en dos tiempos:
| Tiempo | Quién | Qué produce | Cuándo |
|---|---|---|---|
| Design-time | Admin/creador | Un esquema: título, outcome y una lista de nodos (competencias) con criticidad, prerrequisitos y fuente asociada. Cero contenido. | Al crear el curso |
| Runtime | Sistema, por empleado | Para cada nodo NO dominado: un UI spec generado al vuelo desde el nodo + el perfil del aprendiz + la fuente | Cuando el empleado abre el nodo |
Entre los dos tiempos hay un gate humano bloqueante: el creador valida el esquema. Sin
schema_status = 'validated' no se genera nada para ningún empleado.
1.2 Entra en este PR (vertical slice completo)
- Esquema de curso — tabla de nodos con criticidad y prerrequisitos, generación del esquema propuesto por LLM, edición por el creador, validación bloqueante.
- Onboarding — 4 preguntas + 1 opcional, ≤90 s, que siembran el perfil del aprendiz.
- Perfil del aprendiz — perfil declarado (rol, objetivo, experiencia, preset), vector implícito de formato, estado por nodo (maestría, rachas, tipo de último error).
- Pre-assessment por nodo — 2 ítems + desempate, con una regla de maestría computable y umbral por criticidad.
- Generación on-the-fly por nodo — grafo LangGraph nuevo (
decide_formato→genera_ui→validate_ui→persist_render), con router de dos niveles de modelo. - Capa de render con adaptador — una IR canónica (
ui_specjsonb) + un backend de dialecto, OpenUI Lang, detrás de unProtocolcon registro. El seam del adaptador entra; el segundo dialecto no (ver §1.3 y §5.4). - Ajuste del perfil — servicio determinista que actualiza maestría, vector y notas del tutor tras cada respuesta.
- Clic-para-explicar (Curio) — cualquier palabra o selección dentro de un nodo abre una explicación contextual en línea, con caché de servidor.
- Latencia — skeleton, espera productiva solapada con el pre-assessment, streaming SSE de bloques, caché por bucket de perfil.
- Migraciones Alembic (
0005), tests con fixtures grabadas, y esta documentación.
1.3 NO entra (backlog explícito, no “TBD”)
| Fuera | Motivo |
|---|---|
SandboxHTML / HTML libre generado por el LLM |
Vector XSS directo y 12-65 % del código generado con vulnerabilidades. El patrón es prompt → IR tipada → render nativo. Se reevalúa cuando exista sandbox de iframe auditado |
Componente Simulation (parámetros ajustables) |
Requiere data-binding y ciclo de vida que la IR no tiene. El valor simulation existe en el enum ui_format reservado, pero decide_formato no lo emite (constante ALLOWED_UI_FORMATS) |
| Repetición espaciada (HLR o FSRS) | Corrección de premisa: no existe hoy. spaced_repetition/HLR aparece en data-model.md, product.md y background-processing.md, pero no hay tabla ni módulo en el repo (verificado: 20 tablas, ningún half_life/next_review en src/). Este PR no lo introduce. Consecuencia directa: el estado needs_review no lo produce ningún scheduler — su único productor en este PR es el tope de pistas (§7.4) |
Segundo backend de dialecto (a2tl) |
El formato UIDL/1 de packages/a2tl-web es una lista plana de secciones sin ids ni children (parser.ts:11-21) y no tiene primitiva para QuizItem, Stack, Card ni Callout. No puede representar la UISpec de §5.2, así que el round-trip y el reintento cruzado son imposibles para cualquier spec con ejercicio o contenedores anidados. Entra el Protocol + el registro (el seam), no el segundo dialecto. Backlog: si hace falta, será un dialecto propio de SkillNet, no el de ese paquete |
Tabla background_jobs / worker de purga |
No existe (verificado). La purga de learning_events y term_explanations es un script CLI (python -m src.scripts.purge_learning_data), documentado y ejecutable a mano o por cron del host. Backlog: convertirlo en job real |
| Fine-tuning QLoRA del DSL | Backlog |
| Descomposición paralela del router (8B esqueleto + 120B rellenos concurrentes) | Se implementa el router a nivel de UI completa. La descomposición por componente sólo tiene sentido con SandboxHTML, que está fuera |
Migración destructiva de modules/lessons/exercises |
Conviven. Las lecciones v1 son la semilla (course_nodes.seed_lesson_id) y el modo degradado |
| Click-to-locate editing (edición por diff sobre el DSL) | Backlog |
| Chat/tutor multi-turno dentro del nodo | El chat v1 sigue como está. El nodo sólo tiene clic-para-explicar |
1.4 Decisiones que este documento cierra
Estas contradicciones venían abiertas en la investigación. Se cierran así, y no se reabren dentro de este PR:
| Cuestión | Decisión |
|---|---|
| Formato de salida del LLM | IR tipada (ui_spec jsonb, lista plana de componentes). El LLM emite el dialecto del backend activo; el adaptador lo parsea a la IR. Nunca HTML |
| Nodos vs módulos/lecciones | Conviven. course_nodes es la unidad de v2; modules/lessons son el camino v1 y la semilla |
| Dónde vive el contenido generado | Tabla nueva node_renders, cacheada por cache_key. Se persiste (coste). La auditoría no vive ahí: vive en node_render_views (§3.4) |
| Estabilidad del render dentro de un nodo | Visión A: el render se fija. learner_node_states.active_render_id ancla el spec desde que se abre el nodo hasta que el usuario pide explícitamente regenerar. Un refresco del navegador o un refetch de TanStack Query devuelve el mismo spec. La adaptación ocurre entre nodos y sesiones, nunca dentro de una pantalla abierta. Visión B (regeneración continua) queda fuera: exige bloqueo de layout, explicación del cambio y “ver la versión anterior”, y nada de eso cabe en este PR |
| Experiencia declarada: ¿por curso o por persona? | Por persona y sobre su propio puesto, no sobre un curso concreto (la pregunta no nombra ningún curso). El ajuste por competencia lo aporta user_skills vía course_nodes.skill_id como prior del probe (§7.1), no la declaración |
| Log de eventos crudo | Se persiste learning_events, revirtiendo la idea previa de guardar sólo el vector agregado. Razón concreta: el decaimiento de §3.3 necesita created_at por evento, y el vector agregado no permite recalcularlo si cambian los pesos. Coste: una ventana de retención de 90 días y un script de purga |
Dimensiones del format_vector |
Sólo las que el kit puede producir: texto, ejercicio, codigo, dato. diagrama, audio y recurso se eliminan — no hay componente que los emita, así que serían dimensiones estructuralmente muertas |
Neurotipos en screens.md |
screens.md §Employee Settings (“optional: TEA, TDAH, dislexia flags”, línea 213) queda derogado por la decisión de no almacenar neurotipo. Se corrige en el mismo chore de rutas (§14.2 #8), junto con design-system.md §Skeleton, que documenta animate-pulse mientras motion-system.md:437,636 lo prohíbe |
| Escala de dominio primaria | mastery real 0..1 por (user, node), más un enum node_state derivado. Shu-Ha-Ri y Bloom son derivaciones, no estado primario |
| Escala de rating | La existente: score real 0..1 (igual que exercise_attempts). Sin Rating 1-4 |
| Preferencia de modalidad | El usuario puede pedir explícitamente imagen, audio, vídeo o texto cuando el kit los soporte. La preferencia declarada prevalece; format_vector queda como señal inferida secundaria. Véase adaptive-learning.md |
| Neurodivergencia | No se almacena etiqueta de neurotipo (dato de salud, art. 9 RGPD). Sólo ajustes de lectura neutros opt-in en users.accessibility |
| Naturaleza de la validación del creador | Gate bloqueante por estado en BD (schema_status), no interrupt() de LangGraph — sobrevive a reinicios del proceso |
| Provider LLM | litellm, provider-agnóstico. Los dos niveles del router son purposes (runtime_fast, runtime_heavy), no proveedores. Groq es un valor posible de env var, no una dependencia |
| Rutas del frontend | Se sigue la convención ya implementada en App.tsx (español). Corregir screens.md es un chore aparte |
| Adaptación de presentación | No se adapta hasta 3 nodos completados (periodo de calibración). Se adapta qué aparece, no dónde |
2. Arquitectura v2 — flujo completo
══════════ DESIGN-TIME (admin) ══════════
┌───────────┐ POST /documents ┌──────────────────┐
│ documento │ ─────────────────────────► │ ingesta v1 │ (sin cambios)
│ PDF/DOCX │ POST /documents/{id}/ │ parse → chunk │
└───────────┘ process │ → embeddings │
└────────┬─────────┘
│ documents.status='ready'
▼
POST /courses/{id}/schema/propose (202 → job_id)
│
┌───────────────────────────┴───────────────────────────┐
│ build_schema_graph() [src/agents/schema/graph.py] │
│ │
│ load_source ─► extract_themes_schema ─► design_schema │
│ (NUEVO, usa (NUEVO, usa helpers (NUEVO, LLM)│
│ helpers) + prompt v1) │ │
│ ▼ │
│ persist_schema │
│ (NUEVO) │
└───────────────────────────┬───────────────────────────┘
│ SSE: schema_step / schema_ready
│ canal generation:{job_id}
▼
courses.schema_status = 'proposed'
course_nodes + course_node_prerequisites
⚠ CERO contenido generado
│
GET /courses/{id}/schema │ PUT /courses/{id}/schema
(el creador lo lee) ◄───────┼───────► (el creador lo edita)
│
▼
╔═══════════════════════════════════════════════════════╗
║ GATE BLOQUEANTE ║
║ POST /courses/{id}/schema/validate ║
║ valida: DAG acíclico · ≥1 nodo critical · ║
║ todo nodo con summary y fuente · ║
║ prereqs sin huérfanos ║
║ ⇒ schema_status='validated', delivery_mode='dynamic' ║
╚═══════════════════════════════╤═══════════════════════╝
│
POST /enrollments (asignación, sin cambios)
│
══════════ RUNTIME (empleado) ══════════
│
▼
primer login ──► ┌────────────────────────────────────────────┐
│ ONBOARDING (5 pantallas, ≤90 s) │
│ GET /onboarding · POST /onboarding │
│ rol · objetivo · experiencia · preset · │
│ ajustes de lectura (opcional) │
└────────────────────┬───────────────────────┘
│ siembra
▼
learner_profiles (role_title, goal,
experience_level, preset, format_vector=0)
│
▼
GET /courses/{id}/nodes (lista + estado + bloqueo por prereqs)
│
▼
┌──────────────────── por cada nodo desbloqueado ─────────────────────┐
│ │
│ POST /nodes/{node_id}/probe ┌─────────────────────────┐ │
│ ──────────────────────────────────►│ PRE-ASSESSMENT │ │
│ │ item A (apply) │ │
│ POST /nodes/{node_id}/probe/answer │ item B (understand) │ │
│ ──────────────────────────────────►│ [+ desempate si duda] │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────┴───────┐ │
│ │ mastery ≥ umbral(criticality)? │ │
│ └────────┬──────────────────────────────┬───────────┘ │
│ SÍ │ │ NO │
│ ▼ ▼ │
│ node_state='mastered' ╔═══════════════════════════════════╗│
│ (se salta, 0 tokens) ║ build_node_graph() ║│
│ │ ║ [src/agents/runtime/graph.py] ║│
│ │ ║ ║│
│ │ ║ load_context ║│
│ │ ║ (nodo + perfil + estado + ║│
│ │ ║ fuente vía RAG/seed) ║│
│ │ ║ │ ║│
│ │ ║ ▼ ║│
│ │ ║ probe_gate ──(mastered)──► skip ║│
│ │ ║ │ needs_content ║│
│ │ ║ ▼ ║│
│ │ ║ decide_formato ◄── ROUTER ║│
│ │ ║ (LLM tier=fast) fast│heavy ║│
│ │ ║ → explanation|exercise| ║│
│ │ ║ chart|mixed ║│
│ │ ║ │ ║│
│ │ ║ ▼ ║│
│ │ ║ genera_ui (LLM tier del router) ║│
│ │ ║ → dialecto del backend activo ║│
│ │ ║ │ ║│
│ │ ║ ▼ ║│
│ │ ║ validate_ui ║│
│ │ ║ adapter.parse() → UISpec ║│
│ │ ║ ├ ok ──────────► persist_render ║│
│ │ ║ ├ inválido & retry<1 ─► genera_ui║│
│ │ ║ └ falla ──────► fallback_seed ║│
│ │ ║ (markdown v1) ║│
│ │ ╚═══════════════╤═══════════════════╝│
│ │ │ node_renders │
│ │ ▼ (ui_spec+answer_key)│
│ │ GET /nodes/{id}/render (cache hit)│
│ │ GET /nodes/{id}/render/stream (SSE) │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────────────────────────┐ │
│ │ │ RENDER (frontend) │ │
│ │ │ UiSpecRenderer → blocks/* │ │
│ │ │ envuelto en ClickableSurface │ │
│ │ │ │ │
│ │ │ clic en palabra/selección ──────►│ │
│ │ │ POST /explain (SSE) ─► popover │ │
│ │ └───────────────┬───────────────────┘ │
│ │ │ │
│ │ POST /nodes/{id}/answer │
│ │ POST /nodes/{id}/feedback │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────────────────────────┐ │
│ │ │ AJUSTE DE PERFIL (determinista) │ │
│ │ │ [src/services/learner_profile_ │ │
│ │ │ service.py] │ │
│ │ │ · mastery ← EWMA(score) │ │
│ │ │ · consecutive_correct/failed │ │
│ │ │ · last_error_kind │ │
│ │ │ · format_vector ← learning_events│ │
│ │ │ · tutor_notes (vocab controlado) │ │
│ │ └───────────────┬───────────────────┘ │
│ │ │ │
│ └─────────────────────────────────┤ │
│ ▼ │
│ siguiente nodo desbloqueado │
└──────────────────────────────────────────────────────────────────────┘
│
▼
enrollments.status='completed' cuando todos los
nodos critical están en 'mastered'
2.1 Qué se persiste vs qué se genera
La preparación pedagógica asíncrona situada entre el índice y OpenUI se especifica en
node-knowledge-packs.md. Tras el commit del índice genera un contrato
estructurado y un Markdown derivado por nodo. Solo los packs ready alimentan la selección de
conocimiento del runtime; su hash y el de la selección forman parte de la clave de caché. Los estados
review_required, failed, stale y la ausencia de pack mantienen el flujo raw anterior como
fallback. Crear o modificar el esquema encola automáticamente la preparación; abrir la pantalla no
inicia trabajo. Cada nodo expone su estado dentro de su propio desplegable en la pantalla de esquema;
los detalles técnicos no ocupan una sección global.
| Se persiste (design-time, estable) | Se genera al vuelo (runtime, por usuario) |
|---|---|
| Título y outcome del curso | La UI de cada nodo (ui_spec) |
| Nodos, summaries, criticidad, prerrequisitos | El texto explicativo adaptado |
| Documento fuente + headings asociados | Los ejemplos contextualizados al rol |
| Constitución: competencia, grounding, gates de evidencia y errores críticos | EpisodeBrief: misión, acción dominante, apoyo, límites y continuación |
| Perfil del aprendiz | Los ítems de ejercicio y del pre-assessment |
| Estado por nodo (maestría, rachas) | Las explicaciones de clic-para-explicar |
El ui_spec ya generado (contenido, compartido por bucket) |
— |
Quién vio qué render y cuándo (node_render_views) |
— |
La constitución y los packs no son una presentación precreada. No fijan secuencia, modalidad, componente ni artefactos especulativos del curso; acotan la generación episódica y permiten auditarla. Audio y vídeo, cuando resultan elegibles, se embeben en la misión: no crean pestañas de modalidad.
Caché y auditoría son dos tablas, no una. node_renders es contenido org-scoped: guarda el
dialect canónico que se sirvió (re-serializado desde la UISpec, nunca el texto crudo del
modelo), la ui_spec validada, el modelo usado y la procedencia (catalog_version,
library_version), y una misma fila la
comparten todos los empleados del mismo bucket de perfil. Por tanto no puede decir quién vio
qué: en un acierto de caché (el ~80 % que hace sostenible el coste) la fila no tiene nada que ver
con el empleado que la está leyendo.
Lo que sí lo dice es node_render_views(user_id, render_id, first_seen_at): una fila fina escrita
en el primer GET /nodes/{id}/render de cada usuario. Un certificado se justifica uniendo
node_attempts → node_render_views → node_renders, y sobrevive al borrado de cualquier otro
usuario porque node_renders.generated_by es NULL-able con ON DELETE SET NULL (§3.4).
3. Modelo de datos
Todo lo nuevo va en una migración: alembic/versions/0005_dynamic_courses.py, con
revision = "0005" y down_revision = "0004" (head actual verificado: cadena lineal
0001→0002→0003→0004, sin ramas).
Qué significa exactamente “downgrade probado” — y qué no: PostgreSQL no puede quitar un valor
de un enum. downgrade() borra las 13 tablas nuevas, las 6 columnas de courses y los 8 enums
nuevos, pero deja huérfanos schema_proposing y schema_proposed en el tipo generation_step.
Es inocuo (ninguna fila los referencia después del downgrade, porque los jobs de esquema se borran
con las tablas nuevas… y si quedara alguno, generation_service ya cae a un valor conocido) y es
exactamente lo que asserta tests/integration/test_migration_0005.py: no un esquema byte a byte
idéntico, sino “todas las tablas y columnas nuevas fuera, los dos valores de enum dentro”. Un
downgrade verdaderamente limpio exigiría recrear el tipo y reescribir generation_jobs.status; no
se hace, y esta línea es la razón documentada.
Convenciones respetadas de data-model.md: PK uuid DEFAULT gen_random_uuid(), timestamptz,
jsonb para lo flexible, org_id en tablas top-level (las hijas heredan el scoping del padre;
ver §15.4), enums nombrados en snake_case.
Aislamiento tenant de matrículas. Una matrícula tiene dos extremos —el aprendiz y el curso— y
ambos deben vivir en la misma organización. La creación (EnrollmentService.assign /
assign_courses) valida ahora que todos los user_ids pertenezcan a la org del admin, no solo el
curso; enrolar a un aprendiz de otra org devuelve 403. El listado de “Mis cursos”
(EnrollmentRepository.list_enrollments) filtra por User.org_id y Course.org_id, de modo que
una fila cruzada preexistente no aparece —antes se mostraba su título y luego el detalle respondía
404 (“Curso no encontrado”)—. La defensa se aplica en lectura y escritura sin borrar filas: una
matrícula inconsistente queda oculta, no eliminada. El detalle del curso ya filtraba por la org del
llamante y se mantiene.
Orden de creación en
0005— los bloques SQL de abajo están agrupados por tema, no en orden ejecutable. Hay dos referencias hacia adelante:course_nodes.default_ui_formatnecesita el enumui_format, ylearner_node_states.active_render_idnecesita la tablanode_renders. Orden real: todos losCREATE TYPEprimero, luegocourse_nodes→course_node_prerequisites→node_renders→learner_node_states→ el resto →node_render_views(que referencianode_rendersycourse_nodes).
3.1 Cambios a tablas existentes
Todos con DEFAULT, todos aditivos. Ninguna columna existente se renombra ni se borra.
CREATE TYPE course_delivery_mode AS ENUM ('static', 'dynamic');
CREATE TYPE course_schema_status AS ENUM ('draft', 'proposed', 'validated', 'archived');
ALTER TABLE courses
ADD COLUMN delivery_mode course_delivery_mode NOT NULL DEFAULT 'static',
ADD COLUMN schema_status course_schema_status NOT NULL DEFAULT 'draft',
ADD COLUMN schema_validated_by uuid REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN schema_validated_at timestamptz,
ADD COLUMN schema_version int NOT NULL DEFAULT 1,
ADD COLUMN intent_density smallint NOT NULL DEFAULT 3
CHECK (intent_density BETWEEN 1 AND 5);
delivery_mode:'static'= camino v1 intacto.'dynamic'= camino v2. Sólo lo ponePOST /courses/{id}/schema/validate.schema_version: se incrementa en cadaPUT /courses/{id}/schemaque cambie nodos. Entra en lacache_key, así que editar el esquema invalida los renders derivados sin borrar filas.intent_density: el “slider de intención” condensado(1) ↔ expandido(5). Entra en el prompt degenera_uicomo presupuesto de longitud, no como decisión de formato.
-- El paso 'schema' del pipeline de design-time
ALTER TYPE generation_step ADD VALUE IF NOT EXISTS 'schema_proposing' BEFORE 'extracting';
ALTER TYPE generation_step ADD VALUE IF NOT EXISTS 'schema_proposed' AFTER 'reviewing';
Nota de implementación, corregida dos veces. El despliegue es pg16 (
docker-compose.yml:pgvector/pgvector:pg16), dondeALTER TYPE … ADD VALUEsí funciona dentro de una transacción desde pg12. Lo único prohibido es usar el valor nuevo en la misma transacción — y la versión anterior de esta nota afirmaba que0005no lo hacía. Era falso: el paso 16 usaschema_proposing/schema_proposeden el predicado del índice parcialuq_generation_jobs_schema_in_flight, así que la tirada muere conUnsafeNewEnumValueUsageError. Lo descubrió la primera ejecución real de las suites de integración; hasta entonces nadie había hecho unalembic upgrade headdesde cero.Corrección aplicada en el fichero: los dos
ALTER TYPE … ADD VALUEse ejecutan dentro deop.get_context().autocommit_block(), y es el único de la migración. El coste de ese bloque sigue siendo el que la nota anterior quería evitar y hay que asumirlo con los ojos abiertos:alembic/env.pyenvuelve toda la tirada en uncontext.begin_transaction()sintransaction_per_migration, ysrc/main.pyllama arun_migrations()en el lifespan, así que el bloque confirma0001..0004antes de tiempo. Si un paso posterior de0005falla, la base se queda en0004sin0005estampada y hay que borrar a mano los objetos v2 a medio crear antes de reintentar. Está documentado en el docstring de la migración y afirmado portests/integration/test_migration_0005.py.Segunda lección de la misma ejecución, aplicable a cualquier migración futura de este repo:
sa.Enum(..., create_type=False)no sirve.sa.Enumpierde ese flag al adaptarse al dialecto de postgres, así que elCREATE TYPEse emite igualmente y la segunda vez revienta. Hay que usarpostgresql.ENUM(..., create_type=False). Era el motivo real de quealembic upgrade headdesde cero no hubiera funcionado nunca (estaba en0003).Tercera: un valor por defecto JSONB construido con
sa.text()interpreta los:como parámetros de bind. Sin escapar, el DDL de0005y desrc/models/learner_profile.pysalía como{"texto"NULL,...}. Hay que escribir\:.Añadir también los miembros al enum Python
GenerationStepensrc/models/generation_job.py(SCHEMA_PROPOSING = "schema_proposing",SCHEMA_PROPOSED = "schema_proposed").
generation_jobs.output_type: esNOT NULLsobre un enum con sólocourse_and_manual|manual_only(src/models/generation_job.py:15-17), y un job de propuesta de esquema no es ninguno de los dos. Decisión: el job de esquema escribeoutput_type='course_and_manual'como placeholder sin significado y ningún consumidor lo interpreta (los clientes de esquema se guían porstatus). No se añade un tercer valor al enum para no ampliar el problema de enum-huérfano del downgrade por un campo que nadie lee.
generation_jobs.progress (jsonb) y langgraph_thread_id, hoy nunca escritos, empiezan a
escribirse en el grafo de esquema: progress recibe {"step": ..., "nodes_proposed": N} en cada
nodo, de modo que un cliente que se suscribe tarde al SSE puede reconstruir el estado por REST.
users.accessibility (jsonb, ya existe) pasa a tener forma definida — ajustes neutros, nunca
diagnósticos:
{"reduce_motion": false, "short_blocks": true,
"high_contrast": false, "extra_time": false}
audio_first se elimina de la forma y de la pregunta 5: no hay TTS en ningún punto de este PR,
ni componente de audio en el kit congelado (§5.3). Ofrecer una acomodación que el pipeline no puede
entregar es peor que no ofrecerla.
Cómo se honra short_blocks sin que accessibility llegue al LLM (la regla de que nunca llega
se mantiene): el frontend no puede acortar prosa escrita por el modelo, así que la señal se
traduce en el servidor a una dimensión que ya viaja al prompt. En load_context:
effective_density = min(course.intent_density, 2) if user.accessibility.get("short_blocks") else course.intent_density
effective_density es lo que entra en el prompt y en la cache_key. El LLM recibe un número de
presupuesto de longitud, nunca el flag ni su origen.
3.2 El esquema del curso
CREATE TYPE node_criticality AS ENUM ('critical', 'recommended', 'contextual');
CREATE TABLE course_nodes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
skill_id uuid REFERENCES skills(id) ON DELETE SET NULL,
seed_lesson_id uuid REFERENCES lessons(id) ON DELETE SET NULL,
title text NOT NULL,
summary text NOT NULL,
outcome text,
criticality node_criticality NOT NULL DEFAULT 'recommended',
position int NOT NULL,
source_document_id uuid REFERENCES documents(id) ON DELETE SET NULL,
source_headings text[] NOT NULL DEFAULT '{}',
mastery_threshold real NOT NULL DEFAULT 0.80
CHECK (mastery_threshold > 0 AND mastery_threshold <= 1),
default_ui_format ui_format NOT NULL DEFAULT 'explanation',
probe_items jsonb NOT NULL DEFAULT '[]',
probe_answer_key jsonb NOT NULL DEFAULT '{}',
estimated_minutes int,
reviewed_at timestamptz,
reviewed_by uuid REFERENCES users(id) ON DELETE SET NULL,
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_course_nodes_position UNIQUE (course_id, position)
DEFERRABLE INITIALLY IMMEDIATE
);
CREATE INDEX idx_course_nodes_course ON course_nodes(course_id, position);
CREATE INDEX idx_course_nodes_skill ON course_nodes(skill_id);
CREATE TABLE course_node_prerequisites (
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
prerequisite_node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
PRIMARY KEY (node_id, prerequisite_node_id),
CHECK (node_id <> prerequisite_node_id)
);
CREATE INDEX idx_node_prereq_node ON course_node_prerequisites(node_id);
Notas de diseño, cada una con su razón:
summaryesNOT NULL. Sin summary el patrón PageIndex del tutor (leer el árbol de summaries → decidir qué nodo es relevante → cargar sólo ése) no funciona. Es requisito de validación, no un adorno.source_headings text[], nochunk_id. Los chunks se destruyen al re-ingerir el documento; los headings sobreviven. El nodo referencia(source_document_id, source_headings).seed_lesson_idapunta a la lección v1 equivalente si el curso viene de v1. Es el modo degradado: sigenera_uifalla dos veces o no hay LLM disponible, se sirvelessons.contentrenderizado con markdown. Esto resuelve la incompatibilidad de v2 con el modo offline/catálogo.mastery_thresholdpor nodo, con default derivado de la criticidad al crear el esquema:critical → 0.90,recommended → 0.80,contextual → 0.70. El creador puede sobreescribirlo.default_ui_formatexiste porque §6.4 manda caer en “el formato canónico por defecto del nodo” durante la calibración, y sin esta columna esa instrucción no tenía dónde leerse. Lo proponedesign_schemay lo edita el creador en B10. Defaultexplanation.probe_items/probe_answer_keyguardan el pre-assessment pre-generado en la validación, no por usuario. Los ítems dependen sólo de(node, source), así que se generan una vez por nodo y se sirven instantáneamente a todo el mundo: resuelve el arranque en frío del probe (§9.1), donde de otro modo la “espera productiva” tendría delante su propia espera de una llamada LLM contra una pantalla en blanco.node_probespasa a ser sólo el registro por usuario de un intento.reviewed_at/reviewed_bypor nodo. La validación de §11.1 prueba que el grafo está bien formado, no que un humano leyera la pedagogía. Un nodo sinreviewed_atno se puede servir:resolve_deliverysigue mirando el curso, peroGET /nodes/{id}/renderdevuelve409 node_not_reviewedsi el nodo no está revisado. Eso cierra el bypass de §11.1 (añadir nodos nuevos a un curso ya validado) por construcción, no por confianza.archiveden lugar de borrado: un nodo conlearner_node_states.attempts_count > 0no se puede borrar (422 node_has_progress), se archiva. Borrarlo cascadearía alearner_node_statesynode_renders, tirando maestría y rastro de auditoría de gente que ya había trabajado.UNIQUE (course_id, position)esDEFERRABLE INITIALLY IMMEDIATEy elPUTde §11.1 la difiere (SET CONSTRAINTS uq_course_nodes_position DEFERRED) dentro de su transacción. Sin eso, cualquier reordenación viola la restricción a mitad de la sentencia. Test obligatorio: intercambiar las posiciones 1 y 2 en un soloPUT.- Aciclicidad: no se puede expresar en un CHECK. Se valida en
CourseSchemaService.validate()con un orden topológico (Kahn) antes de pasar a'validated', y hay test unitario del detector de ciclos. Un ciclo devuelve422.
3.3 El perfil del aprendiz
Tres fuentes independientes, tres sitios distintos: declarado (learner_profiles), inferido
(learning_events → format_vector), y por competencia (learner_node_states).
CREATE TYPE learner_experience AS ENUM ('unknown', 'none', 'some', 'experienced');
CREATE TABLE learner_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
role_title text,
sector text,
goal text,
experience_level learner_experience NOT NULL DEFAULT 'unknown',
preset learning_profile NOT NULL DEFAULT 'standard',
format_vector jsonb NOT NULL DEFAULT
'{"texto":0,"ejercicio":0,"codigo":0,"dato":0}',
format_vector_updated_at timestamptz,
nodes_completed int NOT NULL DEFAULT 0,
tutor_notes jsonb NOT NULL DEFAULT '{}',
onboarding_completed_at timestamptz,
onboarding_skipped boolean NOT NULL DEFAULT false,
onboarding_version smallint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
presetreusa el enumlearning_profileque ya existe (standard|focus|fast). No se crea un enum nuevo.users.learning_profilesigue siendo la fuente de verdad para el frontend v1;learner_profiles.presetse mantiene sincronizado en la misma transacción del onboarding.experience_levelarranca en'unknown', no en'none'.'none'significa “declara que no tiene experiencia” y dispara andamiaje de novato (ejemplos resueltos), que es exactamente lo que perjudica al experto. Usar'none'como “no lo sé” haría que todo el que salte el onboarding reciba andamiaje de novato en silencio.'unknown'se mapea a andamiaje neutro: ni ejemplos resueltos extra ni supresión de andamiaje; el probe del primer nodo corrige en 2 ítems.format_vectores jsonb, no 4 columnas. Añadir una dimensión no debe ser una migración. Las dimensiones son exactamente las que el kit congelado de §5.3 puede producir:texto(TextContent,Callout,StepSequence,Card),ejercicio(QuizItem),codigo(CodeBlock),dato(Chart,Table). Se eliminandiagrama,audioyrecurso: ningún componente los emite, así que serían dimensiones que no pueden recibir señal nunca y que sesgarían el bucket dominante haciatextopor construcción. Suma ~1.0 tras normalizar L1; con el usuario nuevo son todos 0 y no se usa para nada (ver periodo de calibración, §6.4).nodes_completedes el contador que gobierna el periodo de calibración. Denormalizado a propósito: se lee en cadadecide_formatoy no queremos unCOUNT(*)por render. Regla de incremento, fijada:+1sólo en la transiciónlearning → mastered, es decir sólo cuando el nodo se ha trabajado. Un nodo saltado por el probe (probing → mastered) no incrementanodes_completed, precisamente porque no generó ni un evento de interacción: contarlo sacaría al usuario de la calibración con unformat_vectorvacío.tutor_noteses el “cuaderno del tutor” (Notarius). Vocabulario controlado, no prosa libre, para que sea auditable y borrable:
{
"version": 1,
"context": {"sector": "retail", "role": "dependiente", "prior": ["caja", "inventario"]},
"signals": [
{"node_id": "…", "action": "reforzar_con_ejemplo", "at": "2026-07-25T10:00:00Z"},
{"node_id": "…", "action": "reducir_longitud_modulo", "at": "…"}
]
}
Acciones permitidas (validadas por Pydantic, Literal) y la condición exacta que las escribe.
Sin esta tabla tutor_notes era una entrada libre y no especificada a un prompt, es decir no
implementable ni testeable; y sugerir_formato_audio se elimina porque ni existe componente de
audio (§5.3) ni hay señal que pueda producirla. Todas las escrituras ocurren en
LearnerProfileService.apply_signals(), llamado tras cada answer/feedback, nunca desde un
LLM:
| Acción | Condición exacta que la emite | Test |
|---|---|---|
reforzar_con_ejemplo |
consecutive_failed >= 2 en el nodo |
test_profile_service.py::test_signal_reinforce |
bajar_dificultad |
node_feedback.difficulty == 'hard' en el nodo |
…::test_signal_lower |
subir_dificultad |
node_feedback.difficulty == 'easy' y consecutive_correct >= 3 |
…::test_signal_raise |
reducir_longitud_modulo |
3 eventos scroll_fast consecutivos en el mismo nodo |
…::test_signal_shorten |
revisar_prerrequisito |
last_error_kind == 'conceptual' y el nodo tiene ≥1 prerrequisito con state != 'mastered' |
…::test_signal_prereq |
signals está capado a las 20 más recientes (poda en el servicio) y una misma (node_id, action)
no se duplica: se actualiza el at. Esto es una simplificación deliberada de la exploración de
Notarius, que proponía derivar señales de la conversación con el tutor y de comportamiento de
audio/dwell: como §1.3 quita el chat dentro del nodo y no hay audio, esas fuentes no existen aquí y
el vocabulario cerrado es lo único alimentable.
CREATE TYPE node_state AS ENUM
('not_started', 'probing', 'learning', 'mastered', 'needs_review');
CREATE TYPE error_kind AS ENUM ('detail', 'procedural', 'conceptual');
CREATE TABLE learner_node_states (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
state node_state NOT NULL DEFAULT 'not_started',
mastery real NOT NULL DEFAULT 0 CHECK (mastery >= 0 AND mastery <= 1),
probe_score real CHECK (probe_score IS NULL OR (probe_score >= 0 AND probe_score <= 1)),
consecutive_correct smallint NOT NULL DEFAULT 0,
consecutive_failed smallint NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
attempts_count int NOT NULL DEFAULT 0,
last_error_kind error_kind,
active_render_id uuid REFERENCES node_renders(id) ON DELETE SET NULL,
render_pinned boolean NOT NULL DEFAULT true,
scaffold_band text NOT NULL DEFAULT 'neutral'
CHECK (scaffold_band IN ('novice','neutral','advanced')),
waived_by uuid REFERENCES users(id) ON DELETE SET NULL,
waived_at timestamptz,
first_seen_at timestamptz,
mastered_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, node_id)
);
CREATE INDEX idx_lns_user ON learner_node_states(user_id);
CREATE INDEX idx_lns_state ON learner_node_states(user_id, state);
active_render_id+render_pinnedimplementan la Visión A de §1.4: el render se fija al abrir el nodo.GET /nodes/{id}/renderdevuelve el spec deactive_render_idmientrasrender_pinnedseatrue, sin recalcular lacache_key. Sin esto, el contenido mutaba a mitad de nodo: con respuestas perfectas la maestría recorre 0 → 0.40 → 0.64 → 0.784 → 0.87, y conmastery_banden la clave eso son cuatro claves distintas dentro de un mismo nodocritical, así que un simple refresco del navegador devolvía otros bloques en otro orden — rompiendo la propia tabla de zonas de estabilidad de §5.5.scaffold_bandsustituye amastery_banden lacache_key(§3.4). Se calcula una vez, al cerrar el probe:novicesiexperience_level='none'o el probe saliólearningconscore_a == 0;advancedsi el probe saliótiebreakoexperience_level='experienced';neutralen el resto. Es estable durante todo el nodo por construcción, no por convención.waived_by/waived_at: la vía de escape humana de §7.4 (POST /nodes/{id}/waive).
mastery es la única escala primaria de dominio. Las demás son vistas derivadas, calculadas
en código, nunca persistidas por duplicado:
| Derivación | Regla |
|---|---|
| Fase Shu-Ha-Ri (andamiaje) | mastery < 0.5 → shu; 0.5 ≤ mastery < threshold → ha; ≥ threshold → ri |
skill_level (low/medium/high de user_skills) |
< 0.5 → low; < 0.85 → medium; ≥ 0.85 → high. Se aplica sólo al alza, como ya hace _assign_course_skills |
| Nivel Bloom objetivo del siguiente ítem | shu → understand; ha → apply; ri → analyze |
CREATE TABLE learning_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid REFERENCES course_nodes(id) ON DELETE SET NULL,
type text NOT NULL,
element text,
weight real NOT NULL DEFAULT 0,
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_learning_events_user ON learning_events(user_id, created_at DESC);
Append-only. Pesos fijos, definidos como constante en
src/services/learner_profile_service.py::EVENT_WEIGHTS:
type |
peso | type |
peso | |
|---|---|---|---|---|
explain_click |
+0.30 | quiz_correct |
+0.20 | |
expand |
+0.15 | quiz_wrong |
+0.10 | |
scroll_slow (>3 s) |
+0.10 | view |
+0.05 | |
scroll_fast (<1 s) |
−0.05 |
element ∈ {texto, ejercicio, codigo, dato} — las cuatro dimensiones del format_vector.
resource_opened se elimina (era el segundo peso más alto): el kit congelado de §5.3 no tiene
componente de enlace o recurso, así que ningún render puede emitir un elemento sobre el que abrir un
recurso. Un evento que ningún render puede disparar no es una señal, es peso muerto que distorsiona
la normalización L1.
Privacidad, decidida y corregida: learning_events.metadata nunca guarda texto del usuario
ni el contenido copiado. Sólo {"element_id": "...", "ms": 1234}. El texto derivado del usuario
aterriza en dos sitios, no en uno — la versión anterior de este documento decía “un único sitio”
y se contradecía con §3.4:
node_feedback.unclear— texto libre que el usuario escribe.term_explanations.term/term_normalized— la selección que el usuario clicó. Es texto elegido por el usuario, y por eso §8.4 limita lo cacheable a ≤60 caracteres y ≤4 tokens; por encima de eso la explicación se sirve pero no se persiste.
Retención y borrado, todo por el mismo script (python -m src.scripts.purge_learning_data, ver §1.3
— no existe tabla background_jobs): learning_events a 90 días; term_explanations a 180
días desde last_used_at. Borrado a petición del interesado:
DELETE /users/me/learner-profile (§11.2) borra las siete tablas personales del usuario —
node_render_views, node_feedback, node_attempts, node_probes, learner_node_states,
learning_events y learner_profiles — y anonimiza node_renders.generated_by a NULL.
node_attempts y node_probes guardan las respuestas que el empleado escribió, así que un borrado
que las dejara atrás devolvería 204 por una promesa que no ha cumplido; el orden es el que imponen
las FKs de 0005 (node_attempts antes de node_probes, porque node_attempts.probe_id es
ON DELETE SET NULL). El script de retención no cubre esto y no debe: su cometido son las dos
ventanas de arriba, no la supresión del art. 17.
El vector se calcula sobre una ventana de 30 días con decaimiento:
weight_effective = weight * GREATEST(0.2, 1.0 - (age_seconds / (30*86400)) * 0.8)
format_vector[e] = SUM(weight_effective) / SUM(all) -- normalizado L1
Visibilidad: learner_profiles y learner_node_states son privados del empleado. El admin ve
sólo agregados con k ≥ 5 (si el grupo tiene menos de 5 personas, no se muestra la métrica).
role_title y sector sí viajan al LLM. goal ya no viaja al LLM (ver §3.4 y §6.2): se
consume de forma determinista en el frontend para la línea de apertura “esto te sirve para X”. Eso
reduce el dato personal enviado a un tercero y hace que la promesa de la pregunta 2 sea real
en lugar de depender de que el modelo se acuerde de escribirla. users.accessibility nunca va al
LLM (§3.1 explica cómo se honra short_blocks sin enviarlo).
Aviso en el punto de recogida (RGPD, art. 13): la pantalla de la pregunta 1 del onboarding
muestra, con el mismo peso visual que la pregunta, una línea fija:
“Tu puesto y tu sector se envían al proveedor de IA para adaptar los ejemplos. Puedes borrarlos
cuando quieras desde Ajustes.” No es copy opcional: es requisito y va en OnboardingRead
(notice), no hardcodeado en el cliente.
3.4 Contenido generado en runtime
CREATE TYPE ui_format AS ENUM ('explanation', 'simulation', 'exercise', 'chart', 'mixed');
CREATE TYPE node_render_status AS ENUM ('pending', 'generating', 'ready', 'failed', 'fallback');
CREATE TABLE node_renders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
generated_by uuid REFERENCES users(id) ON DELETE SET NULL,
is_preview boolean NOT NULL DEFAULT false,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
cache_key text NOT NULL,
ui_format ui_format NOT NULL,
ui_spec jsonb NOT NULL DEFAULT '{}', -- IR validada; auditoría, NO se sirve
answer_key jsonb NOT NULL DEFAULT '{}', -- nunca se serializa al cliente
dialect text, -- el programa canónico que pintó el navegador
catalog_version text, -- "skillnet-ui/1+<digest12>"
library_version text, -- "@openuidev/lang-core@0.2.10; ..."
backend text NOT NULL,
model text NOT NULL,
tier text NOT NULL CHECK (tier IN ('fast', 'heavy')),
status node_render_status NOT NULL DEFAULT 'pending',
tokens_in int,
tokens_out int,
duration_ms int,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
-- Trazabilidad de cumplimiento: una fila que alguien vio dice QUÉ vio y contra qué
-- catálogo. 'pending'/'generating'/'failed' no tienen nada que mostrar.
CONSTRAINT ck_node_renders_served_provenance CHECK (
status NOT IN ('ready', 'fallback')
OR (dialect IS NOT NULL AND catalog_version IS NOT NULL AND library_version IS NOT NULL)
),
UNIQUE (cache_key)
);
CREATE INDEX idx_node_renders_node ON node_renders(node_id, created_at DESC);
CREATE INDEX idx_node_renders_status ON node_renders(status);
CREATE TABLE node_render_views (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
render_id uuid NOT NULL REFERENCES node_renders(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
first_seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, render_id)
);
CREATE INDEX idx_node_render_views_user ON node_render_views(user_id, node_id, first_seen_at DESC);
answer_keyes columna aparte deui_specy nunca se serializa a la API. Es el equivalente estructural destrip_answers()del camino v1, pero por construcción en lugar de por filtrado: no se puede filtrar mal lo que no está en el mismo campo.node_rendersno tieneuser_id. Tenía unoNOT NULL … ON DELETE CASCADEjunto a unUNIQUE (cache_key)global, y las dos cosas juntas eran incoherentes de tres maneras: la búsqueda por usuario no encontraba nunca la fila compartida (hit rate 0, que es el pilar del modelo de coste); la fila sólo registraba a quien la generó primero, así que la promesa de auditoría de §2.1 era falsa para cada acierto de caché; y dar de baja a ese primer empleado destruía el render que veían todos los demás y, con él, la evidencia de sus certificados. Ahora:org_idpara el scoping,generated_byNULL-able conSET NULLpara trazabilidad de generación, ynode_render_viewspara la auditoría de lectura (§2.1).- La búsqueda de caché es estrictamente
WHERE cache_key = :key AND status='ready' AND NOT is_preview. Nada deuser_iden elWHERE, nunca. is_preview: los renders de?preview=1del modoshadowse persisten para poder revisarlos, pero quedan fuera de la caché. Sin esto, un preview generado por un admin antes de validar el esquema podía servirse literalmente a un empleado del mismo bucket — contenido no aprobado llegando a un aprendiz por la puerta de atrás.cache_keyesUNIQUEglobal: dos usuarios con el mismo bucket de perfil comparten render. Eso es deliberado y es lo que hace el coste sostenible.
cache_key = sha256(
f"{node_id}|{course.schema_version}|{preset}|{experience_level}|{role_bucket}"
f"|{scaffold_band}|{vector_bucket}|{effective_density}|{backend}|{model}|{PROMPT_VERSION}"
)
role_bucket = slug(role_title or sector or "")[:24] # "" si no hay onboarding
scaffold_band = learner_node_states.scaffold_band # novice|neutral|advanced, fijo por nodo
vector_bucket = f"{dominant}:{round(p_dominant,1)}" # "" durante calibración
Dos correcciones respecto a la versión anterior de esta fórmula, ambas obligadas:
- Entra
role_bucket.role_titlees lo único que §6.2 declara que viaja literalmente al prompt degenera_ui, y era la única adaptación con evidencia fuerte — pero no estaba en la clave. Resultado: un dependiente y un encargado de turno con el mismo preset compartían fila y el segundo recibía los ejemplos enmarcados para el rol del primero, borrando en silencio la personalización que el onboarding promete.slug(role_title)baja el hit rate; se acepta, porque una caché que sirve contenido con el marco de rol equivocado no es un acierto, es un fallo barato. - Sale
mastery_band, entrascaffold_band.floor(mastery*5)/5cambia con cada respuesta: cuatro claves distintas dentro de un solo nodocritical.scaffold_bandse congela al cerrar el probe (§3.3) y no se mueve hasta que el nodo se cierra.
PROMPT_VERSION es una constante en src/llm/prompts/runtime.py; subirla invalida todos los renders
sin tocar la BD.
Sobre la cita del experimento de caché, honestamente: la medición interna de ~80 % de aciertos
con 0 entregas obsoletas corresponde a una clave por usuario (usuario+curso+módulo+bucket), y
el 0 % de obsoletas es una propiedad de esa clave, no un resultado transferible. La clave
inter-usuario de este diseño es un régimen no medido. Por eso el primer número que se mide en
§14.2 #3 no es sólo el hit rate: es el par (hit rate, tasa de obsoletas) de la clave compartida.
CREATE TABLE node_probes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
schema_version int NOT NULL,
attempt_no smallint NOT NULL DEFAULT 1,
items jsonb NOT NULL,
answer_key jsonb NOT NULL DEFAULT '{}',
answers jsonb NOT NULL DEFAULT '[]',
score real CHECK (score IS NULL OR (score >= 0 AND score <= 1)),
mastered boolean,
tiebreak_used boolean NOT NULL DEFAULT false,
scored boolean NOT NULL DEFAULT true,
model text,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE UNIQUE INDEX uq_node_probes_user_node_version
ON node_probes(user_id, node_id, schema_version) WHERE scored;
CREATE INDEX idx_node_probes_user_node ON node_probes(user_id, node_id, created_at DESC);
CREATE TABLE node_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
render_id uuid REFERENCES node_renders(id) ON DELETE SET NULL,
probe_id uuid REFERENCES node_probes(id) ON DELETE SET NULL,
item_id text NOT NULL,
item_type exercise_type NOT NULL,
bloom_level text CHECK (bloom_level IN
('remember','understand','apply','analyze','evaluate','create')),
answer jsonb NOT NULL,
score real NOT NULL CHECK (score >= 0 AND score <= 1),
passed boolean NOT NULL,
hints_used smallint NOT NULL DEFAULT 0,
feedback text,
latency_ms int,
attempted_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_node_attempts_user_node ON node_attempts(user_id, node_id, attempted_at DESC);
El UNIQUE parcial de node_probes es la regla anti-reintento, y es lo que impedía que la
maestría fuera gameable: un probe perfecto (2/2) devuelve mastered, salta el nodo y cuenta para
enrollments.status='completed' y para user_skills; con dos ítems de 4 opciones el azar acierta
1 vez de 16, así que sin límite de intentos bastaba reentrar ~16 veces para saltarse cualquier nodo
—incluido uno critical de seguridad— sin haber visto una línea de contenido. Reglas, concretas:
- Un probe puntuado por
(user_id, node_id, schema_version). Al reentrar en el nodo se sirve el veredicto almacenado; no se genera otro. - Re-probe sólo desde
needs_reviewy con al menos 7 días desdecompleted_at. Se inserta conattempt_no + 1(la fila anterior pasa ascored = false, así el índice parcial lo permite). - En un nodo
criticalel veredictomasterednunca puede salir de ítems de respuesta seleccionada: el desempate de respuesta construida es obligatorio (§7.2). scored = falsetambién se usa para el probe diagnóstico del novato (§7.1): se muestra, no puntúa, no persiste fallos y no consume el intento único.
node_attempts existe en lugar de reusar exercise_attempts porque esta última tiene
exercise_id uuid NOT NULL REFERENCES exercises(id) y los ítems generados al vuelo no son filas de
exercises. Sí reusa el enum exercise_type y reusa la corrección determinista existente,
pero con un nombre y una forma exactos que la versión anterior de este documento tenía mal:
- La función es
grade(exercise_type, content, answer), módulo-level y ya pura ensrc/services/exercise_service.py:73(“Pure and importable without any DB or LLM dependency”). No esExerciseService.grade()(ExerciseServicees la clase de la línea 100) y no hay que extraerla a ningún sitio: se importa tal cual. - Sí hace falta un adaptador, así que no es “cero lógica nueva”:
grade()lee la respuesta correcta de un dictcontentcon forma v1 (correct,blanks,correct_order,explanation), mientras que v2 tiene los enunciados enQuizItem.propsy las soluciones enanswer_key. El adaptador essrc/services/node_grading.py::content_for(item_props, answer_key_entry) -> dict, con test propio por cada uno de los 4 tipos deterministas. - Los cuatro tipos deterministas puntúan 0.0 o 1.0, sin crédito parcial — incluido
fill_blank, que devuelve 0.0 si falla un solo hueco (_grade_fill_blank, líneas 34-43). Esto es load-bearing para la aritmética de §7.2 y hay que tenerlo presente antes de “hacer continuo” ningún ítem. - Para
practical_case/dialoguese usagrade_open_answer()(purposeeval), que se construye conget_optional_llm_service. Ese factory también tiene que pasar por_maybe_fixture(§12.1) o el flujo con fixtures intenta una llamada de red real.
CREATE TABLE node_feedback (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
difficulty text NOT NULL CHECK (difficulty IN ('easy', 'ok', 'hard')),
unclear text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, node_id)
);
CREATE TABLE term_explanations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
node_id uuid REFERENCES course_nodes(id) ON DELETE SET NULL,
term text NOT NULL,
term_normalized text NOT NULL,
context_hash text NOT NULL,
language text NOT NULL DEFAULT 'es',
explanation text NOT NULL,
model text NOT NULL,
hit_count int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (org_id, term_normalized, context_hash, language)
);
CREATE INDEX idx_term_expl_lookup ON term_explanations(org_id, term_normalized, context_hash);
CREATE INDEX idx_term_expl_purge ON term_explanations(last_used_at);
Sólo se persiste si el término es ≤60 caracteres y ≤4 tokens (§8.4). Una selección de 140
caracteres es una frase escogida por el usuario, no un término, y guardarla indefinidamente en una
fila sin user_id la haría además imposible de atender ante una solicitud de supresión. Por
encima de 60 caracteres (hasta el límite duro de 140) la explicación se genera y se sirve, pero
no se escribe. idx_term_expl_purge da soporte al borrado a 180 días de last_used_at.
context_hash = sha256(normalized_block_text)[:16]. Incluir el contexto en la clave no es
opcional: es el argumento central de la función. “Mercurio” en un nodo de química y “Mercurio”
junto a “planeta” deben dar explicaciones distintas. La implementación de referencia de Curio
omite el contexto de la clave y ése es su bug de diseño; aquí no se replica.
3.5 Las dos tablas de instrumentación que el diseño da por hechas
Estas dos no existen en el repo (verificado: 20 tablas, ningún llm_usage_log ni audit_log en
src/ ni en 0001..0004), y sin embargo tres mitigaciones de §14.1 y la decisión abierta #1 de
§14.2 dependen de ellas. Se crean en 0005 en lugar de seguir citándolas:
CREATE TABLE llm_usage_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
use_case text NOT NULL, -- decide_formato|runtime_activity_authoring|genera_ui|explain|probe_generate|schema_design
purpose text NOT NULL, -- runtime_fast|runtime_heavy|generation|eval|tutor
model text NOT NULL,
tier text CHECK (tier IS NULL OR tier IN ('fast','heavy')),
tokens_in int,
tokens_out int,
duration_ms int,
ok boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_llm_usage_case ON llm_usage_log(org_id, use_case, created_at DESC);
CREATE TABLE audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
actor_id uuid REFERENCES users(id) ON DELETE SET NULL,
action text NOT NULL, -- course_schema_validated|course_schema_unvalidated|node_waived
subject text NOT NULL, -- "course:{uuid}" | "node:{uuid}"
detail jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_log_subject ON audit_log(org_id, subject, created_at DESC);
llm_usage_log es pequeña y load-bearing: es la única forma de decidir §14.2 #1 (ratio real
fast/heavy) con datos en lugar de con la hipótesis 90/10. La escribe un solo sitio, un wrapper
log_usage() alrededor de las llamadas de los nodos nuevos; los nodos v1 no se instrumentan en este
PR. audit_log.detail en course_schema_validated guarda el diff propuesto→validado (nodos
añadidos, borrados, campos editados): así se puede medir si los creadores editan de verdad lo que
propone el LLM, que es el riesgo “Alta” de §14.1 que ninguna validación estructural cubre.
3.6 Resumen del delta
13 tablas nuevas: course_nodes, course_node_prerequisites, learner_profiles,
learner_node_states, learning_events, node_renders, node_render_views, node_probes,
node_attempts, node_feedback, term_explanations, llm_usage_log, audit_log.
8 enums nuevos: course_delivery_mode, course_schema_status, node_criticality,
learner_experience, node_state, error_kind, ui_format, node_render_status.
1 tabla alterada: courses (+6 columnas). 1 enum extendido: generation_step (+2 valores).
node_stateconserva el miembroneeds_review, pero su único productor en este PR es el tope de pistas de §7.4. No hay scheduler de repetición espaciada (§1.3), así que la transiciónmastered → needs_reviewno ocurre y no aparece en la tabla de §7.3.
4. Pipeline LangGraph
Dos grafos nuevos. El grafo v1 (src/agents/content/) no cambia de comportamiento, pero sí
recibe un refactor de extracción sin cambio funcional (ver más abajo). La afirmación de la
versión anterior de este documento — que prepare_context y extract_themes “no escriben en BD” y
podían importarse tal cual — era falsa, y con ella caían dos piezas del diseño:
nodes.py:177-178haceawait _set_job(job_id, status=GenerationStep.EXTRACTING)yawait _publish_step(job_id, "extracting", …);nodes.py:215-216hace lo mismo conSTRUCTURING. Importarlos enbuild_schema_graph()pondría el job de esquema en'extracting'/'structuring'— nunca en los'schema_proposing'/'schema_proposed'que añade la migración — y emitiría eventosstepgenéricos.- El canal está fijado en
src/agents/content/errors.py:26-27(return f"generation:{job_id}"), así que esos eventos irían ageneration:{job_id}, que ningún cliente de esquema escucharía.
Decisión, una sola: se extraen las partes puras a src/agents/content/helpers.py
(estimate_pages, assemble_chunk_text, themes_list) y src/agents/content/nodes.py las importa
desde ahí. Es un movimiento de código sin cambio de comportamiento, cubierto por
tests/test_generation_pipeline.py, que ya existe. Los nodos del grafo de esquema son nuevos y
viven en src/agents/schema/nodes.py: reutilizan esos helpers, THEME_EXTRACTOR_SYSTEM y
build_extraction_prompt, y escriben sus propios estados y eventos. Nada de importar los nodos
v1. src/agents/content/helpers.py y nodes.py van en la lista de ficheros de B2.
4.1 Design-time: build_schema_graph()
src/agents/schema/{state.py,nodes.py,graph.py,runner.py,errors.py}
load_source ──► extract_themes_schema ──► design_schema ──► persist_schema ──► END
(NUEVO, usa (NUEVO, usa (NUEVO, LLM) (NUEVO, DB)
helpers puros) helpers + prompt v1)
└──────────────── on error ────────────────► handle_error ──► END
class SchemaState(TypedDict, total=False):
# Identity
job_id: str
org_id: str
triggered_by: str
# Inputs
source_document_ids: list[str]
course_id: str
intent_density: int
# Derived from the source (same shape as v1, computed by the new nodes)
rag_mode: Literal["full_text", "chunked"]
full_texts: dict
extracted_themes: list[dict]
source_metadata: dict
available_headings: list[str] # lista cerrada de headings reales del documento
# New
proposed_nodes: list[dict] # título, summary, outcome, criticality,
# prerequisites (índices), source_headings
schema_warnings: list[str]
# Control
error: str | None
current_step: str
design_schema hace una llamada LLM (SCHEMA_DESIGNER_SYSTEM, json_mode=True,
temperature=0.2, purpose generation) y devuelve nodos con prerrequisitos expresados como
índices de la propia lista, no como uuids — el LLM no puede inventar uuids. persist_schema
los traduce a FKs, ejecuta un orden topológico, poda las aristas que crearían ciclos (añadiendo
un aviso a schema_warnings en lugar de fallar) y escribe con schema_status = 'proposed'.
source_headings se elige de una lista cerrada, no se inventa. load_source recoge los
headings reales (chunk_metadata->>'heading' distintos del documento, que src/services/chunker.py
guarda como un string por chunk) en available_headings, y el prompt de design_schema obliga a
elegir sólo de esa lista; persist_schema descarta cualquier heading fuera de ella y lo anota en
schema_warnings. Sin esto, un heading inventado por el LLM no coincide con ningún chunk y
load_context (§4.2) entrega una fuente vacía a genera_ui — un fallo silencioso que produce
contenido plausible sin base documental.
Checkpointer: MemorySaver, igual que v1. El job es corto y su estado real vive en
generation_jobs + course_nodes. No se introduce langgraph-checkpoint-postgres en este PR
(dependencia nueva, y el gate humano ya es un estado de BD, no un interrupt).
Canal SSE: f"generation:{job_id}", el mismo que v1 — no schema:{job_id}. Razón concreta: el
endpoint que se reutiliza tiene el canal hardcodeado (src/routes/generation_jobs.py:42,
async for event in subscribe(f"generation:{job_id}")), así que un canal propio no llegaría a
ningún cliente sin reescribir el endpoint para suscribirse a dos canales. Los eventos ya van
namespaced por tipo, así que compartir canal no colisiona: schema_step, schema_progress,
schema_ready, error. Lo único que cambia en el fichero de rutas es
_TERMINAL_EVENTS = {"completed", "error", "schema_ready"} (línea 18).
4.2 Runtime: build_node_graph()
src/agents/runtime/{state.py,nodes.py,graph.py,router.py,runner.py}
load_context
│
▼
probe_gate ──(mastered)──────────────────────────────────► skip_node ──► END
│ needs_content
▼
decide_formato ──► genera_ui ──► validate_ui ──┬─(ok)─────► persist_render ──► END
▲ │
└──(invalid, retry<1)───┤
└─(fail)──► fallback_seed ──► END
class NodeRuntimeState(TypedDict, total=False):
# Identity
request_id: str
org_id: str
user_id: str
course_id: str
node_id: str
# Loaded context
node: dict # title, summary, outcome, criticality, source_headings
profile: dict # role_title, sector, goal, experience_level, preset,
# format_vector, nodes_completed, tutor_notes
node_state: dict # mastery, state, consecutive_*, last_error_kind
source_context: str # texto de la fuente (RAG o full_text), ya recortado
# Gate
mastered: bool
# Router
ui_format: Literal["explanation", "simulation", "exercise", "chart", "mixed"]
tier: Literal["fast", "heavy"]
format_rationale: str
# Generation
backend: str # "openui" (único dialecto en este PR)
effective_density: int # intent_density del curso, acotado por short_blocks (§3.1)
scaffold_band: str # novice|neutral|advanced, fijado al cerrar el probe
raw_dsl: str
ui_spec: dict | None
answer_key: dict
validation_errors: list[str]
retry_count: int
# Output
cache_key: str
render_id: str | None
tokens_in: int
tokens_out: int
# Control
error: str | None
current_step: str
Cada nodo va envuelto en un wrapper propio y nuevo, src/agents/runtime/errors.py:: runtime_node_error_wrapper. No se adapta el de v1: src/agents/content/errors.py:47-66 está
duramente acoplado a state["job_id"] y a marcar una fila de generation_jobs como failed, y
NodeRuntimeState no tiene job_id ni fila de job. Con job_id vacío el wrapper v1 se salta tanto
el bookkeeping como el sse.publish, de modo que el contrato error {fallback: true} que el
frontend espera en §9.2 no se emitiría nunca ante un fallo de nodo. El wrapper nuevo:
# src/agents/runtime/errors.py
def runtime_node_error_wrapper(name: str): # keyed on request_id, not job_id
# on exception: node_renders.status = 'failed' + error_message
# sse.publish(f"node:{request_id}", "error",
# {"step": name, "message": …, "fallback": True})
# return {"error": …, "current_step": "failed"}
Cada nodo abre su propia sesión con async_session_factory, igual que v1.
| Nodo | Qué hace | LLM |
|---|---|---|
load_context |
Carga nodo, perfil, estado, y la fuente (ver nota de abajo). Calcula effective_density y cache_key. Si hay hit en node_renders con status='ready' y NOT is_preview, corta antes de entrar al grafo (lo comprueba el servicio, no el grafo) |
— |
probe_gate |
Lee learner_node_states.state. Si 'mastered' → salta |
— |
decide_formato |
Decide ui_format y llama al router para el tier |
Sí, tier fast |
genera_ui |
Pide al modelo el dialecto del backend activo, usando src.render.prompt.render_prompt() (el artefacto generado por library.prompt()) como parte del system prompt |
Sí, tier del router |
validate_ui |
gate.canonicalize(raw_dsl): topes de tamaño y rechazo de reactividad → backend.parse() → UISpec (las 7 reglas) → serialize() → el dialect canónico. Separa answer_key |
— |
persist_render |
Escribe node_renders (status='ready') con dialect, catalog_version y library_version, publica ui_done |
— |
fallback_seed |
Construye un ui_spec de un solo bloque Markdown con lessons.content del seed_lesson_id (o el source_context recortado). status='fallback' |
— |
skip_node |
Marca el nodo como saltado y publica node_skipped |
— |
La fuente en load_context, con camino de implementación real. La versión anterior decía
“similarity_search(top_k=8) … filtrando por source_headings”, y ese filtro no existe:
src/repositories/document_chunk_repo.py:73-97 sólo acepta
org_id / query_embedding / top_k / document_ids. Se añade en B1 un método nuevo al repositorio
(no se toca el existente, que lo usan rutas v1):
async def similarity_search_by_headings(
self, *, org_id, query_embedding, top_k=8,
document_ids=None, headings: Sequence[str] | None = None,
) -> list[dict]:
"""Igual que similarity_search, con
AND (chunk_metadata->>'heading') = ANY(:headings) cuando headings no es vacío."""
Ramas, explícitas:
- Documento con ≤5 páginas →
full_text. Esta rama no necesita embeddings, y es la que cubren los tests con fixtures (§12.1). - Documento mayor →
similarity_search_by_headings(headings=node.source_headings). Necesita un embedding real de la query y chunks conembeddingno nulo (DocumentChunk.embeddingesVector(...)nullable=False), así que sólo funciona con embedder disponible. Siheadingsno devuelve nada, se reintenta sin el filtro de headings y se anota un aviso en el log estructurado.
Presupuestos: decide_formato max_tokens=256, temperature=0.0, json_mode=True.
genera_ui max_tokens=1200 (tier fast) / 2400 (tier heavy), temperature=0.4. MAX_UI_RETRIES = 1.
Concurrencia global de generación runtime: asyncio.Semaphore(6) en el runner, para que un pico
de empleados no tumbe el proceso.
4.3 El router de dos niveles
src/agents/runtime/router.py
HEAVY_FORMATS = frozenset({"chart", "mixed", "simulation"})
ALLOWED_UI_FORMATS = frozenset({"explanation", "exercise", "chart", "mixed"}) # simulation OFF
# No hay FAST_FORMATS: select_tier sólo consulta HEAVY_FORMATS, así que una segunda
# constante sería código muerto que puede desincronizarse.
def select_tier(ui_format: str) -> Literal["fast", "heavy"]:
return "heavy" if ui_format in HEAVY_FORMATS else "fast"
def purpose_for(tier: str) -> str:
return "runtime_heavy" if tier == "heavy" else "runtime_fast"
Enrutamiento a nivel de UI completa, no por componente. Se conecta al mecanismo de purposes
que ya existe en resolve_llm_config(org_settings, purpose=...), así que sólo hay que añadir dos
env vars y dos claves de org settings:
# src/config.py — Settings
LLM_RUNTIME_FAST_MODEL: str | None = None # ej. "groq/llama-3.1-8b-instant"
LLM_RUNTIME_HEAVY_MODEL: str | None = None # ej. "groq/openai/gpt-oss-120b"
Precedencia (la que ya implementa resolve_llm_config, sin tocarla):
org_settings["llm_runtime_fast_model"] → org_settings["llm_model"] →
LLM_RUNTIME_FAST_MODEL → LLM_MODEL. Si no se configura nada, ambos tiers caen en
LLM_MODEL y todo sigue funcionando con un solo modelo. Ningún proveedor concreto es requisito.
Se registra cada llamada en llm_usage_log (tabla creada en 0005, §3.5) con use_case ∈
{decide_formato, runtime_activity_authoring, genera_ui, explain, probe_generate, schema_design} para medir el ratio real
fast/heavy (la estimación 90/10 es una hipótesis, no un dato).
Single-tenancy: se documenta, no se arregla aquí. src/deps/llm.py:22-25::_org_settings y
SettingsService._get_org hacen select(Organization).limit(1). Arreglarlo de verdad significa meter
un CurrentUser en LLMDep/TutorLLMDep/EmbeddingDep/OptionalLLMDep, que consumen rutas v1
(src/routes/chat.py:30,47, src/routes/exercises.py:61) sin un solo test que las cubra hoy
(tests/ tiene test_chunking, test_generation_pipeline, test_grading,
test_retrieval_assembly, test_skill_service). Cambiar firmas de dependencias de rutas v1 sin red
de seguridad, dentro de un lote presentado como “paralelo y seguro”, es exactamente el tipo de
regresión silenciosa que este PR promete no introducir.
Decisión: sale de B1 y se convierte en su propio chore PR (con tests de ruta para chat y
corrección de ejercicios como parte del mismo). Es seguro diferirlo porque el bootstrap mantiene la
invariante de una organización (ensure_organization en el lifespan), así que hoy
limit(1) resuelve la org correcta por construcción. Los dos purposes nuevos no dependen de ese
arreglo: resolve_llm_config los resuelve por getattr(settings, f"LLM_{purpose.upper()}_MODEL")
(src/llm/client.py:61-67) con independencia de cómo se cargaron los org settings. Queda anotado en
§14.2 #11 con fecha.
5. Capa de render
5.1 Principio
CORREGIDO el 2026-07-26 (decisión de producto: adopción completa de OpenUI —
docs/design/openui-adoption.md). La frase que había aquí, “el navegador nunca recibe markup generado”, ya no es cierta en la parte que importa: el navegador recibe dialecto y lo interpreta con<Renderer>de@openuidev/react-langsobre los componentes que registramos. Lo que sigue siendo literalmente cierto es lo demás: el LLM nunca produce HTML y el navegador nunca recibe el texto que escribió el modelo.
nodo + perfil + fuente ──► LLM ──► DIALECTO (texto crudo, NO SE SIRVE NUNCA)
│
├─► gate.check_program() topes de tamaño; nada de $estado, Query, Mutation, @builtins
├─► backend.parse() gramática congelada + las 7 reglas de §5.2 (Pydantic)
├─► UISpec (jsonb) registro de auditoría, sólo servidor
└─► backend.serialize() ──► node_renders.dialect ──► <Renderer> en el navegador
(+ catalog_version, library_version)
Las tres propiedades que sostienen esto, y ninguna es una promesa de estilo:
- El navegador sólo ve la re-serialización canónica de una
UISpecya validada. Nuncaraw_dsl. La columna ya no se llama así (node_renders.dialect), precisamente para que nadie la sirva por descuido. UnaUISpecno puede representar estado ni una llamada a tool, así que la propiedad es estructural, no una comprobación. - El servidor sigue siendo el que valida. El parser de OpenUI en el cliente pinta; el nuestro
decide. Su parser acepta en silencio enums inventados, tipos incorrectos, ids duplicados,
<script>yMutation("delete_all_users", {...})conmeta.errors=[]; el nuestro no puede ni representarlos. answer_keynunca se serializa. Igual que antes: regla 5 de §5.2, columna aparte, y ningún esquema de respuesta la menciona.
Superficie nueva que esto abre, dicha en voz alta: las mutaciones. El lenguaje real tiene estado
($var), consultas (Query), mutaciones (Mutation), acciones (Action, @OpenUrl,
@ToAssistant, @Set) y 13 builtins. Un PDF envenenado puede intentar emitirlas. Mitigación, en
cuatro controles apilados y medidos (SEGURIDAD-MUTACIONES.md):
| Control | Cómo | Efecto medido |
|---|---|---|
| No servir texto crudo | serialize() desde la UISpec validada |
Las 10 fixtures re-serializadas parsean con 0 violaciones |
toolProvider ausente en el <Renderer> |
por omisión de prop | createQueryManager(null): cero red, queries y mutaciones cortadas |
onAction y onStateUpdate ausentes |
por omisión de prop | @OpenUrl/@ToAssistant son no-ops; @Set no se persiste. Y sin ningún componente que llame a useTriggerAction(), un ActionPlan no es ni alcanzable |
| Puerta en los dos lados | src/render/gate.py + parse() en el servidor; assertStaticOnly(parseResult) en onParseResult en el cliente |
15/15 payloads rechazados, 0 falsos positivos sobre las 10 fixtures válidas |
Y el más barato de todos: el prompt no enseña la reactividad. Sin tools y sin markReactive(),
library.prompt() no menciona $var, Query(, Mutation(, @Run ni @Set. Es mitigación en
profundidad, no barrera: si el modelo la emite de memoria, quien la rechaza es la puerta.
RENDER_ALLOW_REACTIVE=false es el interruptor, y las condiciones para tocarlo están en
docs/design/openui-adoption.md §6.
Se sigue cambiando de dialecto cambiando una env var; el pipeline, la IR y la BD no se enteran. El frontend sí se enteraría ahora: recibe dialecto, no IR.
5.2 La IR canónica: UISpec
Lista plana de componentes con referencias por id (los LLMs generan listas planas mejor que árboles anidados, y así el parseo incremental es trivial).
{
"version": "skillnet-ui/1",
"format": "explanation",
"root": "b0",
"components": [
{"id": "b0", "type": "Stack", "props": {"gap": "md"},
"children": ["b1", "b2", "b3"]},
{"id": "b1", "type": "TextContent",
"props": {"text": "Las devoluciones se aceptan durante 30 días naturales.",
"variant": "body"}},
{"id": "b2", "type": "StepSequence",
"props": {"title": "Proceso de devolución",
"steps": ["Verificar el producto", "Escanear el ticket",
"Registrar en el sistema", "Emitir el reembolso"]}},
{"id": "b3", "type": "QuizItem",
"props": {"item_id": "q1", "item_type": "test", "bloom_level": "apply",
"question": "Un cliente vuelve el día 32. ¿Qué haces?",
"options": ["Aceptar la devolución", "Ofrecer garantía del fabricante",
"Rechazar sin más", "Llamar al encargado"]}}
]
}
Reglas del contrato, validadas por Pydantic en src/render/spec.py:
rootdebe existir encomponentsy ser de tipo contenedor (StackoCard).- Toda referencia en
childrendebe existir. Referencias adelantadas permitidas. - Sin ciclos en el árbol de
children. - Máximo 12 componentes por spec y 5 elementos en el nivel raíz del
Stack. No es estética: la memoria de trabajo procesa 4-7 elementos, y una “pantalla cognitiva” son 3-5 elementos relacionados. Un spec con 30 bloques es un fallo de generación, no contenido rico. QuizItemno lleva respuesta correcta ni explicación. Eso va aanswer_key. Corolario, demostrado (2026-07-26): la corrección 100 % en cliente es incompatible con esta regla por construcción — escribir el veredicto como$elegida == 1exige serializar la respuesta al navegador. No es un defecto de OpenUI Lang, es aritmética. La vía que sí la respeta esMutation("grade_answer", {item_id, choice})con un viaje al servidor, y está apagada (§5.1).props.textes texto plano o markdown inline (**,*,`, links). Nunca HTML.- El primer hijo de
rooten los formatosexplanationymixeddebe ser unTextContent(variant: "lead") o unCallout. Es un error de validación, no un aviso, y es el hueco donde el frontend inyecta la línea “esto te sirve para X” derivada degoal(§6.2 Q2). Sin esta regla la promesa de la pregunta 2 no tenía ningún sitio donde materializarse.
Heurística de calidad (no regla del contrato): dos componentes hermanos con similitud de tokens
0.8 dicen lo mismo dos veces (efecto de redundancia).
validate_uilo anota en el log estructurado y ennode_renders.error_messagecomo aviso, y no rechaza el spec. Se deja explícitamente fuera del contrato porque medir “decir lo mismo en dos formatos” con similitud de tokens tiene falsos positivos obvios (una tabla que resume los pasos que el texto acaba de enumerar es redundancia buena). Si los datos de §14.2 muestran que ocurre a menudo con daño real, se promueve a error; hasta entonces no se presenta como contrato cumplido.
answer_key, guardado aparte y nunca serializado al cliente:
{"q1": {"item_type": "test", "correct": 1,
"explanation": "Manual p.3: pasados 30 días aplica la garantía del fabricante",
"bloom_level": "apply"}}
5.3 El SkillNet UI Kit — lista congelada
Actualizado el 2026-07-26. Dónde vive cada cosa desde la adopción:
apps/skillnet-web/src/components/courses/kit/— el catálogo, en zod +defineComponent. Es de donde sale el prompt:scripts/generate-openui-prompt.mjsllama alibrary.prompt()y escribeapps/skillnet-api/src/render/openui_prompt.txt+openui_catalog.json. Un solo sitio donde se declara la lista.src/render/kit.py— fuente de verdad de la validación (tipos, enums, orden posicional, las 7 reglas víasrc/render/spec.py). Ya no genera el prompt.tests/test_render_prompt_artifact.pyrecalcula el digest del catálogo desde aquí y falla si el artefacto no coincide: es la alarma de deriva entre los dos lados, y la que avisará el día que su API cambie.apps/skillnet-web/src/components/courses/blocks/— la implementación React, que ahora se registra en la librería de OpenUI en vez de despacharse porswitch.
La librería del navegador registra diez componentes y el catálogo del prompt anuncia nueve:
Markdown lo escribe el servidor para fallback_seed y el modelo no puede emitirlo. Como el
navegador ya recibe dialecto, el fallback también necesita forma de dialecto, así que serialize()
cubre los diez y parse() sigue rechazando Markdown. La asimetría no desapareció: cambió de sitio.
| Componente | Props (orden posicional para el dialecto OpenUI) | Para qué |
|---|---|---|
Stack |
children: string[], gap: "sm"|"md"|"lg" |
Contenedor vertical |
TextContent |
text: string, variant: "body"|"lead"|"caption" |
Prosa |
Card |
title: string, children: string[] |
Agrupar |
Callout |
tone: "info"|"warn"|"success", text: string |
Regla crítica, excepción |
StepSequence |
title: string, steps: string[] |
Procedimiento (2-7 pasos) |
Table |
headers: string[], rows: string[][] |
Comparar conceptos |
CodeBlock |
language: string, code: string |
Ejemplo de código |
Chart |
kind: "bar"|"line", title: string, labels: string[], values: number[] |
Dato cuantitativo |
QuizItem |
item_id: string, item_type: exercise_type, bloom_level: string, question: string, options: string[] |
Ejercicio |
Markdown |
content: string |
Sólo para fallback_seed. El LLM no puede emitirlo |
Decisiones de nomenclatura, cerradas: StepSequence (no StepList); Chart unificado con kind
(no BarChart/LineChart); Callout entra porque las excepciones de procedimiento son el 80 % del
contenido de compliance; Timeline, ImageCard, DragDrop, Simulation y SandboxHTML no
entran.
Los 6 valores de item_type son exactamente los del enum exercise_type existente.
QuizItemBlock es autónomo; no es un puente al ExerciseRenderer de v1. La afirmación anterior
(“se reutilizan tal cual, cambiando sólo el hook de envío”) era falsa: cada uno de los seis
componentes de ejercicio construye sus propias mutaciones y se apoya en el id de una fila real de
exercises — p. ej. TestExercise.tsx:3 importa useSubmitAttempt/useCorrectExercise, las llama
en las líneas 10-11, hace correctMut.mutate(exercise.id) y usa name={exercise.id} para el grupo de
radios. Convertirlos en componentes controlados exigiría refactorizar los seis para aceptar handlers
inyectados: superficie v1 que ningún lote presupuesta. Decisión: B6 escribe
QuizItemBlock.tsx con su propio estado y su propio envío contra POST /nodes/{id}/answer
(2 subcomponentes internos para selección única y texto), y no toca src/components/exercises/.
Se acepta la duplicación de ~120 líneas de UI a cambio de no tocar v1.
5.4 La interfaz del adaptador
src/render/backends/base.py
class RenderBackend(Protocol):
name: str # "openui" (el registro admite más)
# prompt_fragment() SE ELIMINÓ el 2026-07-26: el prompt lo genera library.prompt()
# en el paso de build y lo lee src/render/prompt.py. Un backend valida un dialecto
# y lo vuelve a escribir; ya no lo enseña.
def parse(self, raw: str, *, ui_format: str | None = None) -> UISpec:
"""Parsea el dialecto completo. Lanza RenderParseError."""
def parse_partial(self, raw: str, *, ui_format: str | None = None) -> UISpec:
"""Parseo tolerante de salida incompleta (streaming). Descarta la última
línea si está a medias. Nunca lanza. Sigue siendo necesario en el servidor
aunque el navegador también parsee: lo que se le manda en streaming es la
re-serialización canónica del prefijo, nunca los bytes del modelo."""
def serialize(self, spec: UISpec) -> str:
"""Spec -> texto canónico. Lo ÚNICO que el cliente puede recibir."""
# src/render/backends/__init__.py
_BACKENDS = {"openui": OpenUiLangBackend()} # un solo dialecto en este PR
def get_render_backend(name: str | None = None) -> RenderBackend:
return _BACKENDS[(name or settings.RENDER_BACKEND)]
El parseo de validación es Python, en el backend; el de pintado es JavaScript, en el navegador.
De las tres consecuencias que este párrafo reclamaba, (b) sigue en pie — las fixtures cubren el parser
sin navegador — y las otras dos cambiaron el 2026-07-26: (a) sí entran dependencias npm
(@openuidev/react-lang@0.2.9, @openuidev/lang-core@0.2.10, zod@4.4.3, versiones exactas), y (c)
el navegador ya no recibe JSON, recibe dialecto. Lo que ocupa el sitio de (c) son las tres
propiedades y los cuatro controles de §5.1.
Backend 1 — openui (DEFAULT). src/render/backends/openui.py. Dialecto línea a línea, una
declaración por línea, argumentos posicionales en el orden de la tabla del kit, referencias por
array:
root = Stack([intro, steps, quiz], "md")
intro = TextContent("Las devoluciones se aceptan durante 30 días naturales.", "body")
steps = StepSequence("Proceso de devolución", ["Verificar el producto", "Escanear el ticket", "Registrar en el sistema", "Emitir el reembolso"])
quiz = QuizItem("q1", "test", "apply", "Un cliente vuelve el día 32. ¿Qué haces?", ["Aceptar la devolución", "Ofrecer garantía del fabricante", "Rechazar sin más", "Llamar al encargado"])
Elegido como default por densidad de tokens (≈50 % menos que JSON equivalente) y porque el formato
línea a línea permite parse_partial trivial: cada \n completa un componente. El nombre de la
variable es el id del componente en la IR.
Gramática congelada. Vive en el docstring de src/render/backends/openui.py y ya no es una
constante GRAMMAR que se pegue en el prompt: el bloque de sintaxis lo pone library.prompt(). Sigue
siendo la especificación de la puerta, y es lo que hace que la reactividad sea inexpresable en vez
de estar en una lista negra. Un dialecto “obvio” con un ejemplo y sin reglas es lo que un modelo de 8B
rompe el primer día; estas tres son exactamente las que rompe, y van al prompt por
additionalRules:
program = { line } ;
line = ident "=" call newline ;
ident = ("a".."z" | "A".."Z" | "_") { "a".."z" | "A".."Z" | "0".."9" | "_" } ;
call = comp_name "(" [ arg { "," arg } ] ")" ;
comp_name = "Stack" | "TextContent" | "Card" | "Callout" | "StepSequence"
| "Table" | "CodeBlock" | "Chart" | "QuizItem" ;
arg = string | number | array | ident | call ;
array = "[" [ arg { "," arg } ] "]" ;
string = '"' { char | escape } '"' ;
escape = "\" ( '"' | "\" | "n" ) ;
char = <cualquier carácter excepto '"', '\' y newline> ;
number = [ "-" ] digit { digit } [ "." digit { digit } ] ;
arg = … | call es de 2026-07-27 (docs/design/openui-adoption.md §4 bis). Una llamada anidada
en línea, root = Stack([TextContent("Hola.", "lead")], "md"), es OpenUI Lang válido y el bloque de
firmas que genera library.prompt() la ofrece; rechazarla era un subconjunto nuestro, no una regla
del estándar, y le costó el bucle de reparación entero a un modelo de 7B. parse la aplana a la
lista plana de la UISpec con ids sintéticos deterministas (root_1, root_1_1, …), así que la
regla 4 de §5.2 se cuenta después de aplanar; sólo tiene sentido donde el kit declara un ref[]
(Stack.children, Card.children) y en cualquier otra posición es un error con nombre propio; y la
profundidad por línea está topada (16) para que la recursión no sea un vector de caída. serialize
sigue emitiendo sólo la forma referenciada: es la forma canónica, es la que su prompt recomienda
para streaming y es la única que da a cada bloque un statementId real en el navegador.
Las tres reglas que el prompt repite en imperativo y que las fixtures malformadas cubren una a una:
- Comilla doble dentro de un string:
\". Nunca sin escapar. - Arrays anidados permitidos y obligatorios en
Table.rows(string[][]):Table("t", ["A","B"], [["1","2"],["3","4"]]). - Ningún salto de línea literal dentro de un string — se escribe
\n. Es una restricción dura, no estética: toda la premisa deparse_partiales que cada\ncierra un componente, así que un salto literal dentro de una comilla rompe el parseo incremental, no sólo el completo.parse()rechaza el string sin cerrar al final de línea;parse_partial()descarta esa línea.
No hay segundo backend en este PR. El seam sí entra (el Protocol y el registro), el segundo
dialecto no — ver §1.3 para la razón (UIDL/1 no puede representar la UISpec). Consecuencias
concretas, todas aplicadas más abajo: RENDER_BACKEND_FALLBACK desaparece de §10.2; el
“reintento cruzado” desaparece de §14.1; y tests/test_render_a2tl.py no existe.
Qué ocupa su sitio como segundo intento (que era el valor real del reintento cruzado): en el
único reintento (MAX_UI_RETRIES = 1) el prompt no es el mismo. Se envía
UI_REPAIR_SYSTEM, que incluye el raw_dsl fallido, los validation_errors exactos del parser y la
instrucción de devolver el programa corregido y nada más, con temperature=0.0. Reparar con el error
en mano es más efectivo que reintentar a ciegas o cambiar de dialecto. Si también falla:
fallback_seed. La red de seguridad de “nunca una pantalla roja” la da fallback_seed, no el
segundo dialecto.
5.5 Lado frontend
CORREGIDO el 2026-07-26. El despacho por
switchsobre unaUiSpeclo sustituye el<Renderer>de@openuidev/react-langsobre la librería desrc/components/courses/kit/: los mismos diez componentes de bloque, registrados en vez de despachados, y el streaming lo llevaisStreaming. Las props que no se pasan son parte del contrato de seguridad (§5.1): sintoolProvider, sinonAction, sinonStateUpdate. El bloque de abajo describe la forma anterior y se conserva porque las zonas de estabilidad espacial, el pinning deactive_render_idy las dos afordancias de control no cambian.
apps/skillnet-web/src/components/courses/UiSpecRenderer.tsx — mismo patrón de dispatch que
ExerciseRenderer:
export function UiSpecRenderer({ spec, nodeId }: { spec: UiSpec; nodeId: string }) { … }
// switch (component.type) → blocks/StackBlock, TextContentBlock, CardBlock,
// CalloutBlock, StepSequenceBlock, TableBlock, CodeBlockBlock, ChartBlock,
// QuizItemBlock, MarkdownBlock
ChartBlock se dibuja con SVG inline (no se añade Chart.js ni Recharts). MarkdownBlock reutiliza
LessonContent, que se conserva intacto y pasa a ser también el fallback. Un componente de tipo
desconocido renderiza null y registra un aviso — nunca rompe la página.
Zonas de estabilidad espacial, obligatorio en NodeView.tsx:
| Zona | Contenido | Puede cambiar |
|---|---|---|
| Congelada | Header, título del nodo, ProgressBar, botones anterior/siguiente, acceso al chat |
Nunca |
| Estable | Orden y contenido de los bloques mientras el nodo está abierto | Sólo si el usuario lo pide |
| Adaptativa | Qué ejemplos, qué formato, qué profundidad, qué dificultad, entre nodos y sesiones | Libremente |
Cómo se garantiza la fila “Estable” (antes era una promesa sin mecanismo):
GET /nodes/{id}/render sirve learner_node_states.active_render_id mientras render_pinned sea
true. No recalcula la clave, así que responder un ítem no puede cambiar la pantalla, y un refetch de
TanStack Query al recuperar el foco de la ventana devuelve byte a byte lo mismo.
Control del usuario, dos afordancias mínimas (no opcionales; son la contrapartida de adaptar nada):
- “Actualizar esta lección” — un botón en el pie del nodo que hace
POST /nodes/{id}/render {"force": true}y repintaactive_render_id. Es la única vía por la que el contenido de un nodo abierto cambia. - “Ver la versión anterior” — al regenerar, se muestra una línea “esta lección se ha adaptado a
tus últimas respuestas” con enlace al render previo. Las filas ya existen en
node_rendersynode_render_views(ordenadas porfirst_seen_at DESC), así que es una consulta, no una tabla nueva.
En una revisita a un nodo ya visto se sirve el último render de ese (user, node), no uno nuevo,
aunque el perfil haya cambiado. Regenerar requiere el botón.
6. Onboarding
6.1 Forma
5 pantallas, una pregunta por pantalla, máximo 3 elementos visibles por pantalla, objetivo
≤90 segundos. Saltable en cualquier momento con “Lo hago luego”. Se pregunta una vez: al
saltar se escribe onboarding_completed_at y onboarding_skipped = true, y no se vuelve a
preguntar (se puede rehacer desde ajustes). El límite de 5 pantallas y de 3 elementos viene de las
adaptaciones de atención documentadas (tests de 5 preguntas máximo, 3 bullets por pantalla).
Saltar escribe experience_level = 'unknown', no 'none' (§3.3): 'none' significa “declara ser
novato” y fuerza andamiaje de novato, que es el caso que perjudica al experto. Quien salta no ha
declarado nada.
Gate en ProtectedRoute, con los detalles que importan. ProtectedRoute sólo tiene hoy
useAuth() (user, isLoading), y envuelve también todas las páginas de admin, así que:
redirige a /onboarding ⇔ features.dynamic_courses === 'on'
∧ user.role === 'employee'
∧ perfil cargado con onboarding_completed_at == null
- El flag se lee de
GET /healthuna vez al arrancar (ver §10.1 — no de/auth/me). - La query del perfil va condicionada a
role === 'employee' && flag === 'on', para que un admin nunca la dispare. - Un 404 del endpoint de perfil significa “no redirigir”, no “no onboardeado”. Si significara lo segundo, apagar el flag a mitad de sesión (las rutas pasan a 404) metería al usuario en un bucle de redirección hacia una ruta que ya no existe.
- Mientras la query está en vuelo se pinta
AppSkeleton, nunca se redirige.
6.2 Las preguntas
| # | Pregunta (es) | Tipo | Campo | Por qué esta y no otra |
|---|---|---|---|---|
| 1 | “¿Cuál es tu puesto?” | texto libre + 6 sugerencias del sector de la org | learner_profiles.role_title, sector |
El rol entra literalmente en el system prompt de genera_ui para contextualizar los ejemplos. Es adaptación de contenido, la que tiene evidencia fuerte |
| 2 | “¿Para qué quieres usar SkillNet ahora mismo?” | 3 opciones + “otro” | goal |
Principio de andragogía: el adulto necesita saber el POR QUÉ antes de invertir tiempo. No viaja al LLM. Se renderiza como línea de apertura determinista en el bloque lead que la regla 7 de §5.2 obliga a que exista (plantilla por valor de goal, en el cliente). Así la promesa se cumple siempre, no cuando el modelo se acuerda |
| 3 | “¿Cuánta experiencia tienes en tu puesto actual?” | Ninguna / Algo / Bastante | experience_level |
El conocimiento previo es la única dimensión con efecto grande: invierte el diseño instruccional (los ejemplos resueltos ayudan al novato y perjudican al experto). La pregunta no nombra ningún curso: el campo es uno por persona (UNIQUE (user_id)) y entra en la cache_key de todos sus cursos, así que preguntar por “Atención al cliente” y luego aplicarlo a “Prevención de incendios” era incoherente. La granularidad por competencia la aporta user_skills vía course_nodes.skill_id (§7.1), no la declaración |
| 4 | “¿Cómo prefieres estudiar?” | Estándar / Concentración / Ritmo rápido, con una línea de descripción cada uno | preset (+ espejo en users.learning_profile) |
Es presentación, no modalidad. Da autonomía real y es reversible sin restricciones |
| 5 | “¿Quieres activar algún ajuste de lectura? (opcional)” | checkboxes: bloques cortos · menos animaciones · más contraste · sin límite de tiempo | users.accessibility |
Sin diagnóstico, sin etiqueta. “Leer en voz alta” se elimina: no hay TTS en este PR ni componente de audio en el kit, y ofrecer una acomodación inexistente es peor que no ofrecerla. “Bloques más cortos” sí es real: se traduce a effective_density ≤ 2 en el servidor (§3.1) |
6.3 Lo que no se fuerza durante este onboarding
- Nivel inicial mediante test. El sistema ajusta por rendimiento; el pre-assessment por nodo ya hace ese trabajo, y mejor, porque es por competencia y no global.
- Formato preferido (¿vídeo, texto, audio?). No se fuerza como pregunta inicial ni se convierte
en una etiqueta de “estilo de aprendizaje”. Sin embargo, cualquier elección explícita posterior
se respeta y prevalece sobre el
format_vector. La modalidad pedida puede combinar distintas estrategias pedagógicas —recuperación, autoexplicación, contraste o escenario— sin sustituir la elección del usuario. Véaseadaptive-learning.md. - Diagnósticos de neurodivergencia. Un diagnóstico es dato de salud (categoría especial, art. 9 RGPD) y no hace falta: los ajustes concretos de la pregunta 5 producen el mismo resultado funcional sin el riesgo legal. La pregunta 5 pregunta por necesidades, no por condiciones.
6.4 Periodo de calibración
Con un usuario nuevo el format_vector es todo ceros: no hay señal. Regla dura, implementada en
decide_formato:
if profile["nodes_completed"] < 3:
vector_bucket = "" # no entra en la cache_key
ui_format = node.default_ui_format # columna real, §3.2 — no se llama a decide_formato
# el prompt recibe SOLO: role_title, sector, experience_level, preset,
# effective_density y scaffold_band
Qué significa exactamente “no se adapta”, con la frontera dibujada — la versión anterior decía “no
se adapta la presentación” mientras §4.2 metía node_state completo en el prompt desde el nodo 1, lo
cual se contradecía:
| Dimensión | ¿Actúa durante la calibración? |
|---|---|
Formato (ui_format: explicación vs ejercicio vs tabla) |
No. Se usa node.default_ui_format |
Bucket de vector implícito (vector_bucket en la clave y en el prompt) |
No. Los eventos se acumulan y no se usan |
| Contenido (rol, sector, fuente) | Sí, desde el primer nodo |
Andamiaje (scaffold_band, last_error_kind, consecutive_failed) |
Sí. Es dificultad y apoyo, no disposición espacial: responder al error del alumno no mueve la interfaz de sitio |
La razón de la primera fila es la lección del fracaso de los menús adaptativos de Office 2000-2003: el usuario debe formar su mapa mental antes de que la interfaz empiece a moverse.
Distribución esperada de nodos y su consecuencia incómoda. Un curso de compliance típico tiene
3-6 nodos; los cursos de proceso, 6-12. Con un curso de 3 nodos y una sola asignación, el
subsistema de format_vector (endpoint de eventos, decaimiento, normalización L1, vector_bucket,
purga a 90 días) no influye en un solo render: el usuario acaba el curso todavía en calibración.
Se acepta a sabiendas — el vector es infraestructura para el segundo y tercer curso, no para el
primero — y por eso los nodos saltados por el probe no incrementan nodes_completed (§3.3): si lo
hicieran, alguien podría salir de la calibración con cero eventos de interacción y el vector se
aplicaría sobre ruido.
7. Pre-assessment y regla de maestría
7.1 Los ítems
Al abrir un nodo, POST /nodes/{node_id}/probe devuelve 2 ítems (3 en nodos critical):
- Ítem A —
bloom_level = "apply", de tipotestcon exactamente 4 opciones. Un caso, no una definición. Es el que decide. - Ítem B —
bloom_level = "understand", de tipotestcon exactamente 4 opciones. Ya no se admitetrue_false: con verdadero/falso el suelo del azar sube de 6.25 % a 12.5 %, y el número que §7.2 usa para justificar la banda de duda dejaba de ser cierto. - Ítem C (desempate) — respuesta construida:
fill_blankopractical_casecorto. En un nodocriticales obligatorio siempre; en el resto sólo si el veredicto cae en la banda de duda.
Origen de los ítems, por orden de preferencia:
- Pre-generados en la validación del esquema y guardados en
course_nodes.probe_items/probe_answer_key(§3.2). Es el caso normal: cero tokens y cero espera. Los ítems dependen sólo de(node, source), así que una generación por nodo sirve a toda la organización. - Si el nodo tiene
seed_lesson_idy no hay pre-generados, se muestrean ejercicios existentes de esa lección con los niveles Bloom pedidos. Cero tokens. - Último recurso: una llamada LLM (purpose
runtime_fast,json_mode,max_tokens=500) desdenode.summary+source_context, y se escriben encourse_nodes.probe_itemspara que el siguiente empleado no la pague.
Se registra el intento en node_probes con answer_key separado, una sola fila puntuada por
(user_id, node_id, schema_version) — ver §3.4 para la regla anti-reintento completa, que es lo que
impide saltarse un nodo a base de reentrar hasta acertar por azar.
Prior desde user_skills, en vez de arrancar todo el mundo en 0. course_nodes.skill_id existe y
user_skills.skill_level ya lleva un nivel verificado (incluida la verificación por par/responsable).
Al crear learner_node_states se siembra:
mastery_prior = {"high": 0.85, "medium": 0.55, "low": 0.25}.get(user_skill_level, 0.0)
Es sólo el punto de partida del EWMA y del scaffold_band; no salta el nodo por sí mismo (eso lo
decide el probe). Hasta ahora §7 sólo escribía en user_skills y nunca lo leía, desperdiciando la
única señal de dominio previo que el producto ya tenía.
Probe diagnóstico para el novato declarado. Si experience_level == 'none' y
nodes_completed == 0, el probe del primer nodo se sirve con scored = false: se presenta como
“vamos a ver qué te suena ya”, no persiste fallos, no puntúa maestría y no consume el intento
único. Sin esto, la primera experiencia del producto para quien acaba de declararse novato son N×2
fallos garantizados antes de ver una sola línea de contenido.
7.2 La regla, computable
Punto de partida honesto: los cuatro tipos deterministas puntúan 0.0 o 1.0, sin crédito
parcial (verificado en exercise_service.py:25-57, fill_blank incluido). Con dos ítems binarios,
0.6a + 0.4b sólo puede valer {0.0, 0.4, 0.6, 1.0}. Consecuencia de la versión anterior de esta
regla: los tres umbrales (0.90 / 0.80 / 0.70) se comportaban idénticamente — 1.0 dominaba en los
tres, 0.6 caía en desempate en los tres, 0.4 y 0.0 aprendían en los tres — y el desempate
0.5a+0.2b+0.3c topaba en 0.80, por debajo del 0.90 de critical, así que en un nodo crítico era
código muerto: una llamada LLM y una pregunta extra que no podían cambiar el veredicto.
# src/services/mastery_service.py
THRESHOLDS = {"critical": 0.90, "recommended": 0.80, "contextual": 0.70}
DOUBT_BAND_FLOOR = 0.55
W_APPLY, W_UNDERSTAND = 0.6, 0.4
# Desempate renormalizado: un tercer ítem perfecto llega a 1.0, así que
# TODOS los umbrales son alcanzables y ninguno es inalcanzable.
W3_APPLY, W3_UNDERSTAND, W3_CONSTRUCTED = 0.45, 0.15, 0.40
FADING_STREAK = 3 # N
REGRESS_STREAK = 2
def probe_estimate(score_a: float, score_b: float) -> float:
return W_APPLY * score_a + W_UNDERSTAND * score_b
def probe_verdict(score_a, score_b, criticality, threshold=None):
"""Veredicto con SÓLO los dos ítems de respuesta seleccionada."""
est = probe_estimate(score_a, score_b)
if score_a < 0.5: # falla aplicar → nunca domina
return "learning", est
if est >= 1.0:
# Todo correcto. En un nodo critical NO basta: el azar aquí es 1/16.
if criticality == "critical":
return "tiebreak", est
return "mastered", est
if est >= DOUBT_BAND_FLOOR: # 0.6 → duda
return "tiebreak", est
return "learning", est
def tiebreak_mastery(score_a, score_b, score_c) -> float:
return W3_APPLY * score_a + W3_UNDERSTAND * score_b + W3_CONSTRUCTED * score_c
def tiebreak_verdict(score_a, score_b, score_c, criticality, threshold=None):
m = tiebreak_mastery(score_a, score_b, score_c)
thr = threshold if threshold is not None else THRESHOLDS[criticality]
return ("mastered" if m >= thr else "learning"), m
Aritmética resultante, comprobada (tests/test_mastery.py la asserta caso por caso):
| a | b | c | tiebreak_mastery |
critical 0.90 | recommended 0.80 | contextual 0.70 |
|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 1.00 | domina | domina | domina |
| 1 | 0 | 1 | 0.85 | aprende | domina | domina |
| 1 | 1 | 0 | 0.60 | aprende | aprende | aprende |
| 1 | 0 | 0 | 0.45 | aprende | aprende | aprende |
Los umbrales ahora discriminan de verdad y ninguno es inalcanzable. Cuatro reglas que hacen esto defendible:
- No se puede dominar fallando el ítem de aplicar (
score_a < 0.5→learning, sin más). - En probe de dos ítems seleccionados, el veredicto honesto es “todo correcto = candidato”. Se
dice así en lugar de fingir que un umbral continuo discrimina sobre cuatro valores posibles. El
umbral por criticidad hace su trabajo real en dos sitios: decidiendo si hace falta confirmación
construida, y en la transición
learning → masteredde §7.3. - En un nodo
critical,masterednunca sale de respuesta seleccionada. El desempate construido es obligatorio. Azar combinado: 1/16 × ~0 ≈ 0. Junto con el probe único por versión de esquema (§3.4), saltarse un nodo de seguridad por suerte deja de ser una estrategia. - El umbral depende de la criticidad, no de la persona.
7.3 Maestría durante el nodo
Tras cada POST /nodes/{node_id}/answer:
ALPHA = 0.4 # peso de la evidencia nueva (EWMA)
mastery_new = (1 - ALPHA) * mastery_old + ALPHA * score
if passed:
consecutive_correct += 1; consecutive_failed = 0
# TECHO DE MAESTRÍA: sin esto el EWMA converge a la media de los scores,
# así que quien puntúa 0.85 de forma sostenida asintota en 0.85 y NUNCA
# alcanza el 0.90 de un nodo critical -> curso imposible de completar.
if consecutive_correct >= FADING_STREAK:
mastery_new = max(mastery_new, node.mastery_threshold)
else:
consecutive_failed += 1; consecutive_correct = 0
mastery_new = min(mastery_new, mastery_old) # un fallo nunca sube la maestría
El techo es la corrección de un fallo aritmético real: mastery_new = 0.6·old + 0.4·score tiene punto
fijo en score, así que el alumno competente-pero-imperfecto (0.85 sostenido) se quedaba a 0.05 del
umbral para siempre, y como enrollments.status='completed' exige todos los nodos critical en
mastered, el curso quedaba permanentemente incompleto sin ninguna vía de salida. Con el techo, tres
aciertos consecutivos son la evidencia suficiente: es la misma racha que ya se exigía, aplicada
también a la magnitud y no sólo al contador.
Transiciones de node_state, deterministas y completas (las 8 que cubre
tests/test_mastery.py):
| # | Desde | Condición | Hacia | Efectos |
|---|---|---|---|---|
| 1 | not_started |
se pide el probe | probing |
first_seen_at, mastery = prior de user_skills (§7.1) |
| 2 | probing |
probe_verdict == "mastered" |
mastered |
probe_score = est; mastery = max(prior, est); mastered_at; nodes_completed NO se incrementa |
| 3 | probing |
probe_verdict == "tiebreak" |
probing |
tiebreak_used = true; se sirve el ítem C; probe_score sin escribir aún |
| 4 | probing |
tiebreak_verdict == "mastered" |
mastered |
probe_score = m; mastery = max(prior, m); mastered_at |
| 5 | probing |
probe_verdict/tiebreak_verdict == "learning" |
learning |
probe_score escrito; mastery NO se toca (queda el prior); scaffold_band congelado |
| 6 | learning |
mastery >= threshold y consecutive_correct >= 3 |
mastered |
mastered_at; nodes_completed += 1 |
| 7 | learning |
consecutive_failed >= 2 |
learning |
baja dificultad, no cambia de estado; señal reforzar_con_ejemplo |
| 8 | learning |
4.º fallo del mismo ítem tras 3 pistas (§7.4) | needs_review |
solución trabajada mostrada; el nodo entra en la cola de práctica |
Dos ambigüedades que quedaban abiertas y que afectan a certificados, cerradas arriba: mastery tras
un probe se escribe sólo si el veredicto domina (max(prior, estimate)), y si el veredicto es
learning se conserva el prior — de modo que probe_score y mastery no se pisan; y
nodes_completed se incrementa sólo en la transición 6. Sin fijar ambas, enrollments.score
(media de mastery sobre nodos critical) variaba según detalles de implementación, y eso sale
impreso en un certificado.
mastered → needs_review no existe en este PR: requeriría el scheduler de repetición espaciada,
que no está en el repo (§1.3). El único productor de needs_review es la transición 8.
FADING_STREAK = 3 y REGRESS_STREAK = 2, fijos, iguales para toda criticidad. Se elige el
valor que ya aparece en la investigación (3 aciertos suben, 2 fallos bajan) y no se parametriza por
skill hasta tener datos reales.
Exigir a la vez mastery >= threshold y una racha de 3 evita el problema central del
cognitive offloading: con contenido generado por IA el alumno reporta menos carga cognitiva pero
produce respuestas más débiles — la “ilusión de dominio”. Una racha exige generación repetida, no
un pico afortunado.
7.4 Escalada de andamiaje
Reglas duras en el prompt de genera_ui y en el servicio, no sugerencias:
attempt-before-hint: no se ofrece pista hasta que hay al menos un intento registrado ennode_attemptspara eseitem_id. Un clic-para-explicar dentro de unQuizItemsin responder cuenta como pista y consume cupo — ver §8.5, donde antes era una vía de escape que no tocabahints_used.- Tope de pistas: 3, y con salida definida. Al cuarto fallo se muestra la solución trabajada
completa y el nodo pasa a
state = 'needs_review'(no'learning'), lo que le da tres cosas que antes no tenía — la versión anterior decía “se pasa de nodo” sin definir ningún camino de vuelta:- Visibilidad:
NodeListReadexponeneeds_practice: truey el nodo aparece en una sección “para practicar”, en lugar de desaparecer. - Reentrada: se puede reintentar en cualquier momento (
POST /nodes/{id}/render {force:true}regenera conlast_error_kinden el prompt) y re-probar pasados 7 días (§3.4). - Vía humana:
POST /nodes/{node_id}/waive(rol admin o responsable) ponemasteredconwaived_by/waived_aty una fila enaudit_log(action='node_waived'). Es coherente con el principio “si sabes, sabes” del producto: un humano que ha visto trabajar a la persona puede acreditarla, y queda registrado quién lo hizo. Mientras un nodocriticalesté enneeds_review,enrollments.statusse queda enactiveyNodeListRead.can_completeesfalsecon el nodo listado enblocked_by. El curso no se completa en silencio ni se bloquea en silencio: se ve por qué.
- Visibilidad:
- Clasificación del error →
last_error_kind, que entra en el siguientegenera_ui:detail(typo/formato) → corregir y seguir;procedural→ señalar el paso exacto y repetir;conceptual→ una sola pregunta socrática sobre la parte errónea. - No intervenir por defecto. Si
consecutive_correct >= 1y no hay señal de sobrecarga, el siguiente render no añade andamiaje ni explicaciones extra. El silencio es la opción por defecto. - Sin límite de tiempo en ningún ítem. La presión temporal aumenta la carga cognitiva extrínseca.
7.5 Cierre del curso
enrollments.status = 'completed' cuando todos los nodos critical no archivados del curso están
en mastered (por dominio, por probe o por waive). Los recommended y contextual no bloquean.
enrollments.score = media de learner_node_states.mastery sobre esos nodos critical.
_assign_course_skills sigue otorgando user_skills con la traducción mastery → skill_level de
§3.3 y sin degradar nunca.
Recálculo obligatorio al cambiar el esquema. PUT /courses/{id}/schema cambia el conjunto de
nodos critical, que es precisamente lo que gobierna la condición de cierre. En la misma transacción
del PUT (y del validate) se recalcula la condición para todas las matrículas activas del
curso: un curso ya completado puede volver a active si el creador añade un nodo critical nuevo, y
uno bloqueado puede completarse si el nodo que faltaba se archiva. Se registra en audit_log. Sin
esto, el estado de las matrículas quedaba en función de un esquema que ya no existe.
8. Clic-para-explicar (Curio)
8.1 Qué se porta y qué no
| Se porta | Destino | Nota |
|---|---|---|
tokenize() + TOKEN_RE + Token |
apps/skillnet-web/src/lib/tokenize.ts |
Función pura, sin dependencias. Se copia con su regex `/[\p{L}\p{N}]+(?:[’’-][\p{L}\p{N}]+)* |
toClickable() |
src/components/courses/ClickableText.tsx |
Envuelve cada token clicable en <span className="entity"> |
Patrón clickify() sobre nodos de texto ya renderizados |
ClickableText + LessonContent |
La clave del port: nunca se tokeniza el markdown crudo, sólo los hijos typeof child === 'string' del árbol ya construido. Por eso la estructura no se rompe |
ClickableSurface (un único listener con onClick + onMouseUp) |
src/components/courses/ClickableSurface.tsx |
Con el ref justDragged y su setTimeout(…, 0) |
expandRangeToWords() |
src/components/courses/ClickableSurface.tsx |
Snap a palabra completa en selecciones |
cleanDescription() |
src/llm/prompts/explain.py (Python) |
La limpieza se hace en el servidor, antes de cachear |
| Prompt de descripción | src/llm/prompts/explain.py |
Sin etiquetas en mayúsculas (los modelos pequeños las repiten) |
NO se porta useGenerative |
— | Dispara 4 generaciones LLM por clic. Sólo se quiere el vistazo |
NO se porta @floating-ui/react |
— | Dependencia nueva. Posicionamiento manual estilo Overlay.tsx de la extensión, con framer-motion que ya está |
| NO se porta el hover | — | Solo clic y selección. El hover con debounce está prometido en la documentación de Curio pero no implementado, y dispararía coste sin intención del usuario |
8.2 Correcciones obligatorias respecto al original
Estos tres son bugs reconocidos del original y no se replican:
STOPWORDSen español y en inglés. La lista original es sólo inglesa, con lo que en un texto españolde,la,queserían clicables ythe,ofno. Se defineSTOPWORDS_ES(~120 palabras función) ∪STOPWORDS_EN.- La clave de caché incluye el contexto.
(org_id, term_normalized, context_hash, language)— ver §3.4. Sin elcontext_hashla función contradice su propia premisa. - Accesibilidad de teclado. Las palabras no llevan
tabindex(inundaría el orden de tabulación de un nodo largo). Se implementa roving tabindex sobre el bloque: cada bloque de texto estabindex="0"conrole="group", y dentro de él las flechas ←/→ mueven un cursor lógico entre palabras clicables, Enter/Espacio abre la explicación. El<span>activo recibearia-expanded="true"y hay una regla:focus-visiblereal. Se añade@media (prefers-reduced-motion: reduce)a las animaciones del popover, que también falta en el original.
8.3 El contexto que se envía
- Contexto de bloque: se sube al bloque más cercano con
BLOCK_SELECTOR = 'p,li,h1,h2,h3,h4,h5,h6,blockquote,td,th,dd,dt', se normalizan los espacios (.replace(/\s+/g,' ').trim()) y se recorta a 600 caracteres centrados en el término, no los primeros 600. Corregido respecto al original: en un bloque largo, el término clicado puede quedar fuera del contexto enviado al modelo, que es exactamente el peor fallo posible. - Contexto de nodo:
node_id(el servidor añadenode.titleynode.summary). Sustituye almessageId/último turno de usuario de Curio, que aquí no aplica.
8.4 Cómo se sirve
POST /api/v1/explain con Accept: text/event-stream.
- Se normaliza el término (
trim().toLowerCase()) y se calculacontext_hash. - Hit en
term_explanations→ se emite un único eventotokencon el texto completo ydone; se incrementahit_countylast_used_at. Latencia ~10 ms, coste 0. - Miss →
LLMService.stream()con purposeruntime_fast,temperature=0.2,max_tokens=80. Se acumula, se pasa porclean_explanation()en cada delta y se emitetoken. Al terminar se persiste. - Rate limit: 30 explicaciones por usuario y minuto, en memoria del proceso. Por encima,
429y el popover muestra “Demasiadas consultas seguidas”. - Dos límites, no uno: más de 140 caracteres →
422(una selección accidental de medio párrafo no es un término). Entre 61 y 140 caracteres → se explica pero no se persiste (term_explanationssólo cachea ≤60 caracteres y ≤4 tokens, §3.4). Guardar frases elegidas por el usuario en una fila sinuser_id, sin ventana de retención y sin endpoint de borrado contradecía la propia promesa de privacidad de §3.3.
Prompt (EXPLAIN_SYSTEM, en src/llm/prompts/explain.py): exactamente una frase corta, sin
markdown, sin preámbulo, sin repetir la instrucción, en el idioma del texto, explicando el
término en su uso concreto — no traduciéndolo. Sin etiquetas en mayúsculas tipo TERM:.
El popover no es recursivamente clicable (su contenido se pinta como texto plano). Evita bucles
de generación y no aporta. Sí lleva una acción: “No lo entiendo”, que abre el chat v1 ya
existente sembrado con el término, el texto del bloque y el node_id. Es la salida para quien no
entiende la frase única, que de otro modo no tenía siguiente paso: el chat vive en otra ruta y no
conoce el contexto del nodo.
8.5 Dónde se monta y qué clics NO cuentan
NodeView.tsx envuelve <UiSpecRenderer> en <ClickableSurface nodeId={nodeId}>.
ClickableText se aplica dentro de TextContentBlock, CalloutBlock, StepSequenceBlock,
TableBlock, CardBlock (títulos incluidos).
Regla de hit-test, explícita. ClickableSurface es un único listener sobre todo el subárbol, y
ese subárbol ya no es prosa de chat: contiene botones, radios e inputs. El patrón original de Curio
nunca tuvo que distinguir “clic en una palabra” de “clic en un control”, y justDragged sólo separa
arrastre de clic. Sin una regla, responder una opción de test disparaba también la explicación.
Primera línea del handler:
if ((e.target as HTMLElement).closest(
'button, a, input, textarea, select, label, [role="radio"], [role="button"], [data-no-explain]'
)) return;
QuizItemBlockentero llevadata-no-explain: enunciado y opciones. La versión anterior sólo excluía el enunciado, y las opciones son justamente la parte que filtra la respuesta — clicar una palabra dentro de la opción correcta devuelve una explicación contextual de ella.CodeBlockBlocky los enlaces siguen excluidos.- Dentro de un
QuizItemsin responder, si en el futuro se habilitara explain, cada explain contaría como pista (hints_used += 1, sujeto al tope de 3 y aattempt-before-hint). Hoy está simplemente deshabilitado, que es la versión segura de la misma regla: un explain gratis era una pista no contabilizada que además alimentaba el peso más alto del vector (+0.30). - Test obligatorio en
ClickableSurface.test.tsx: clic en el botón de una opción no produce ninguna petición a/explain.
9. Estrategia de latencia
Tres capas, en este orden.
9.1 El pre-assessment ES la espera productiva
La contradicción “el pre-assessment y la pantalla de espera ocupan los mismos primeros segundos” se resuelve fusionándolos:
Requisito previo, sin el cual esto no funciona: los ítems del probe están pre-generados en
course_nodes.probe_items desde la validación del esquema (§3.2, §7.1). Si el probe tuviera que
generarse con una llamada LLM al abrir el nodo, la “espera productiva” tendría delante su propia
espera contra una pantalla en blanco — la espera no puede cubrir a la espera. En el caso residual en
que haya que generarlos (nodo sin pre-generar), se muestra node.summary más la línea de apertura
derivada de goal mientras se generan.
t=0 POST /nodes/{id}/probe → 2 ítems (pre-generados: instantáneo)
t≈0 el usuario lee y responde el ítem A
t=A POST /nodes/{id}/probe/answer (A) ─┬─► si el veredicto ya no puede ser "mastered",
│ se dispara POST /nodes/{id}/render EN BACKGROUND
└─► el usuario responde el ítem B
t=B POST /nodes/{id}/probe/answer (B) → veredicto final
t=B+ε GET /nodes/{id}/render/stream → normalmente ya hay bloques listos
Responder el ítem B cuesta 10-20 s de atención humana, que es del mismo orden que
decide_formato + genera_ui. La espera desaparece porque se solapa con trabajo pedagógicamente
útil (el efecto de pre-pregunta tiene un tamaño de efecto notable por sí mismo). Si el veredicto
final sale mastered, el render en vuelo se cancela (asyncio.Task.cancel()) y se descarta —
coste asumido a cambio de latencia cero en el caso frecuente.
9.2 Skeleton y streaming
- Skeleton:
NodeSkeleton.tsxpinta la forma canónica (título + 3 barras de texto + 1 bloque) con un shimmer basado entransform/opacity, no conanimate-pulse(motion-system.md:437,636lo prohíbe; se añade un presetshimmerasrc/lib/motion.ts). Se implementa como componente nuevoShimmerSkeleton.tsx, ysrc/components/ui/Skeleton.tsxNO se toca: ese fichero usaanimate-pulsey se reexporta comoSkeletonText/SkeletonCard/SkeletonRowen páginas v1 (más su story), así que cambiarlo sería un cambio visible de v1 con el flag apagado, contradiciendo §10.1.design-system.md§Skeleton, que documentaanimate-pulsecomo patrón canónico, es el doc obsoleto y se corrige en elchorede §14.2 #8. - Streaming:
GET /nodes/{node_id}/render/streames SSE con estos eventos:
| Evento | Payload | Cuándo |
|---|---|---|
render_step |
{step, message} |
Al entrar en cada nodo del grafo |
ui_format |
{format, tier} |
Tras decide_formato — permite cambiar el skeleton por uno de la forma correcta |
ui_block |
{component} |
Cada vez que parse_partial completa un componente nuevo |
ui_done |
{render_id, format} |
Al persistir |
node_skipped |
{reason: "mastered"} |
Si el gate salta el nodo |
error |
{step, message, fallback: bool} |
Fallo; si fallback es true, el cliente pide el render otra vez y recibirá el seed |
El canal es f"node:{request_id}". Se mantiene src/core/sse.py con su limitación conocida (en
memoria, un worker, pierde eventos previos a la suscripción) y se le añaden dos funciones, porque
“se reutiliza tal cual” era incompatible con la mitigación: los suscriptores viven en el dict privado
_registry (líneas 11-37) y no hay accesor, así que esperar “a que haya un suscriptor” no se podía
programar. En B5, y src/core/sse.py va en su lista de ficheros:
def subscriber_count(channel: str) -> int:
return len(_registry.get(channel, ()))
async def wait_for_subscriber(channel: str, timeout: float = 0.5) -> bool:
"""True si aparece un suscriptor antes del timeout. Sondeo cada 25 ms."""
Con eso: POST /nodes/{id}/render devuelve 202 {request_id}, el cliente se suscribe, y el runner
hace await wait_for_subscriber(f"node:{request_id}", 0.5) antes de empezar el trabajo real. Migrar a
LISTEN/NOTIFY es backlog, y sólo hace falta con más de un worker de uvicorn (hoy
docker/api.Dockerfile arranca con --workers 1, coherente).
9.3 Caché
Cuatro niveles:
node_rendersporcache_key(§3.4). Un hit es una consulta SQL: ~5 ms, 0 tokens. Es el nivel que hace que el segundo empleado con el mismo perfil no pague generación. La búsqueda es porcache_keya secas, nunca poruser_id, o el hit rate sería 0.active_render_idpor(user, node)(§5.5). Dentro de un nodo abierto y en una revisita no se consulta ni la caché: se sirve el render ya fijado. Es el nivel más barato de todos y además es el que garantiza estabilidad espacial.course_nodes.probe_items(§7.1). Los ítems del pre-assessment se generan una vez por nodo en la validación y sirven a toda la organización. Es la única pre-generación de este PR, y se justifica porque no depende del usuario: N empleados, una generación.term_explanationspara clic-para-explicar (§8.4), y seed v1 como red final:fallback_seedsirvelessons.content. Esto también responde a la compatibilidad con el catálogo offline: sin LLM disponible, el curso sigue funcionando en modo v1 degradado en lugar de romperse.
9.4 Ventana anticipada de renders
La generación sigue siendo on-the-fly: la representación no se incorpora al curso al validarlo ni se produce el recorrido completo. Lo que cambia es el momento de iniciar el trabajo runtime para que la latencia del modelo no se convierta en latencia visible:
- al abrir el curso se solicitan las dos primeras lecciones disponibles;
- al quedar servida la lección actual,
NodeViewsolicita las tres siguientes; - al avanzar, esa ventana de tres se desplaza;
POST /nodes/{id}/render {force:false}es idempotente y reutiliza render listo o tarea en curso;- cada render conserva los mismos pins, claves de política, versiones y reglas de invalidación que si se hubiese solicitado al entrar directamente.
La anticipación es por recorrido probable y está acotada; no genera ramas completas ni todos los
componentes posibles. Por tanto, “pregenerado” aquí significa render runtime adelantado durante la
sesión, no un artefacto pedagógico persistido dentro de la definición del curso. La autoridad y las
implicaciones para futuros episodios ramificados están en
learning-experience-architecture.md §2.1.
10. Elección de camino: sin flag global
Esta sección describía originalmente un flag
DYNAMIC_COURSES_MODEde tres valores (off/shadow/on) como mecanismo de despliegue progresivo. Ese flag nunca llegó a producción: el mecanismo que sí se implementó, y el que hay hoy enmain, es más simple — la elección es por curso, sin ninguna variable de entorno de por medio.
src/services/course_delivery.py::resolve_delivery(course) es el único punto de decisión:
def resolve_delivery(course) -> Literal["static", "dynamic"]:
if course.delivery_mode != CourseDeliveryMode.DYNAMIC:
return "static"
if course.schema_status != CourseSchemaStatus.VALIDATED:
return "static"
return "dynamic"
Un curso va por v2 sólo si tiene delivery_mode='dynamic' y schema_status='validated'.
Cualquier otro curso —incluido cualquiera creado antes de que existiera v2— sigue por v1 en la
misma instancia, sin gate ni entorno especial. GET /api/v1/health no expone flags de features;
devuelve estado de BD y de embeddings (src/routes/health.py). Prohibido consultar
course.delivery_mode/schema_status para esta decisión en cualquier otro sitio que no sea esta
función.
10.1 Flags secundarias
| Env var | Valores | Default | Qué hace |
|---|---|---|---|
RENDER_BACKEND |
openui |
openui |
Dialecto que se le pide al LLM y parser que se usa. Un solo valor válido en este PR; la env var existe para que añadir un dialecto no sea un cambio de código de llamada |
LLM_RUNTIME_FAST_MODEL |
id de modelo litellm | vacío → LLM_MODEL |
Tier rápido del router |
LLM_RUNTIME_HEAVY_MODEL |
id de modelo litellm | vacío → LLM_MODEL |
Tier pesado del router |
LLM_FIXTURE_DIR |
ruta | src/llm/fixture_data |
Dónde busca/graba fixtures (§12) |
LLM_FIXTURE_MODE |
replay | record |
replay |
record graba pares (prompt, respuesta) con una clave real |
RENDER_BACKEND_FALLBACK no existe — se retira junto con el segundo dialecto (§1.3, §5.4). El
reintento único usa UI_REPAIR_SYSTEM con el mismo dialecto.
LLM_FIXTURE_DIR apunta dentro del paquete, no a tests/. El default anterior
(./tests/fixtures/llm) hacía imposible el perfil fixtures de docker-compose.yml: la imagen de
runtime copia sólo .venv, src, alembic, alembic.ini y pyproject.toml
(docker/api.Dockerfile:34-38), así que tests/ no está dentro del contenedor y todas las búsquedas
fallarían — justo la promesa de “el flujo completo se demuestra en local sin ninguna clave”. Las
fixtures viven en src/llm/fixture_data/, entran en la imagen con src y no hay que tocar el
Dockerfile. Los tests apuntan al mismo directorio.
Todas se documentan en .env.example y en docker-compose.yml.
11. API
Prefijo /api/v1. Auth por cookie de sesión, como todo lo demás. Los guards de rol y de flag se
implementan como dependencias: require_dynamic_courses(mode_min="shadow").
11.1 Esquema del curso (admin)
| Método | Ruta | Request | Response |
|---|---|---|---|
POST |
/courses/{course_id}/schema/propose |
{"source_document_id": uuid | null, "intent_density": 1..5} |
202 {"job_id": str} — idempotente mientras el trabajo está en vuelo: si ya hay un job del curso en estado no terminal (pending/schema_proposing, sin cancelled_at) se devuelve ese mismo job_id en lugar de lanzar otro diseñador. Dos clics no compran dos ejecuciones ni dejan dos runners escribiendo el mismo conjunto de nodos. La lectura la hace CourseSchemaService.propose; la carrera real la cierra el índice único parcial uq_generation_jobs_schema_in_flight de 0005. intent_density no se reescribe al reusar (el job en curso ya lo leyó) |
GET |
/courses/{course_id}/schema |
— | 200 CourseSchemaRead |
PUT |
/courses/{course_id}/schema |
CourseSchemaUpdate |
200 CourseSchemaRead |
POST |
/courses/{course_id}/schema/validate |
— | 200 CourseSchemaRead · 422 SchemaValidationError |
POST |
/courses/{course_id}/schema/unvalidate |
— | 200 CourseSchemaRead |
// CourseSchemaRead
{
"course_id": "…", "schema_status": "proposed", "schema_version": 3,
"delivery_mode": "static", "intent_density": 3,
"validated_by": null, "validated_at": null,
"warnings": ["Se eliminó un prerrequisito cíclico entre 'Excepciones' y 'Plazos'"],
"nodes": [{
"id": "…", "title": "Plazo de devolución", "summary": "…", "outcome": "…",
"criticality": "critical", "position": 1,
"mastery_threshold": 0.90, "estimated_minutes": 6,
"skill_id": "…", "seed_lesson_id": null,
"source_document_id": "…", "source_headings": ["Devoluciones", "Plazo"],
"prerequisite_node_ids": []
}]
}
// CourseSchemaUpdate — reemplazo completo (no PATCH parcial: el orden y el grafo
// deben validarse como un todo). Los nodos sin "id" se crean; los ausentes se
// ARCHIVAN si tienen progreso, se borran si no.
{ "intent_density": 4, "nodes": [ { /* mismos campos, "id" opcional */ } ] }
// 422 SchemaValidationError
{ "detail": { "code": "schema_invalid",
"errors": [{"code": "cycle", "node_ids": ["…","…"]},
{"code": "missing_summary", "node_ids": ["…"]},
{"code": "no_critical_node"}] } }
// 422 al editar un esquema ya validado
{ "detail": { "code": "schema_locked",
"message": "Este esquema está validado. Usa /schema/unvalidate antes de editarlo." } }
El gate no se puede saltar editando después de validar. Antes, PUT …/schema era un reemplazo
completo que subía schema_version sin tocar schema_status ni delivery_mode: sobre un curso
vivo y validado, un creador podía añadir nodos nuevos jamás revisados y los empleados recibían
contenido generado para ellos de inmediato — exactamente lo que §1.1 promete que no puede pasar. Tres
reglas, todas bloqueantes:
PUT …/schemasobreschema_status='validated'devuelve422 schema_locked. Hay que llamar aPOST …/unvalidate, que poneschema_status='proposed'ydelivery_mode='static'en la misma transacción y escribeaudit_log(course_schema_unvalidated). Es decir: editar un curso vivo lo saca de v2 hasta que se vuelva a validar. Explícito y visible, no implícito.reviewed_atpor nodo (§3.2).POST …/validatesólo revisa el grafo; un nodo sinreviewed_atno se sirve nunca (409 node_not_reviewed). El panel de B10 marca cada nodo como revisado al abrirlo y editarlo, yPUTlimpiareviewed_atde todo nodo cuyotitle,summary,criticalityosource_headingshaya cambiado.- Un nodo con
attempts_count > 0no se borra:422 node_has_progress, o se archiva (archived = true). Borrarlo cascadearía alearner_node_statesynode_renders, destruyendo maestría y rastro de auditoría de gente que ya trabajó, y cambiando además el conjunto de nodoscriticalque gobierna el cierre de matrícula.
Reglas de validación de POST …/validate, todas bloqueantes: DAG acíclico · al menos un nodo
critical · todo nodo con summary no vacío · todo nodo con source_document_id o
seed_lesson_id (regla heredada: sin fuente no hay curso) · sin prerrequisitos huérfanos ·
position contiguo desde 1 · todo nodo con reviewed_at. Al validar: schema_status='validated',
delivery_mode='dynamic', schema_validated_by/at, pre-generación de los probes de todos los
nodos (§7.1), recálculo del cierre de las matrículas activas (§7.5), y una fila en audit_log con
action='course_schema_validated' y el diff propuesto→validado en detail.
El PUT ejecuta SET CONSTRAINTS uq_course_nodes_position DEFERRED al inicio de su transacción
(§3.2), sin lo cual cualquier reordenación viola el UNIQUE (course_id, position) a mitad de
sentencia.
11.2 Onboarding y perfil (empleado)
| Método | Ruta | Request | Response |
|---|---|---|---|
GET |
/onboarding |
— | 200 OnboardingRead |
POST |
/onboarding |
OnboardingSubmit |
200 LearnerProfileRead |
POST |
/onboarding/skip |
— | 200 LearnerProfileRead |
GET |
/users/me/learner-profile |
— | 200 LearnerProfileRead · 404 si no existe |
PATCH |
/users/me/learner-profile |
{"preset"?, "role_title"?, "sector"?, "goal"?} |
200 LearnerProfileRead |
DELETE |
/users/me/learner-profile |
— | 204 — borra las siete tablas personales del usuario en este orden: node_render_views, node_feedback, node_attempts, node_probes, learner_node_states, learning_events, learner_profiles; y pone node_renders.generated_by = NULL. node_attempts antes de node_probes porque node_attempts.probe_id es ON DELETE SET NULL (§3.3). Es la vía de supresión del art. 17 RGPD que §3.3 prometía y no tenía endpoint |
// OnboardingRead — el servidor manda las preguntas para que el copy viva en un sitio
{ "version": 1, "completed": false,
"notice": "Tu puesto y tu sector se envían al proveedor de IA para adaptar los ejemplos. Puedes borrarlos cuando quieras desde Ajustes.",
"questions": [
{"id": "role_title", "kind": "text_suggest", "prompt": "¿Cuál es tu puesto?",
"suggestions": ["Dependiente", "Cajero", "Encargado de turno", "…"]},
{"id": "goal", "kind": "single_choice", "prompt": "¿Para qué quieres usar SkillNet ahora mismo?",
"options": [{"value":"onboarding","label":"Acabo de entrar y quiero ponerme al día"},
{"value":"specific_gap","label":"Hay algo concreto que necesito dominar"},
{"value":"assigned","label":"Me han asignado formación"}], "allow_other": true},
{"id": "experience_level", "kind": "single_choice",
"prompt": "¿Cuánta experiencia tienes en tu puesto actual?",
"options": [{"value":"none","label":"Ninguna"},{"value":"some","label":"Algo"},
{"value":"experienced","label":"Bastante"}]},
{"id": "preset", "kind": "single_choice", "prompt": "¿Cómo prefieres estudiar?",
"options": [{"value":"standard","label":"Estándar","hint":"Bloques de 10-15 min"},
{"value":"focus","label":"Concentración","hint":"Paso a paso, sin distracciones"},
{"value":"fast","label":"Ritmo rápido","hint":"Micro-bloques de 3-5 min"}]},
{"id": "accessibility", "kind": "multi_choice", "optional": true,
"prompt": "¿Quieres activar algún ajuste de lectura?",
"options": [{"value":"short_blocks","label":"Bloques más cortos"},
{"value":"reduce_motion","label":"Menos animaciones"},
{"value":"high_contrast","label":"Más contraste"},
{"value":"extra_time","label":"Sin límite de tiempo"}]}
]}
// OnboardingSubmit
{ "role_title": "Dependiente", "sector": "retail", "goal": "onboarding",
"experience_level": "some", "preset": "focus",
"accessibility": {"short_blocks": true, "reduce_motion": false,
"high_contrast": false, "extra_time": false} }
// LearnerProfileRead — format_vector y tutor_notes NO se exponen al cliente
{ "role_title": "Dependiente", "sector": "retail", "goal": "onboarding",
"experience_level": "some", "preset": "focus", "nodes_completed": 0,
"onboarding_completed_at": "2026-07-25T09:12:00Z", "onboarding_skipped": false,
"calibrating": true }
POST /onboarding escribe learner_profiles y users.learning_profile y
users.accessibility en una sola transacción.
11.3 Runtime (empleado)
| Método | Ruta | Request | Response |
|---|---|---|---|
GET |
/courses/{course_id}/nodes |
— | 200 NodeListRead |
POST |
/nodes/{node_id}/probe |
— | 200 ProbeRead |
POST |
/nodes/{node_id}/probe/answer |
{"probe_id", "item_id", "answer"} |
200 ProbeAnswerResult |
POST |
/nodes/{node_id}/render |
{"force": false, "preview": false} |
202 {"request_id", "cached": bool} · 409 node_not_reviewed |
GET |
/nodes/{node_id}/render |
— | 200 NodeRenderRead (el render fijado, ver abajo) · 202 {"status":"generating","request_id"} · 409 node_not_reviewed |
GET |
/nodes/{node_id}/renders |
— | 200 {"renders": [{render_id, created_at, ui_format}]} — historial para “ver la versión anterior” (§5.5) |
POST |
/nodes/{node_id}/waive |
{"reason"?} |
200 NodeStateRead — sólo admin/responsable; §7.4 |
GET |
/nodes/{node_id}/render/stream?request_id=… |
— | 200 text/event-stream |
POST |
/nodes/{node_id}/answer |
{"render_id", "item_id", "answer", "hints_used", "latency_ms"} |
200 NodeAttemptResult — hints_used del body es informativo y el servidor NO debe confiar en él (B5): es el valor que decide si NodeAttemptResult.correct_answer se revela, y un campo que rellena el cliente no puede gobernar esa revelación (hints_used: 3 sería una clave de respuestas gratis). El conteo válido se deriva en el servidor de node_attempts.hints_used para (user_id, node_id, item_id), que sólo incrementa POST /nodes/{id}/hint. QuizItemBlock (B6) no concede pistas y siempre manda 0 |
POST |
/nodes/{node_id}/hint |
{"render_id", "item_id"} |
200 {"hint", "hints_used"} · 409 si no hay intento previo |
POST |
/nodes/{node_id}/feedback |
{"difficulty", "unclear"?} |
204 |
POST |
/nodes/{node_id}/events |
{"events": [{"type","element","node_id"?,"ms"?}]} |
204 |
POST |
/explain |
{"term", "context", "node_id"?, "language"?} |
200 text/event-stream |
GET |
/render-kit |
— | 200 UIKitRead |
// NodeListRead
{ "course_id": "…", "delivery_mode": "dynamic", "schema_version": 3,
"nodes": [{ "id": "…", "title": "Plazo de devolución", "summary": "…",
"criticality": "critical", "position": 1,
"state": "not_started", "mastery": 0.0,
"locked": false, "locked_by": [],
"needs_practice": false, // state == 'needs_review' (§7.4)
"estimated_minutes": 6 }],
"can_complete": false, "blocked_by": ["…"], "progress_percent": 0 }
// ProbeRead — sin respuestas correctas
{ "probe_id": "…", "node_id": "…",
"items": [{ "item_id": "a", "item_type": "test", "bloom_level": "apply",
"question": "…", "options": ["…","…","…","…"] },
{ "item_id": "b", "item_type": "true_false", "bloom_level": "understand",
"question": "…" }] }
// ProbeAnswerResult
{ "item_id": "a", "score": 1.0, "passed": true,
"verdict": null, // null hasta que se responden todos los ítems
"estimate": 0.6, "next_item_id": "b",
"render_hint": "prefetch" } // "prefetch" | "skip" | null → el cliente
// dispara POST /render en background
// NodeRenderRead — answer_key NUNCA aparece aquí
{ "render_id": "…", "node_id": "…", "ui_format": "explanation",
"status": "ready", "backend": "openui", "cached": true,
"spec": { "version": "skillnet-ui/1", "root": "b0", "format": "explanation",
"components": [ /* … */ ] } }
// NodeAttemptResult
{ "score": 0.0, "passed": false, "feedback": "…",
"correct_answer": null, // sólo cuando hints_used >= 3 o passed
"mastery": 0.34, "state": "learning",
"consecutive_correct": 0, "consecutive_failed": 1,
"next": "retry" } // "retry" | "next_item" | "next_node"
GET /courses/{course_id} no cambia de forma. Cuando el curso es dinámico añade
"delivery_mode": "dynamic" y devuelve modules: []; el frontend usa eso para decidir a qué
vista ir. Ningún campo existente cambia de tipo — el CourseView de v1 sigue compilando y
funcionando.
GET /nodes/{node_id}/render no recalcula nada. Devuelve el ui_spec de
learner_node_states.active_render_id mientras render_pinned sea true (§5.5), y escribe una fila
en node_render_views la primera vez que ese usuario ve ese render (§2.1). Sólo
POST …/render {"force": true} recalcula la cache_key y repinta. Sin esta separación, un simple
refetch cambiaba la pantalla a mitad de nodo.
POST /nodes/{node_id}/render con "preview": true sólo lo puede llamar un admin, genera con el
perfil del admin, no escribe learner_node_states y persiste con is_preview = true, lo que lo
excluye de la caché (§3.4). Sin ese flag, un preview generado antes de validar podía servirse
literalmente a un empleado del mismo bucket. Es lo que hace posible el modo shadow sin filtrar
contenido no aprobado.
12. Estrategia de test y fixtures
Restricción central: no hay claves de API. Todo debe ser verificable sin red. La solución no es
mockear en cada test, sino una implementación alternativa de LLMService seleccionada por
configuración.
12.1 FixtureLLMService
src/llm/fixtures.py
FIXTURE_PREFIX = "fixture/"
class FixtureLLMService(LLMService):
"""Sirve respuestas grabadas. Se activa cuando el modelo resuelto empieza por
'fixture/'. Ninguna llamada de red."""
def _key(self, system_prompt: str, user_prompt: str) -> str:
return sha256(f"{system_prompt}\x00{user_prompt}".encode()).hexdigest()[:16]
class FixtureEmbeddingService(EmbeddingService):
"""Vectores deterministas por hash del texto, dimensión = config.dimensions.
Ninguna llamada de red. No son semánticos: sirven para que el pipeline
corra y para asserts de forma, no para medir relevancia."""
Se bifurca en TODOS los puntos de construcción, con un solo helper. La versión anterior decía “una sola bifurcación, en la fábrica” y parchaba dos sitios de cinco, con un nombre equivocado. Los puntos reales son:
| Fichero | Línea | Qué construye |
|---|---|---|
src/deps/llm.py |
29 | get_llm_service |
src/deps/llm.py |
33 | get_tutor_llm_service |
src/deps/llm.py |
37 | get_embedding_service |
src/deps/llm.py |
43 | get_optional_llm_service (purpose eval — el que usa grade_open_answer) |
src/agents/content/nodes.py |
82 | _make_llm (no _build_llm, como decía este documento) |
src/agents/content/nodes.py |
88 | _make_embedder |
src/services/settings_service.py |
72 | prueba de conexión de settings |
src/services/ingestion.py |
53 | embedder de ingesta |
# src/llm/fixtures.py
def maybe_fixture_llm(config) -> LLMService:
return FixtureLLMService(config) if config.model.startswith(FIXTURE_PREFIX) else LLMService(config)
def maybe_fixture_embedder(config) -> EmbeddingService:
return FixtureEmbeddingService(config) if config.model.startswith(FIXTURE_PREFIX) else EmbeddingService(config)
Los ocho sitios llaman a uno de los dos. Sin get_optional_llm_service parcheado, corregir un
practical_case/dialogue de un probe o de un desempate intentaría una llamada de red real (§3.4).
Sin FixtureEmbeddingService, load_context en su rama chunked no tiene embedding de query y
además no habría chunk alguno que buscar: DocumentChunk.embedding es Vector(...)
nullable=False (src/models/document_chunk.py:35-37), así que sin embedder no se crea ni una fila
(src/services/ingestion.py:76-81 se traga el fallo y guarda sólo full_text).
Alcance honesto del flujo sin claves: con FixtureEmbeddingService los tests con fixtures cubren
ambas ramas de load_context. La rama chunked se ejercita con vectores deterministas, así que
prueba el cableado (que la query llega, que el filtro por headings se aplica, que el contexto se
recorta), no la relevancia semántica. La calidad del retrieval sólo se puede juzgar con
@pytest.mark.integration y claves reales, y así se etiqueta.
Layout de fixtures (dentro del paquete, ver §10.2 — si vivieran en tests/ el perfil
fixtures de Docker no las encontraría):
src/llm/fixture_data/
├── index.json # {sha16: fichero, prompt_preview, use_case}
├── schema_design/returns_policy.json
├── decide_formato/{explanation,exercise,chart}.json
├── genera_ui/openui_explanation.txt # dialecto crudo, tal como lo emitiría el modelo
├── genera_ui/openui_exercise.txt
├── genera_ui/openui_table_nested.txt # Table.rows como string[][] (regla 2 de §5.4)
├── genera_ui/malformed_unclosed_array.txt # camino de reintento
├── genera_ui/malformed_unescaped_quote.txt # regla 1 de §5.4
├── genera_ui/malformed_literal_newline.txt # regla 3 de §5.4 — rompe parse_partial
├── genera_ui/invalid_unknown_component.txt # camino de fallback
├── genera_ui/repaired_after_retry.txt # respuesta a UI_REPAIR_SYSTEM
├── probe_generate/plazo_devolucion.json
└── explain/{mercurio_quimica,mercurio_planeta}.json # prueba el context_hash
Las tres fixtures malformed_* corresponden una a una a las tres reglas de la gramática congelada
de §5.4: son los fallos que un modelo de 8B comete el primer día, no malformaciones inventadas.
Modo grabación, para cuando alguien tenga una clave: LLM_FIXTURE_MODE=record hace que
LLMService real escriba cada par (prompt, respuesta) en LLM_FIXTURE_DIR y actualice
index.json. Así las fixtures son reales, no inventadas a mano. Si falta una fixture en modo
replay, el test falla con el sha y una vista previa del prompt, no con un KeyError opaco.
docker-compose.yml tiene un perfil fixtures con LLM_MODEL=fixture/local y
EMBEDDING_MODEL=fixture/local, para que el flujo completo se pueda demostrar en local sin
ninguna clave (el camino v2 se activa por curso, no por env var — ver §10). Funciona en el
compose de producción porque las fixtures viajan
dentro de src/ (§10.2); docker-compose.dev.yml, que bind-montea ./apps/skillnet-api:/app,
funciona igual.
12.2 Qué se testea y cómo
| Nivel | Fichero | Qué comprueba | Necesita |
|---|---|---|---|
| Unit | tests/test_render_openui.py |
parse() de 8 dialectos válidos → golden JSON; 6 malformados → RenderParseError, incluidas las 3 reglas de la gramática (§5.4); parse_partial sobre truncados en cada posición mediante un @pytest.mark.parametrize sobre range(len(raw)) — no con hypothesis, que sería una dependencia de desarrollo nueva y el límite de dependencias de AGENTS.md exige justificarla para nada que un bucle no dé |
nada |
| Unit | tests/test_render_roundtrip.py |
parse(serialize(spec)) == spec para el backend openui sobre 11 specs golden (los 10 de aquí más inline_nested, que fija los ids sintéticos del anidado en línea), incluidos specs con QuizItem, Stack anidado y Table con rows anidadas |
nada |
| Unit | tests/test_render_kit.py |
El catálogo congelado (10 nombres, orden posicional prop a prop, los 6 item_type del enum existente) y las 7 reglas: UISpec rechaza >12 componentes, ciclos, refs colgantes, QuizItem con correct, y explanation/mixed sin bloque lead inicial |
nada |
| Unit | tests/test_render_prompt_artifact.py |
La alarma de deriva entre src/render/kit.py y el artefacto que genera library.prompt(): digest normalizado del catálogo, prompt_sha256, que el prompt anuncie las 9 firmas y ninguna más, que no enseñe sintaxis reactiva, y que las versiones de @openuidev sean las auditadas |
nada |
| Unit | tests/test_render_gate.py |
15 payloads reactivos (Mutation suelta, Query autodisparada, refreshInterval, @OpenUrl con javascript:, @ToAssistant, $estado, ternario, builtins…) rechazados, y 6 contenidos legítimos aceptados — incluida la prosa que menciona Query() y $300, que es el falso positivo medido de un grep de palabras clave; topes de tamaño; canonicalize() devuelve la re-serialización y no la entrada |
nada |
| Unit | tests/test_mastery.py |
Tabla de verdad de probe_verdict (25 casos), incluido “B perfecto y A a cero” → no maestría; que critical con 2/2 seleccionadas da tiebreak, no mastered; que tiebreak_mastery alcanza cada uno de los 3 umbrales (la tabla de §7.2 caso por caso); el techo de maestría (0.85 sostenido sí llega a mastered en un nodo critical); EWMA; las 8 transiciones de node_state de §7.3 |
nada |
| Unit | tests/test_probe_reuse.py |
El segundo POST /probe del mismo (user, node, schema_version) devuelve el veredicto almacenado y no genera ítems; re-probe rechazado si state != 'needs_review' o han pasado <7 días; probe diagnóstico (scored=false) no consume el intento |
nada |
| Unit | tests/test_node_grading.py |
content_for() para los 4 tipos deterministas: recombina answer_key + props y grade() puntúa igual que en v1 con la misma entrada |
nada |
| Unit | tests/test_schema_validation.py |
Detección de ciclos (auto-arista, 2-ciclo, 5-ciclo, DAG grande válido), huérfanos, no_critical_node, poda de ciclos en persist_schema |
nada |
| Unit | tests/test_runtime_router.py |
select_tier para los 5 formatos; purpose_for; precedencia de resolve_llm_config con runtime_fast/runtime_heavy y caída a LLM_MODEL |
nada |
| Unit | tests/test_profile_service.py |
Vector con decaimiento (fixture de eventos con created_at fijos), normalización L1, vector_bucket, calibración con nodes_completed < 3, poda de tutor_notes a 20 |
nada |
| Unit | tests/test_cache_key.py |
La clave cambia con schema_version, preset, role_bucket, scaffold_band, effective_density, PROMPT_VERSION; no cambia con user_id ni con mastery dentro de una misma scaffold_band; dos perfiles con role_title distinto no comparten clave |
nada |
| Unit | tests/test_schema_gate.py |
PUT sobre validated → 422 schema_locked; unvalidate pone proposed + static; borrar un nodo con attempts_count > 0 → 422; editar summary limpia reviewed_at; nodo sin reviewed_at → 409 al pedir render |
nada |
| Unit | tests/test_delivery_resolution.py |
Los 12 casos de resolve_delivery |
nada |
| Graph | tests/test_runtime_graph.py |
build_node_graph() compila; recorrido completo con FixtureLLMService y repos falsos: camino feliz, mastered→skip, malformado→reintento, inválido→fallback_seed |
fixtures |
| Graph | tests/test_schema_graph.py |
Recorrido completo propose con fixtures → N nodos con criticidad y prereqs esperados |
fixtures |
| Integración | tests/integration/test_dynamic_flow.py (@pytest.mark.integration) |
e2e sobre Postgres real: propose → validate → onboarding → probe → render → answer → maestría → completado | docker db + fixtures |
| Integración | tests/integration/test_v1_regression.py (@pytest.mark.integration) |
El flujo v1 completo sigue idéntico para un curso static, y un curso con columnas nuevas puestas a dynamic pero sin schema validado sigue sirviéndose por v1 (resolve_delivery) |
docker db |
| Migración | tests/integration/test_migration_0005.py |
upgrade desde 0004 y downgrade de vuelta, con datos v1 presentes. Asserts exactos: las 13 tablas y las 6 columnas de courses desaparecen; los 8 enums nuevos desaparecen; generation_step conserva schema_proposing y schema_proposed (huérfanos por diseño, §3); ninguna fila v1 alterada; reordenar posiciones 1↔2 en un PUT no viola el UNIQUE diferido |
docker db |
Sobre aiosqlite: los tests de DB no pueden ir en SQLite (jsonb, text[], enums nativos,
pgvector). Decisión: los tests de servicio usan repositorios falsos en memoria (protocolos, ya
que los servicios reciben repos por inyección) y todo lo que toque SQL real se marca
@pytest.mark.integration y corre contra el Postgres de docker-compose. pytest -m "not integration" sigue siendo verde sin Docker y sin red — eso es lo que corre en CI por defecto.
12.3 Frontend
| Fichero | Qué |
|---|---|
src/lib/tokenize.test.ts |
Tokenización de español con acentos, apóstrofos, guiones, puntuación, emoji; stopwords ES y EN; re-render verbatim (tokens.join('') === input) |
src/components/courses/UiSpecRenderer.test.tsx |
Renderiza los 10 golden specs (compartidos con el backend vía src/test/fixtures/ui-specs/*.json); tipo desconocido → null sin crash; refs colgantes → no crash |
src/components/courses/ClickableSurface.test.tsx |
Clic en palabra → término correcto; selección parcial → palabra completa; justDragged; clic en CodeBlock → nada; clic en el botón de una opción de QuizItemBlock → ninguna petición a /explain; clic en el enunciado del quiz → nada |
src/api/nodes.test.ts |
Parseo del SSE con fetch stubeado (mismo patrón que client.test.ts), incluido ui_block incremental y error con fallback |
| Stories | UiSpecRenderer, cada bloque, NodeSkeleton, popover de explicación, wizard de onboarding — con a11y del addon en error (no todo) para lo nuevo |
Los golden specs son el mismo fichero JSON en backend y frontend (copiado por un script en
pretest, no duplicado a mano). Si el contrato se rompe, se rompen los dos lados a la vez.
13. Plan de trabajo por lotes
Regla: cada lote es un commit (o pocos), compila, pasa pytest -m "not integration" y pnpm lint,
y deja el flag en off hasta el lote B12. Ningún lote intermedio puede romper v1.
Las seis únicas superficies v1 que se tocan, declaradas (todo lo demás es fichero nuevo). Ninguna cambia comportamiento con el flag apagado, y las seis van cubiertas por tests v1 existentes o nuevos:
| Fichero v1 | Lote | Cambio | Por qué es seguro |
|---|---|---|---|
src/agents/content/{nodes,helpers}.py |
B2 | Mover 3 funciones puras a helpers.py e importarlas |
Sin cambio de comportamiento; tests/test_generation_pipeline.py ya lo cubre |
src/routes/generation_jobs.py |
B2 | _TERMINAL_EVENTS += schema_ready (1 línea) |
Un tipo de evento que v1 nunca emite |
src/core/sse.py |
B5 | +subscriber_count, +wait_for_subscriber |
Sólo añade; publish/subscribe intactos |
src/repositories/document_chunk_repo.py |
B1 | +similarity_search_by_headings |
Método nuevo; el existente intacto |
apps/skillnet-web/src/pages/employee/CourseView.tsx |
B9 | if (delivery_mode === 'dynamic') |
Con el flag apagado la API nunca devuelve dynamic |
apps/skillnet-web/src/pages/admin/CreateCourse.tsx |
B10 | Extraer StepIndicator + paso opcional |
Refactor puro + rama tras el flag |
src/components/ui/Skeleton.tsx ya no está en la lista: B6 crea ShimmerSkeleton.tsx aparte
(§9.2).
B0 ──┬── B1 ──┬── B5 ──┬── B9 ── B12
├── B2 ──┴─ B10 ──┤
├── B3 ──── B8 ───┤
├── B4 ───────────┤
├── B6 ───────────┤
└── B7 ───────────┘
B0 — Base: migración, modelos, config (bloqueante para todo)
alembic/versions/0005_dynamic_courses.py(nuevo,down_revision="0004",downgradecon el alcance exacto de §3: sinop.execute("COMMIT"), dejando los 2 valores de enum huérfanos)src/models/{course_node.py, course_node_prerequisite.py, learner_profile.py, learner_node_state.py, learning_event.py, node_render.py, node_render_view.py, node_probe.py, node_attempt.py, node_feedback.py, term_explanation.py, llm_usage_log.py, audit_log.py}(nuevos — 13)src/models/course.py(+6 columnas, 2 enums),src/models/generation_job.py(+2 miembros),src/models/__init__.pysrc/config.py(RENDER_BACKEND,LLM_RUNTIME_FAST_MODEL,LLM_RUNTIME_HEAVY_MODEL,LLM_FIXTURE_DIR,LLM_FIXTURE_MODE)src/llm/fixtures.py(nuevo:FixtureLLMService,FixtureEmbeddingService,maybe_fixture_llm,maybe_fixture_embedder),src/llm/fixture_data/(directorio de fixtures, dentro del paquete)- Los 8 puntos de construcción de §12.1 pasan por los helpers:
src/deps/llm.py(4),src/agents/content/nodes.py(2),src/services/settings_service.py(1),src/services/ingestion.py(1) src/services/course_delivery.py(nuevo,resolve_delivery)src/deps/features.py(nuevo,require_dynamic_courses)src/routes/health.py(+features),src/scripts/purge_learning_data.py(nuevo)tests/test_delivery_resolution.py,tests/integration/test_migration_0005.py.env.example,docker-compose.yml(perfilfixturesconLLM_MODELyEMBEDDING_MODELfixture)
B1 — Adaptador de render y UI Kit (Python) (paralelo con B2, B3, B4, B6, B7)
src/render/{__init__.py, spec.py, kit.py, errors.py}(nuevos)src/render/backends/{__init__.py, base.py, openui.py}(nuevos — sina2tl.py, §1.3)src/repositories/document_chunk_repo.py(+similarity_search_by_headings, método nuevo; el existente no se toca)tests/test_render_openui.py,test_render_roundtrip.py,test_render_kit.pytests/fixtures/dsl/*.openui,tests/fixtures/ui-specs/*.json- NO se toca el scoping por
org_iddesrc/deps/llm.pyni desettings_service.py: sale a su propiochorePR con tests de ruta para chat y ejercicios (§4.3, §14.2 #11)
B2 — Design-time: grafo de esquema + endpoints (paralelo con B1, B3, B4, B6, B7)
src/agents/schema/{__init__.py, state.py, nodes.py, graph.py, runner.py, errors.py}(nuevos — nodos nuevos, no importados de v1, §4)src/agents/content/helpers.py(nuevo:estimate_pages,assemble_chunk_text,themes_listmovidas desdenodes.py) +src/agents/content/nodes.py(importa desde ahí; sin cambio de comportamiento)src/llm/prompts/schema.py(nuevo:SCHEMA_DESIGNER_SYSTEM,build_schema_promptconavailable_headingscomo lista cerrada)src/repositories/course_node_repo.py(nuevo)src/services/course_schema_service.py(nuevo: propose, update, validate, unvalidate, ciclos, versionado, gateschema_locked,reviewed_at, archivado, pre-generación de probes, recálculo de matrículas)src/repositories/audit_log_repo.py(nuevo)src/schemas/course_schema.py(nuevo)src/routes/course_schema.py(nuevo),src/main.py(registro),src/routes/generation_jobs.py(_TERMINAL_EVENTS+=schema_ready, 1 línea — el canal sigue siendogeneration:{job_id})tests/test_schema_validation.py,tests/test_schema_gate.py,tests/test_schema_graph.py,src/llm/fixture_data/schema_design/*.json
B3 — Perfil del aprendiz y onboarding (API) (paralelo con B1, B2, B4, B6, B7)
src/repositories/{learner_profile_repo.py, learning_event_repo.py}(nuevos)src/services/learner_profile_service.py(nuevo:EVENT_WEIGHTSde 7 tipos, vector de 4 dimensiones con decaimiento,vector_bucket, calibración,apply_signals()con las 5 reglas de disparo de §3.3)src/schemas/{onboarding.py, learner_profile.py}(nuevos;OnboardingRead.noticeincluido)src/routes/{onboarding.py, learner_profile.py}(nuevos, incluidoDELETE /users/me/learner-profile),src/main.pytests/test_profile_service.py(una prueba por regla detutor_notes),tests/test_cache_key.py
B4 — Pre-assessment y maestría (paralelo con B1, B2, B3, B6, B7)
src/services/mastery_service.py(nuevo:probe_verdict,tiebreak_mastery/tiebreak_verdict, EWMA con techo, las 8 transiciones,THRESHOLDS, prior desdeuser_skills)src/services/probe_service.py(nuevo: lectura decourse_nodes.probe_items, muestreo del seed, generación por LLM como último recurso, corrección, intento único por versión, probe diagnóstico)src/services/node_grading.py(nuevo:content_for()— adaptadoranswer_key+ props → dict v1; importagrade()deexercise_service, no lo mueve)src/repositories/{node_probe_repo.py, node_attempt_repo.py, learner_node_state_repo.py}(nuevos)src/llm/prompts/probe.py(nuevo)tests/test_mastery.py,tests/test_probe_reuse.py,tests/test_node_grading.py,src/llm/fixture_data/probe_generate/*.json
B5 — Runtime: grafo por nodo + endpoints (depende de B1 y B4)
src/agents/runtime/{__init__.py, state.py, nodes.py, graph.py, router.py, runner.py, errors.py}(nuevos;errors.pycontieneruntime_node_error_wrapper, independiente del de v1 — §4.2)src/llm/prompts/runtime.py(nuevo:FORMAT_DECIDER_SYSTEM,UI_GENERATOR_SYSTEM,UI_REPAIR_SYSTEM,PROMPT_VERSION,build_*)src/services/node_render_service.py(nuevo:cache_keyconrole_bucket/scaffold_band, hit/miss porcache_keysinuser_id, fijado deactive_render_id, escritura denode_render_views, cancelación, fallback)src/repositories/{node_render_repo.py, node_render_view_repo.py, llm_usage_repo.py}(nuevos)src/core/sse.py(+subscriber_count, +wait_for_subscriber— §9.2; sólo añade)src/schemas/node.py(nuevo),src/routes/nodes.py(nuevo, incluido/waivey/renders),src/main.pysrc/services/exercise_service.py: no se toca.grade()ya es una función pura de nivel de módulo (línea 73) y se importa tal cual; el adaptador vive ennode_grading.py(B4)tests/test_runtime_router.py,tests/test_runtime_graph.py,src/llm/fixture_data/{decide_formato,genera_ui}/*
B6 — Frontend: renderer de specs y bloques (paralelo; sólo necesita el JSON de contrato de B1)
src/types/ui-spec.ts(nuevo),src/types/index.ts(LearningNode,NodeRender,LearnerProfile,ProbeItem, sin tocar los tipos v1)src/components/courses/UiSpecRenderer.tsx(nuevo)src/components/courses/blocks/{StackBlock,TextContentBlock,CardBlock,CalloutBlock,StepSequenceBlock,TableBlock,CodeBlockBlock,ChartBlock,QuizItemBlock,MarkdownBlock}.tsx(nuevos).QuizItemBlockes autónomo (§5.3): su propio estado y su propio envío aPOST /nodes/{id}/answer, y no se tocasrc/components/exercises/src/lib/motion.ts(+presetshimmer),src/components/ui/ShimmerSkeleton.tsx(nuevo;Skeleton.tsxde v1 no se toca — §9.2)- Stories +
UiSpecRenderer.test.tsx,src/test/fixtures/ui-specs/(copia de B1)
B7 — Curio: clic-para-explicar (paralelo; necesita term_explanations de B0)
- Backend:
src/llm/prompts/explain.py,src/services/explain_service.py,src/schemas/explain.py,src/routes/explain.py,src/repositories/term_explanation_repo.py(nuevos) +src/main.py - Frontend:
src/lib/tokenize.ts,src/components/courses/{ClickableText,ClickableSurface,ExplainPopover}.tsx,src/api/explain.ts(nuevos).ClickableSurfaceimplementa el hit-test de §8.5 (closest(...)) como primera línea del handler;ExplainPopoverlleva la acción “No lo entiendo” → chat v1 sembrado src/index.css(.entity,.entity-open,.phrase-rect,:focus-visible,prefers-reduced-motion)tests:tokenize.test.ts,ClickableSurface.test.tsx(incluido el caso “clic en opción → sin explain”),src/llm/fixture_data/explain/*.json
B8 — Frontend: wizard de onboarding (depende de B3)
src/components/ui/StepIndicator.tsx(extraído deCreateCourse.tsx;CreateCourse.tsxpasa a importarlo — refactor sin cambio funcional)src/pages/onboarding/Onboarding.tsx,src/components/onboarding/{RoleStep,GoalStep,ExperienceStep,PresetStep,AccessibilityStep}.tsx(nuevos)src/api/onboarding.ts(nuevo),src/api/users.ts(useUpdateProfileaceptaaccessibility)src/types/index.ts: arreglarlearning_profile?: Record<string, unknown> | null→learning_profile?: 'standard' | 'focus' | 'fast'. La columna del backend es el enumlearning_profile(src/models/user.py), así que el wizard tiene que enviar un string plano y el tipo actual lo impidesrc/App.tsx(ruta/onboarding, fuera deAppLayout),src/components/layout/ProtectedRoute.tsx(gate con las 4 reglas de §6.1: flag desde/health, query condicionada arole === 'employee', 404 ⇒ no redirigir, skeleton mientras carga)
B9 — Frontend: vista de nodo (depende de B5 y B6)
src/pages/employee/NodeView.tsx,src/components/courses/{NodeList,NodeSkeleton,ProbeRunner,NodeFeedback,RenderControls}.tsx(nuevos).RenderControlses el pie del nodo con “Actualizar esta lección” y “Ver la versión anterior” (§5.5) — no es opcionalsrc/api/nodes.ts(nuevo:useCourseNodes,useProbe,useSubmitProbeAnswer,useNodeRender,useNodeRenderStream,useSubmitNodeAnswer,useNodeEvents,useNodeRenderHistory).useNodeRenderva conrefetchOnWindowFocus: false— es cinturón sobre tirantes, porque el backend ya sirve el render fijadosrc/pages/employee/CourseView.tsx(única modificación: sidelivery_mode === 'dynamic'renderizaNodeList; si no, el árbol de v1 intacto)src/App.tsx(/empleado/curso/:id/nodo/:nodeId)
B10 — Frontend admin: esquema (depende de B2)
src/pages/admin/CourseSchema.tsx,src/components/schema/{NodeEditor,PrerequisitePicker,CriticalityBadge,IntentDensitySlider,SchemaValidationPanel,ReviewChecklist}.tsx(nuevos).NodeEditorexponedefault_ui_format;ReviewChecklistmarcareviewed_atpor nodo y bloquea el botón de validar hasta que todos están revisadossrc/api/schema.ts(nuevo, incluidounvalidatey el aviso deschema_locked),src/pages/admin/CreateCourse.tsx(paso 1 → “definir esquema” cuando el flag lo permite; el camino v1 sigue disponible),src/App.tsx(/admin/curso/:id/esquema)
B11 — Integración y regresión (depende de B5, B8, B9, B10)
tests/integration/test_dynamic_flow.py,tests/integration/test_v1_regression.pysrc/services/enrollment_service.py(cierre por nodoscriticalno archivados, sólo en la rama dinámica; recálculo al cambiar el esquema, §7.5)src/services/skill_service.py(traducciónmastery → skill_level, y lectura deuser_skillspara el prior del probe, §7.1)
B12 — Docs, flag y PR
docs/design/v2-dynamic-courses.md(este fichero, actualizado con lo aprendido)AGENTS.md(§“Current phase” → v2 con el flag; y arreglar la lista de paquetes: mencionapackages/mcp-ui-renderer, que no existe —packages/contienea2tl-video,a2tl-web,mcp-md-reader),CLAUDE.md(comandos del perfilfixtures)chorede docs obsoletos (§14.2 #8), en este mismo lote:screens.md(rutas en español; §Employee Settings línea 213, quitar “TEA, TDAH, dislexia flags” y describirusers.accessibilitycomo ajustes neutros),design-system.md(§Skeleton:animate-pulse→ShimmerSkeleton)docs/design/data-model.md(apéndice v2 apuntando aquí; el cuerpo v1 no se reescribe)README.md(sección del flag),.env.examplefinal- Flag a
shadowen eldocker-compose.ymlde desarrollo,offen el de producción - PR a
maincon la tabla de verdad deresolve_deliveryy la evidencia del test de regresión v1
Ruta crítica: B0 → B1 → B5 → B9 → B11 → B12. Todo lo demás cuelga en paralelo. Con dos personas, la reparto: una hace B0/B1/B5 (adaptador + runtime), la otra B2/B3/B4 (esquema + perfil + maestría) y luego B6-B10 se pueden solapar.
14. Riesgos y decisiones abiertas
14.1 Riesgos con mitigación decidida
| Riesgo | Probabilidad | Mitigación en este diseño |
|---|---|---|
| Un modelo pequeño (8B) genera dialecto malformado con frecuencia | Alta | Gramática EBNF congelada + 3 reglas de escape en el prompt (§5.4) · parse_partial tolerante · 1 reintento con UI_REPAIR_SYSTEM (el error exacto en el prompt) · fallback_seed a markdown v1. El usuario nunca ve una pantalla roja — y eso lo garantiza fallback_seed, no un segundo dialecto |
| El contenido generado es pedagógicamente peor que el estático y nadie lo revisa | Alta | Modo shadow (previews con is_preview, fuera de la caché) · reviewed_at obligatorio por nodo: un nodo que nadie ha abierto no se sirve · diff propuesto→validado en audit_log, para medir si los creadores editan · node_feedback con disparador: 3+ usuarios marcan hard en el mismo nodo → aviso al creador. El creador decide, el sistema no reescribe solo |
| Coste por LLM se dispara | Media | Caché compartida por bucket (no por usuario) · probes pre-generados por nodo, no por usuario · el pre-assessment evita generar lo ya sabido · llm_usage_log con use_case, tabla creada en 0005 (§3.5) · presupuestos de max_tokens explícitos |
| El hit rate de la caché inter-usuario es mucho peor que el medido | Alta | El régimen inter-usuario no está medido (§3.4) y role_bucket lo empeora a propósito. Es lo primero que se mide (§14.2 #3), y la palanca de retirada es una línea: quitar role_bucket o vector_bucket de la clave y subir PROMPT_VERSION |
| El SSE en memoria pierde eventos o rompe con >1 worker | Media | 202 {request_id} + suscripción antes del trabajo + espera de 500 ms · fallback a polling de GET /nodes/{id}/render (patrón que el frontend ya usa para generación) · documentado: un solo worker de uvicorn hasta migrar a LISTEN/NOTIFY |
| Fuga de la respuesta correcta al cliente | Media | answer_key en columna separada que ningún schema Pydantic de respuesta incluye · ProbeSession.probe tipado como ProbeRow (protocolo sin answer_key) y proyectado por ProbeSessionRead.from_session (extra="forbid", campos enumerados a mano) — el servicio ya no devuelve la fila ORM entera a su llamante · test que vuelca el modelo de respuesta y afirma que ni la clave ni sus valores aparecen (tests/test_probe_answer_key_privacy.py) · NodeRenderRead ya existe (src/schemas/node.py:115, llegó con B5) con extra="forbid" y la lista de campos enumerada a mano en NodeRenderRead.of, que es el contrato entero; queda pendiente el test equivalente que vuelque ese modelo y afirme la ausencia de la clave, como el de ProbeSessionRead · hints_used del cliente es informativo y no puede gobernar la revelación (§11.3) |
| Inyección de prompt desde el texto que manda el cliente | Media | POST /explain interpola dos valores del cliente (term, context, y el contexto no se contrasta con el texto real del nodo). Ninguno va entre comillas: se sanean (controles, </>, rachas de comillas, tope de longitud 140/600) y se vallan en marcas <<<nombre:token>>> cuyo token ningún payload saneado puede contener — cerrar la valla exigiría los caracteres que ya se han quitado. El system declara que lo que va entre marcas es dato, nunca instrucción. Token derivado del contenido (no aleatorio) para que las fixtures sigan siendo reproducibles. Tests de secuestro en tests/test_explain_service.py |
| La regla de maestría deja pasar a quien no sabe | Media | Cláusula score_a >= 0.5 · desempate con respuesta construida obligatorio en todo nodo critical · un solo probe puntuado por versión de esquema (índice único), que es lo que impide reentrar hasta acertar por azar · racha de 3 además del umbral |
| La regla de maestría deja fuera a quien sí sabe | Media | Techo de maestría: 3 aciertos consecutivos elevan mastery al umbral (§7.3), porque el EWMA converge a la media y dejaba el 0.85 sostenido a 0.05 del 0.90 para siempre · salida humana POST /nodes/{id}/waive con registro en audit_log · nodo en needs_review visible y reintentable, no desaparecido |
| El contenido cambia bajo los pies del usuario | Media | active_render_id fija el render mientras el nodo está abierto · scaffold_band (estable) sustituye a mastery_band (cambiaba con cada respuesta) en la clave · regeneración sólo por botón explícito, con “ver la versión anterior” |
| Regresión silenciosa en v1 | Media | test_v1_regression.py con el flag en off · resolve_delivery como único punto de decisión · default off |
| Deriva del contrato entre backend y frontend | Media | Golden specs compartidos, copiados por script en pretest. Rompen los dos lados a la vez |
| XSS vía contenido generado | Baja (por diseño) | Nunca HTML: IR tipada + render nativo. SandboxHTML fuera de scope. props.text es texto/markdown inline, renderizado por react-markdown sin rehype-raw |
| RGPD con datos sensibles | Media | Sin etiquetas de neurotipo · learning_events sin texto del usuario, purgado a 90 días por src/scripts/purge_learning_data.py (no hay tabla background_jobs, §1.3) · term_explanations sólo cachea ≤60 caracteres y purga a 180 días · goal ya no viaja al LLM · aviso en el punto de recogida (OnboardingRead.notice) · DELETE /users/me/learner-profile para el art. 17, que borra las siete tablas personales (incluidas node_attempts y node_probes, las que guardan lo que el empleado escribió) y lo demuestra tabla por tabla en tests/test_gdpr_erasure.py · accessibility nunca va al LLM · agregados de admin con k≥5 · audit_log (tabla creada en 0005) en la validación del esquema |
| Se destruye evidencia de auditoría al dar de baja a un empleado | Media | node_renders sin user_id y generated_by con ON DELETE SET NULL; la traza de lectura vive en node_render_views, por usuario · un nodo con progreso se archiva, no se borra |
14.2 Decisiones abiertas (con fecha de decisión, no “TBD”)
-
Ratio real fast/heavy. La estimación 90/10 es una hipótesis. Se decide con datos de
llm_usage_logtras 2 semanas en modoshadow. Si el heavy supera el 25 %, hay que revisar el prompt dedecide_formato— probablemente esté eligiendochartcuando bastaexplanation. -
Latencia real de
genera_ui.AbiertaCERRADA (2026-07-27, medida). Las fuentes internas daban cifras incompatibles (1-2 s vs 22.9 s vs 60-120 s), todas de generación de HTML completo y no de IR. Medido conscripts/quality_bench.pycontra Groq real (groq/llama-3.1-8b-instantcomo nivel fast,groq/openai/gpt-oss-120bcomo heavy): de menos de un segundo a ~3 s por render, a ~0.0008 USD por render, con la contabilidad de tokens ya poblada. El problema de “20-30 segundos” que asumía la investigación no existe en esta pila: las cifras de 60-150 s eran de un modelo 7B en CPU local.Consecuencias, y son las que importan: la espera productiva basta de sobra, no hace falta pre-generación, y no se añade ninguna capa más de espera. El presupuesto de latencia deja de ser una restricción de diseño, así que los diales se gastan en corrección, no en velocidad (
docs/design/tuning.md). La única restricción operativa real es que el plan gratuito de Groq devuelve 429 con facilidad: cualquier tanda de medición necesita retroceso exponencial, y el banco lo trae dentro y contabiliza la espera aparte para que un 429 no pueda puntuar como fallo de calidad. -
Tasa de aciertos y de obsolescencia de la caché inter-usuario. Se miden las dos, porque el ~80 %/0 % citado se midió con una clave por usuario y esta clave es compartida: es un régimen sin datos (§3.4). Consulta: aciertos por
cache_keysobrenode_renders+node_render_views, y “obsoleta” = render servido cuyorole_bucket/scaffold_bandya no corresponde al perfil del lector. Si los aciertos caen por debajo del 50 %, se quitavector_bucket; si además hay obsolescencia perceptible, se quitarole_buckety se acepta contenido menos personalizado. Un cambio de una línea y una constante dePROMPT_VERSION. -
FSRS vs HLR. Se mantiene HLR (ya está en el modelo de datos). La decisión de migrar a FSRS-6 se toma en el PR de repetición espaciada, no aquí, y requiere fijar antes: número de pesos (las fuentes dicen 17, 19 y 21), rango de
difficulty, y la versión depy-fsrscon su API (Scheduler.review_cardvsFSRS.repeat). -
Umbrales de alerta de retención. Hay tres juegos incompatibles en la investigación (0.85/0.70/0.50 vs 0.50/0.70 vs 85/70/50 + crítico). Se define una tabla canónica única en el PR de repetición espaciada.
-
Interleaving entre nodos. Mezclar nodos de distintos cursos en una sesión mejora la transferencia, pero sobrecarga al novato. Se decide cuando existan datos de
masteryreales: la regla candidata es habilitarlo sólo para nodos conmastery >= 0.5(fasehao superior). -
¿
Simulationcon estado? Requiere que la IR crezca con data-binding, que es un cambio estructural del contrato. Se decide sólo si el feedback denode_feedbackmuestra queexplanation+exerciseno basta para los nodos procedimentales. -
Rutas en español vs inglés. El código usa español, los docs inglés. Se sigue el código y se corrige
screens.mden unchoreaparte. Si se decide lo contrario, es un renombrado mecánico deApp.tsxy de losLink, sin efecto en la API. -
Umbral de personalización de pesos por empleado. No aplica hasta que exista FSRS. Cuando aplique, elegir entre >50 y >200 revisiones (las fuentes dan ambos) y documentarlo.
-
Multi-worker. El diseño asume un worker de uvicorn por el SSE en memoria (coherente con
docker/api.Dockerfile, que arranca con--workers 1). Si el despliegue necesita más, la decisión esLISTEN/NOTIFYde Postgres (sin Redis, coherente con “sin Redis, sin Celery”), y hay que tocar sólosrc/core/sse.py. -
Scoping por
org_idde los org settings.select(Organization).limit(1)ensrc/deps/llm.py:22-25ySettingsService._get_org. Sale de este PR a unchorepropio (§4.3) porque arreglarlo bien exige meterCurrentUseren cuatro dependencias que consumen rutas v1 sin tests. Se decide antes de admitir la segunda organización en una instancia; hoy la invariante de una sola org lo hace inocuo. Elchoreincluye tests de ruta parachat.pyyexercises.py. -
Segundo dialecto de render. Fuera de este PR (§1.3). Se decide si el reintento con
UI_REPAIR_SYSTEMdeja una tasa de fallo de parseo >5 % medida sobrenode_renders.status. Si hace falta, será un dialecto propio de SkillNet capaz de expresar laUISpeccompleta, noUIDL/1. -
Navegación del curso: lista plana vs canvas con zoom semántico.
NodeListReades una lista ordenada, que es una rebaja deliberada respecto a la exploración en canvas con raíl de estado persistente que recomienda la investigación de Keyhole. Motivo: el canvas es un proyecto de frontend propio y este PR ya tiene un renderer nuevo. Se reevalúa cuando existan cursos de >10 nodos reales; queda anotado aquí en lugar de omitido. -
Promover la regla de redundancia (§5.2) de aviso a error. Se decide con los avisos acumulados en
node_renders.error_messagetras 2 semanas enshadow: si aparece en <5 % de los renders, se promueve a error de validación; si aparece a menudo por tabla-resumen-tras-texto (redundancia buena), se retira la heurística.
14.3 Lo que encontró la primera ejecución real de las suites de integración (2026-07-27)
Las suites de tests/integration/ se escribieron en B11 pero no se habían llegado a ejecutar
nunca contra un PostgreSQL vivo. La primera vez que corrieron, encontraron siete cosas. Se anotan
aquí porque cinco de ellas contradicen algo que este documento o el código afirmaban, y porque dos
son bugs de v1 que llevaban ahí desde antes de v2.
Migraciones (nada de esto era teórico: alembic upgrade head desde cero no había funcionado nunca).
0003usabasa.Enum(..., create_type=False).sa.Enumpierde ese flag al adaptarse al dialecto de postgres, así queCREATE TYPE skill_levelse emitía dos veces y la tirada moría. Corregido apostgresql.ENUM. Este era el motivo de fondo de que nadie hubiera podido levantar la base desde vacío.0005ysrc/models/learner_profile.pyconstruían un default JSONB consa.text()que llevaba:sin escapar. SQLAlchemy los leía como parámetros de bind y el DDL salía como{"texto"NULL,...}. Corregido escapando\:.0005usaba un valor nuevo degeneration_stepen la misma transacción que lo añadía (el índice parcialuq_generation_jobs_schema_in_flight) →UnsafeNewEnumValueUsageError. Los dosALTER TYPE … ADD VALUEvan ahora enop.get_context().autocommit_block(). La afirmación contraria del docstring de0005y de la nota de §3 era falsa y está corregida en los dos sitios.- Migración nueva
0006:user_skills.last_assessed_ateratimestamp without time zonemientras que sus dos escritores (SkillService.record_masteryyEnrollmentService._grant_course_skills) le pasan valores aware. asyncpg rechaza la mezcla, así que subir de nivel una skill que ya existía reventaba la petición. Sobrevivió hasta ahora porque sólo afecta a la rama UPDATE.
Bugs de producto de v1, descubiertos por la regresión y no por v2.
- Cuatro sitios auto-completan una matrícula al llegar a progreso 1.0 y ninguno concedía las
skills del curso.
POST /enrollments/{id}/completese encontraba la matrícula ya completada, tomaba su retorno temprano, yuser_skillsse quedaba vacía: un empleado terminaba un curso y no se le acreditaba nada. Corregido enEnrollmentService.complete(). PUT /lessons/{id}era un 500 garantizado (MissingGreenlet): leíalesson.exercisesde un loader que nunca las cargaba de forma anticipada. Ninguna prueba cubría esa ruta.
Entorno.
docker-compose.dev.ymlmontaba el repo del host sobre/app, así queuv rundentro del contenedor veía un virtualenv con binarios ajenos, lo daba por roto y borraba el.venvdel host para reconstruirlo. Resuelto con un volumen anónimo sobre/app/.venv.
La lección transversal, y es la que vale para el resto del proyecto: una suite que no se ha ejecutado no es cobertura. Las dos rutas de v1 rotas (5 y 6) llevaban meses en el repo con tests unitarios verdes alrededor.
15. Revisión: objeciones descartadas
Dos auditorías adversariales revisaron este documento. Todos los bloqueantes de ambas se verificaron contra el código y todos resultaron correctos, y están corregidos arriba. Lo que sigue son las partes de objeciones que no se han aplicado, con la evidencia de por qué.
15.1 “Forzar fill_blank en el ítem A para que score_a sea continuo”
Descartado: el arreglo propuesto no funciona. El diagnóstico sí era correcto (con dos ítems
binarios los tres umbrales se comportan igual, y el desempate topaba en 0.80 < 0.90), y está
corregido en §7.2. Pero la vía sugerida —hacer continuo score_a usando fill_blank— no existe:
_grade_fill_blank (src/services/exercise_service.py:34-43) devuelve 0.0 en cuanto falla un solo
hueco, igual que _grade_test, _grade_true_false y _grade_order_steps. Los cuatro tipos
deterministas son todo-o-nada. Hacerlo continuo exigiría cambiar la corrección de v1, que se usa en
producción para exercises reales, y eso está fuera de alcance.
Lo aplicado en su lugar: renormalizar el desempate a 0.45a + 0.15b + 0.40c (llega a 1.0, así que
todos los umbrales son alcanzables), obligar a que el ítem B sea test de 4 opciones (para que el
6.25 % citado sea cierto), y hacer el desempate construido obligatorio en todo nodo critical.
Los umbrales discriminan sin necesidad de crédito parcial (tabla de §7.2).
15.2 “Añadir un valor schema_only a generation_output”
Descartado por coste/beneficio, no por incorrección. La observación es válida:
generation_jobs.output_type es NOT NULL sobre un enum de dos valores y un job de esquema no es
ninguno. Pero añadir un tercer valor amplía el mismo problema que §3 documenta —un enum de Postgres no
puede perder valores, así que el downgrade dejaría tres huérfanos en lugar de dos— para un campo
que ningún consumidor de esquema lee: los clientes se guían por status, y
generation_service ya cae a COURSE_AND_MANUAL ante cualquier valor desconocido. Se documenta como
placeholder explícito en §3.1 en vez de crear deuda de migración.
15.3 “Generar el bloque de rol con una llamada fast por usuario (~40 tokens)”
Descartado a favor de la otra opción del mismo revisor. El problema —el rol no estaba en la
cache_key y la personalización por rol se perdía en cada acierto de caché— es real y está corregido.
De las dos vías propuestas se toma la primera (role_bucket dentro de la clave), no la del bloque
generado aparte, por dos razones concretas: una llamada LLM extra por usuario y por nodo es
precisamente el coste que la caché existe para evitar (y sería el 100 % de los usuarios, no el 20 % de
los fallos de caché); y un bloque generado fuera del ui_spec viviría fuera de node_renders, es
decir fuera de la auditoría y del answer_key-por-construcción. La línea “esto te sirve para X” sí se
implementa, pero determinista y en el cliente desde goal (§6.2 Q2), sin tokens.
15.4 “Añadir org_id a las diez tablas nuevas”
Aplicado en parte, por diseño. Llevan org_id las tablas top-level, que es lo que pide la
convención de data-model.md: course_nodes, learner_profiles, node_renders,
term_explanations, llm_usage_log, audit_log. No lo llevan las tablas hijas cuyo scoping se
deriva sin ambigüedad de su padre y que sólo se consultan por user_id o por node_id:
course_node_prerequisites, learner_node_states, learning_events, node_probes, node_attempts,
node_feedback, node_render_views. Añadir org_id ahí sería una columna denormalizada más que
mantener coherente en cada escritura sin ninguna consulta que la use. La incoherencia que señalaba la
objeción —presumir de multi-tenancy con una sola tabla org-scoped— queda resuelta con las seis de
arriba.
15.5 Verificaciones que confirmaron el documento (no requerían cambio)
DOS DE ESTAS VERIFICACIONES QUEDAN ANULADAS el 2026-07-26 (decisión de producto: adopción completa de OpenUI —
docs/design/openui-adoption.md). Esta sección es la que se lee como “esto ya está comprobado, no lo vuelvas a auditar”, así que las dos frases caducadas están tachadas abajo en lugar de borradas, y lo que sigue siendo cierto queda separado de lo que ya no lo es.
- La promesa de “ninguna dependencia npm nueva” ya NO se sostiene. Entran tres, con versión exacta y sin
^:@openuidev/react-lang@0.2.9,@openuidev/lang-core@0.2.10yzod@4.4.3(apps/skillnet-web/package.json, resueltas enpnpm-lock.yaml). Las versiones están fijadas porque ninguna de las propiedades de seguridad en las que se apoya la adopción es un contrato público del paquete;tests/test_render_prompt_artifact.py::test_the_pinned_openui_versions_are_the_audited_oneses la alarma que salta al subirlas.- El parser del navegador ya NO es el de Python. El de Python se conserva, pero como validador en el servidor (
src/render/gate.py+src/render/spec.py): valida antes de persistir y re-serializa laUISpecal dialecto canónico. El pintado lo hace<Renderer>de@openuidev/react-langsobre los componentes que registramos (§5.1, §5.3, §5.4).Lo que sí sigue siendo cierto de la frase original, y está comprobado: la mitigación de XSS.
react-markdown10 sigue presente sinrehype-raw, no hay ningúndangerouslySetInnerHTMLenapps/skillnet-web/src/yframer-motionsigue presente. Esa mitad no depende de la adopción: los paquetes de OpenUI no interpretan HTML, mapean el lenguaje a componentes React nuestros.
Se listan para que nadie las vuelva a auditar: la cadena de Alembic es lineal 0001→0004, así que
down_revision="0004" es correcta y no hay heads en conflicto · ninguna ruta nueva de §11 colisiona
con las ~40 existentes · la precedencia de resolve_llm_config funciona exactamente como describe
§4.3 para runtime_fast/runtime_heavy (src/llm/client.py:61-67) · GenerationJobRead.status es
str, así que los dos miembros nuevos del enum no lo rompen · el marcador integration ya está en la
configuración de pytest · react-markdown 10 está presente sin rehype-raw y framer-motion
está presente, así que la mitigación de XSS y la promesa de “ninguna dependencia npm nueva” se
sostienen la mitigación de XSS se sostiene (la promesa de “ninguna dependencia npm nueva”, no:
ver el aviso de arriba) · docker/api.Dockerfile arranca con --workers 1, coherente con el supuesto
del SSE.
Sobre el paquete npm openui: es irrelevante que exista o no, porque §5.4 implementa el parser en
Python y no añade ninguna dependencia de frontend. Corregido el 2026-07-26: el paquete real
es @openuidev/*, existe, y sí entra como dependencia de frontend; el parser de Python se
conserva como validador de servidor, no como el parser del navegador. Lo que sí era un riesgo real
—presentar “OpenUI Lang” como dialecto externo con un ejemplo y ninguna gramática— está cerrado dos
veces: con la EBNF congelada y las tres reglas de escape de §5.4 (más una fixture malformada por
regla) y, desde la adopción, con la implementación de referencia del propio dialecto.