# Especificación del módulo Pensamiento estratégico (PE) — OKRFEDEF

Versión 1.0 · 5 de septiembre de 2026 (noche) · Contrato para los cinco equipos que construyen el módulo en paralelo.
Uso previsto: Retiro 1 de FEDEF, domingo 6 de septiembre de 2026, 8:00, bloque 4 (pensamiento estratégico) del día 2, en la plataforma.

Este documento y `docs/CONTRATO_RUTAS_PE.md` son la única fuente de verdad del módulo PE. Donde este documento y el contrato de rutas difieran, manda este documento en reglas de negocio y el contrato en nombres, rutas y props. El módulo DOFA v2 (`docs/ESPECIFICACION_DOFA_v2.md`, `docs/CONTRATO_RUTAS.md`) es el modelo a imitar en todo lo que aquí no se diga: estilo, kit de UI (`resources/js/Components/UI`), marca, sondeo cada 5 s con `usePolling`, confirmaciones en diálogo del kit, iniciales en el monitor, respuestas JSON `{ok, ...}`.

---

## 0. Reglas duras (repetidas aquí porque nadie las puede olvidar)

1. No se modifican las tablas `dofa2_*`, `users` ni ninguna migración existente. Solo migraciones NUEVAS y aditivas con prefijo `2026_09_06_`. Nada de `migrate:fresh` ni seeders destructivos.
2. No se instalan paquetes composer ni npm. Se usa lo que ya hay: dompdf, bacon-qr-code, Inertia, Vue 3, Tailwind, axios, Ziggy.
3. Toda la interfaz en español y sin anglicismos. Tablas, rutas, componentes y textos en español; clases PHP en inglés con prefijo `Pe` (`PeSession`, `PeParticipant`, …) para no chocar con los modelos viejos `Aspiration`, `Capability`, `Renunciation` que ya existen en `app/Models`.
4. Nunca nombres ni cargos de personas entrevistadas en textos, semillas ni skills. Nunca datos personales de asociados.
5. La llave de Anthropic no se pega en ningún archivo ni salida.
6. No se toca `retiro1/Retiro1_Dia1_FEDEF.html`.
7. `npm run build` solo lo ejecuta el integrador o el empaquetador, nunca un agente de equipo mientras otro prueba.
8. La sesión DOFA 32 de producción (proyecto 1) puede estar en fase `cruces` o `cerrada` el domingo. PE no depende de ella salvo para leer su último `Dofa2Result` si existe.

---

## 1. Objetivo

Reemplazar las tarjetas de papel y la hoja `retiro1/CAPTURA_BLOQUE4.html` por cuatro dinámicas en el celular, controladas por el facilitador desde un panel, con un monitor para el video beam y una "Hoja de captura" que se llena sola y se exporta a PDF y a JSON:

| Dinámica | Diapositivas | Qué hace cada participante | Qué hace el facilitador | Salida |
|---|---|---|---|---|
| 1. Aspiración | 57 a 60 | Escribe UNA frase "FEDEF gana cuando…" y luego vota hasta 2 formulaciones agrupadas | Agrupa las frases en 2 a 4 formulaciones (a mano o con IA) | Formulaciones con número de frases y votos; frases textuales sin nombre |
| 2. Dónde ganar | 61 y 62 | Vota UNA opción por dimensión (segmento, producto, territorio) | Puede agregar o editar opciones antes de abrir | Ganador por dimensión y empates |
| 3. Capacidades distintivas | 63 y 64 | Propone hasta 2 y vota hasta 3 | Cura la lista: edita, oculta, fusiona, marca tenemos/construimos | Capacidades ordenadas por votos con tipo |
| 4. Renuncias | 66 a 68 | Propone hasta 2 (con evidencia opcional) y vota Sí/No a cada una | Cura la lista igual que capacidades | Renuncias con Sí/No, porcentaje y umbral del 70 % |

La Hoja de captura conserva además la sección 0 (lo que dijo el DOFA: MEFI, MEFE, MIME, cuadrante, top 3 cruces, top 3 tendencias) leída del último resultado de la sesión DOFA enlazada, y la sección 5 (tensiones y clima) como notas libres del facilitador.

---

## 2. Modelo de datos (tablas nuevas, prefijo `pe_`)

Una sola migración: `database/migrations/2026_09_06_100000_create_pe_tables.php`, protegida con `Schema::hasTable` por tabla, compatible con MySQL 8 y SQLite (pruebas), nombres de índices explícitos (≤ 64 caracteres). Todas las tablas con `id` y `timestamps`. Orden de creación: `pe_sesiones`, `pe_participantes`, `pe_grupos_aspiracion`, `pe_aspiraciones`, `pe_votos_aspiracion`, `pe_opciones_donde`, `pe_votos_donde`, `pe_capacidades`, `pe_votos_capacidad`, `pe_renuncias`, `pe_votos_renuncia`, `pe_agrupaciones_ia`, `pe_resultados`.

Claves foráneas: `cascadeOnDelete` hacia `pe_sesiones` y hacia las entidades votadas; `nullOnDelete` hacia `users`, hacia `dofa2_sesiones` (referenciar una tabla `dofa2_*` desde una tabla nueva no la modifica) y en las autorreferencias `fusionada_en_id`.

### 2.1 Tablas

| Modelo | Tabla | Columnas (además de `id`, `timestamps`) | Índices |
|---|---|---|---|
| `PeSession` | `pe_sesiones` | `project_id` FK projects cascade · `dofa2_session_id` FK `dofa2_sesiones` nullable nullOnDelete · `nombre` string(150) · `fase` enum(ver 3) default `configuracion` · `config` json nullable · `notas` json nullable (sección 5 de la hoja, ver 6.4) · `abierta_en` timestamp nullable · `cerrada_en` timestamp nullable · `created_by` FK users nullable nullOnDelete | `pe_sesiones_proyecto_fase` (project_id, fase) |
| `PeParticipant` | `pe_participantes` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `rol_en_sesion` enum('participante','facilitador') default participante · `aspiracion_enviada_en` · `voto_aspiracion_enviado_en` · `donde_enviado_en` · `capacidades_enviadas_en` · `voto_capacidades_enviado_en` · `renuncias_enviadas_en` · `voto_renuncias_enviado_en` (todas timestamp nullable) · `ultimo_visto_en` timestamp nullable | única `pe_participantes_sesion_user` (sesion_id, user_id) |
| `PeAspirationGroup` | `pe_grupos_aspiracion` | `sesion_id` FK cascade · `titulo` string(300) (la formulación agrupada, empieza por "FEDEF gana cuando") · `tema` enum('asociado','sector','interno') nullable · `orden` unsignedInteger default 0 · `origen` enum('ia','facilitador') default facilitador | `pe_grupos_sesion_orden` (sesion_id, orden) |
| `PeAspiration` | `pe_aspiraciones` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `texto` string(300) · `grupo_id` FK `pe_grupos_aspiracion` nullable nullOnDelete | única `pe_aspiraciones_sesion_user` (sesion_id, user_id); `pe_aspiraciones_grupo` (grupo_id) |
| `PeAspirationVote` | `pe_votos_aspiracion` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `grupo_id` FK `pe_grupos_aspiracion` cascade | única `pe_votos_asp_user_grupo` (user_id, grupo_id); `pe_votos_asp_sesion` (sesion_id) |
| `PeWhereOption` | `pe_opciones_donde` | `sesion_id` FK cascade · `dimension` enum('segmento','producto','territorio') · `texto` string(150) · `orden` unsignedInteger default 0 · `es_personalizada` bool default false (true si la agregó el facilitador, false si es semilla) · `visible` bool default true | `pe_opciones_sesion_dim_orden` (sesion_id, dimension, orden) |
| `PeWhereVote` | `pe_votos_donde` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `dimension` enum(igual) · `opcion_id` FK `pe_opciones_donde` cascade | única `pe_votos_donde_sesion_user_dim` (sesion_id, user_id, dimension) |
| `PeCapability` | `pe_capacidades` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete (null en semilla y en las que agrega el facilitador) · `texto` string(200) · `tipo` enum('tenemos','construimos') nullable · `es_base` bool default false · `visible` bool default true · `fusionada_en_id` FK `pe_capacidades` nullable nullOnDelete · `orden` unsignedInteger default 0 | `pe_capacidades_sesion_vis_orden` (sesion_id, visible, orden) |
| `PeCapabilityVote` | `pe_votos_capacidad` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `capacidad_id` FK `pe_capacidades` cascade | única `pe_votos_cap_user_cap` (user_id, capacidad_id); `pe_votos_cap_sesion` (sesion_id) |
| `PeRenunciation` | `pe_renuncias` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `texto` string(300) · `evidencia` string(300) nullable · `es_base` bool default false · `visible` bool default true · `fusionada_en_id` FK `pe_renuncias` nullable nullOnDelete · `orden` unsignedInteger default 0 | `pe_renuncias_sesion_vis_orden` (sesion_id, visible, orden) |
| `PeRenunciationVote` | `pe_votos_renuncia` | `sesion_id` FK cascade · `user_id` FK users nullable nullOnDelete · `renuncia_id` FK `pe_renuncias` cascade · `voto` bool | única `pe_votos_ren_user_ren` (user_id, renuncia_id); `pe_votos_ren_sesion` (sesion_id) |
| `PeGrouping` | `pe_agrupaciones_ia` | `sesion_id` FK cascade · `skill_version` string(40) · `modelo` string(80) · `prompt` longtext · `respuesta_cruda` longtext · `propuesta` json · `estado` enum('propuesta','aplicada','descartada') default propuesta · `tokens_entrada` unsignedInteger default 0 · `tokens_salida` unsignedInteger default 0 · `created_by` FK users nullable nullOnDelete | `pe_agrupaciones_sesion` (sesion_id) |
| `PeResult` | `pe_resultados` | `sesion_id` FK cascade · `version` unsignedInteger · `calculado_en` timestamp · `datos` json (sección 6) · `participantes_incluidos` json | única `pe_resultados_sesion_version` (sesion_id, version) |

Notas:
- `pe_aspiraciones` tiene una fila por participante (única `sesion_id, user_id`): el autoguardado hace `updateOrCreate`. Un participante borrado deja la fila con `user_id = null`; sigue contando como frase anónima.
- Al igual que `dofa2_participantes`, la relación `PeSession::participantes()` y el scope `PeParticipant::deSesion()` filtran `user_id IS NOT NULL`, y `sincronizarParticipantes` borra las huérfanas.
- `sincronizarParticipantes` nunca retira a un inscrito que ya tenga registros en la sesión (frase con texto, voto, propuesta o marca de envío) aunque no venga en la lista: devuelve sus `user_id` en `conservados`. Motivo: quien entra por QR después de que el facilitador abrió el panel no está en el diálogo «Inscribir o retirar» y un Guardar lo desinscribía (403 en cada toque del celular). El celular, además, trata un 403 como orden de recargar (`GET /participar` vuelve a inscribir).
- Una sesión se puede borrar (`pe.sesiones.destroy`) solo en `configuracion` y sin participantes inscritos; en cualquier otro estado, 409.
- No se crean columnas nuevas en `users`. Los permisos se reutilizan: `participar_dofa` para participar y `facilitar_dofa` para facilitar. No hay migración de permisos.

### 2.2 `config` json de `pe_sesiones` (`PeSession::CONFIG_POR_DEFECTO`)

```json
{
  "max_caracteres_aspiracion": 300,
  "max_votos_aspiracion": 2,
  "max_capacidades_por_persona": 2,
  "max_votos_capacidades": 3,
  "max_renuncias_por_persona": 2,
  "umbral_aprobacion_renuncia": 70,
  "margen_empate": 1,
  "permitir_editar_tras_enviar": false,
  "historial_fases": []
}
```

Los límites son los del guion y no se exponen en el formulario del panel salvo `umbral_aprobacion_renuncia` y `permitir_editar_tras_enviar`; el backend valida siempre contra `configCompleta()`. `historial_fases[]` guarda `{de, a, en, por, envios_marcados}` como en DOFA. `configPublica()` quita `historial_fases`.

### 2.3 `notas` json de `pe_sesiones` (sección 5 de la hoja y pistas de cada caja)

Campos de texto libre que el facilitador llena desde `Pe/Resultados` (todos string nullable, ≤ 1000 caracteres):

`frase_para_guardar`, `meta_de_tamano`, `que_dejamos_de_hacer`, `tecnologia_que_notaria`, `capacidad_unica`, `renuncia_mas_costosa`, `tension_principal`, `divergencia_gerencia_junta`, `pregunta_abierta`, `desacuerdos_dofa`.

Corresponden, en ese orden, a las pistas de las cajas 1, 1, 2, 3, 3, 4, 5, 5, 5 y 0 de `CAPTURA_BLOQUE4.html`. Van al PDF y a `resultados.json` (`notas`). Nunca deben contener nombres; el formulario lo recuerda con una ayuda.

### 2.4 Semillas al crear una sesión (`PeSesionService::crear`)

Se siembran en la misma transacción que crea la sesión. Se enlaza automáticamente `dofa2_session_id` = `Dofa2Session::activaPara($project->id)?->id` (la más reciente no cerrada, o la última cerrada). Los textos son exactamente estos (sin nombres, sin cargos):

**Opciones "dónde"** (`es_personalizada = false`, `orden` 1..n):

| dimension | texto |
|---|---|
| segmento | Base actual (profundizar) |
| segmento | Nuevos empleadores |
| segmento | Pensionados e independientes |
| segmento | Jóvenes menores de 25 |
| producto | Crédito (más asociados con crédito) |
| producto | Ahorro y depósitos |
| producto | Bienestar y beneficios |
| producto | Vivienda |
| territorio | Sabana de Occidente (4 municipios) |
| territorio | Chía y nuevos municipios |
| territorio | Multiempresa sin territorio |

**Capacidades base** (`es_base = true`, `user_id = null`, `tipo = null`, `orden` 1..4), tomadas de la diapositiva 63:

1. Cultura de pago y solidez patrimonial
2. Atención personalizada y cercanía
3. Agilidad en la decisión de crédito
4. Reputación y confianza en el sector

**Renuncias base** (`es_base = true`, `user_id = null`, `orden` 1..9), tomadas de la diapositiva 67 "Nueve cosas que podríamos dejar de hacer", con su evidencia:

| # | texto | evidencia |
|---|---|---|
| 1 | La SAS o afianzadora | Avance 0 %; "decisión de no avanzar por el momento". Cuatro de cinco entrevistas la dan por descartada. |
| 2 | Crecer la base social como objetivo en sí | Meta re-basada a +29,6 %; en 2026 van +59 asociados netos en cinco meses con 584 retiros. |
| 3 | Las encuestas en su formato actual | Responde el 17,6 % de los encuestados y el 1,5 % de la base; "no hay nivel de confianza". |
| 4 | Bienestar en su formato actual | Programa al 70 %; cada beneficio de evento llega a menos del 10 % de la base. |
| 5 | Metas financieras como objetivos | Tres de los ocho objetivos 2024-2026 eran metas de tamaño; el activo de 70.000 M COP no tuvo indicador. |
| 6 | La línea hipotecaria | 0 desembolsos en 2025; saldo 1.328 M COP. La vivienda migró a consumo-vivienda con garantía personal. |
| 7 | Campañas dispersas | El portafolio de campañas cambia por completo cada año; −53 % en número en 2025. |
| 8 | Oficinas físicas donde no retenemos | Suba cerró en 2023; Chía tiene el 9,4 % de la cartera y un retiro del 40,3 % (2025). |
| 9 | Proyectos sin cierre | Cobranzas suspendido (0 %), centralización de datos 50 %, autogestión 10 %: transformación digital al 22,9 % según el BSC (40 % con el denominador correcto). |

Los textos de semilla viven en `resources/data/fedef/pe_semillas.json` (`{opciones_donde: [...], capacidades: [...], renuncias: [...]}`) y el servicio los lee de ahí; así la prueba de esquema puede comprobar que se sembraron 11 + 4 + 9 filas.

### 2.5 Constantes y métodos de los modelos (nombres obligatorios)

`PeSession`
- `FASE_CONFIGURACION`, `FASE_ASPIRACION_ESCRIBIR`, `FASE_ASPIRACION_AGRUPAR`, `FASE_ASPIRACION_VOTAR`, `FASE_DONDE`, `FASE_CAPACIDADES_PROPONER`, `FASE_CAPACIDADES_VOTAR`, `FASE_RENUNCIAS_PROPONER`, `FASE_RENUNCIAS_VOTAR`, `FASE_CERRADA`; `FASES` (lista ordenada); `ETIQUETAS_FASE` (sección 3); `CONFIG_POR_DEFECTO`; `TEMAS = ['asociado','sector','interno']`; `DIMENSIONES = ['segmento','producto','territorio']`; `ETIQUETAS_DIMENSION`; `NOTAS_CAMPOS` (2.3).
- Relaciones: `project()`, `sesionDofa()` (belongsTo `Dofa2Session`, `dofa2_session_id`), `creador()`, `participantes()`, `usuarios()`, `aspiraciones()`, `gruposAspiracion()`, `votosAspiracion()`, `opcionesDonde()`, `votosDonde()`, `capacidades()`, `votosCapacidad()`, `renuncias()`, `votosRenuncia()`, `agrupaciones()`, `resultados()`.
- Scopes: `deProyecto`, `noCerradas`, `cerradas`, `enFase`.
- Estáticos: `activaPara(int $projectId): ?PeSession` (misma regla que DOFA: la de mayor id no cerrada; si no, la última cerrada; si no, null), `activaDelProyectoActivo()`, `indiceFase`, `esFaseValida`.
- Fases: `puedeAvanzarA`, `siguienteFase`, `faseAnterior`, `faseAlcanzada`, `estaEnFase`, `esCerrada`, `etiquetaFase`, `esFaseDeParticipacion()` (todas menos configuracion, aspiracion_agrupar y cerrada).
- Config: `configCompleta`, `configValor`, `configPublica`, `permiteEditarTrasEnviar`, `umbralAprobacion(): int`, `margenEmpate(): int`.
- Otros: `participanteDe(User|int)`, `ultimoResultado(): ?PeResult`, `ultimoResultadoDofa(): ?Dofa2Result` (del enlace, o null), `versionDatos(): ?string` (sección 4.9).

`PeParticipant`: `ROL_PARTICIPANTE`, `ROL_FACILITADOR`, `ROLES`; `DINAMICAS = ['aspiracion','voto_aspiracion','donde','capacidades','voto_capacidades','renuncias','voto_renuncias']`; `COLUMNA_ENVIO` (dinámica ⇒ columna, tabla en 3.2); `FASE_DE_DINAMICA` (dinámica ⇒ fase); `DINAMICA_DE_FASE` (fase ⇒ dinámica o null); scopes `deSesion`, `conRol`, `queParticipan` (rol participante), `queEnviaron($dinamica)`, `huerfanos`; `envio(string $dinamica): bool`, `esFacilitador()`.

`PeAspiration`: `MAX_TEXTO = 300`; scopes `deSesion`, `deUsuario`, `conTexto` (texto no vacío), `sinGrupo`, `deGrupo`.
`PeAspirationGroup`: `MAX_TITULO = 300`; `ORIGEN_IA`, `ORIGEN_FACILITADOR`; scopes `deSesion`, `ordenados`; relación `aspiraciones()`, `votos()`.
`PeAspirationVote`: scopes `deSesion`, `deUsuario`, `deUsuarios`.
`PeWhereOption`: `DIM_SEGMENTO`, `DIM_PRODUCTO`, `DIM_TERRITORIO`, `MAX_TEXTO = 150`; scopes `deSesion`, `visibles`, `porDimension`, `ordenados`.
`PeWhereVote`: scopes `deSesion`, `deUsuario`, `deUsuarios`.
`PeCapability`: `TIPO_TENEMOS`, `TIPO_CONSTRUIMOS`, `TIPOS`, `MAX_TEXTO = 200`; scopes `deSesion`, `visibles`, `deUsuario`, `base`, `ordenados`; relaciones `autor()`, `votos()`, `fusionadaEn()`.
`PeCapabilityVote`: scopes `deSesion`, `deUsuario`, `deUsuarios`.
`PeRenunciation`: `MAX_TEXTO = 300`, `MAX_EVIDENCIA = 300`; mismos scopes que `PeCapability`.
`PeRenunciationVote`: scopes `deSesion`, `deUsuario`, `deUsuarios`, `afirmativos`, `negativos`.
`PeGrouping`: `ESTADO_PROPUESTA`, `ESTADO_APLICADA`, `ESTADO_DESCARTADA`; `gruposPropuestos()`, `sinGrupo()`, `notasParaElFacilitador()`.
`PeResult`: scopes `deSesion`, `ultimoPrimero`; estático `siguienteVersion($sesionId)`; `datosPublicos()` (quita `por_participante`).
`User` (añadir, sin migración): `peSesiones()` belongsToMany. `Project` (añadir): `peSessions()`.

---

## 3. Fases y máquina de estados

```
configuracion → aspiracion_escribir → aspiracion_agrupar → aspiracion_votar → donde
  → capacidades_proponer → capacidades_votar → renuncias_proponer → renuncias_votar → cerrada
```

`ETIQUETAS_FASE`: configuracion "Configuración" · aspiracion_escribir "Aspiración: escribir" · aspiracion_agrupar "Aspiración: agrupar" · aspiracion_votar "Aspiración: votar" · donde "Dónde ganar" · capacidades_proponer "Capacidades: proponer" · capacidades_votar "Capacidades: votar" · renuncias_proponer "Renuncias: proponer" · renuncias_votar "Renuncias: votar" · cerrada "Cerrada".

Rótulos del botón grande de avance en el panel (`ETIQUETA_AVANCE`, clave = fase destino): aspiracion_escribir "Abrir aspiración" · aspiracion_agrupar "Pasar a agrupar" · aspiracion_votar "Abrir votación de aspiración" · donde "Abrir dónde ganar" · capacidades_proponer "Abrir propuestas de capacidades" · capacidades_votar "Abrir votación de capacidades" · renuncias_proponer "Abrir propuestas de renuncias" · renuncias_votar "Abrir votación de renuncias" · cerrada "Cerrar y consolidar". El botón de retroceso se rotula "Volver a {etiqueta de la fase anterior}". Ambos con diálogo de confirmación del kit (`Modal`); "Cerrar y consolidar" en variante peligro.

### 3.1 Reglas de transición (`PeSesionService::cambiarFase($sesion, $fase, User $por)`)

- Solo `facilitar_dofa`. Solo a la fase siguiente o a la anterior (`puedeAvanzarA`); si no, 409 con el mismo texto que DOFA. Misma fase: no hace nada y devuelve 200.
- Con `lockForUpdate` dentro de una transacción, como DOFA.
- **Al avanzar** desde una fase de participación se ejecuta `marcarEnviosCompletos($sesion, $faseQueSeCierra)` (3.3) y se registra `envios_marcados` en el historial.
- Condiciones para avanzar:
  - a `aspiracion_votar`: debe haber al menos 2 grupos en `pe_grupos_aspiracion` y todos con `titulo` no vacío (409 "Agrupe las frases en al menos dos formulaciones antes de abrir la votación."). Las frases sin grupo se permiten (quedan como "sin agrupar" en resultados).
  - a `donde`: al menos una opción visible por dimensión (409).
  - a `capacidades_votar`: al menos una capacidad visible (409). A `renuncias_votar`: al menos una renuncia visible (409).
  - a `cerrada`: sin condición; se ejecuta `PeResultadosService::calcular($sesion, $por)` y se guarda `cerrada_en = now()`.
- `abierta_en = now()` la primera vez que se pasa a `aspiracion_escribir`.
- **Al retroceder** a la fase X: `deshacerEnvios($sesion, X)` limpia las marcas de envío de la dinámica de X y de todas las posteriores (tabla 3.2). Al retroceder desde `cerrada`, `cerrada_en = null` (los `pe_resultados` calculados se conservan como versiones anteriores). Retroceder a `aspiracion_agrupar` conserva los grupos y los votos de aspiración guardados (si el facilitador borra un grupo, sus votos se van por cascada). Retroceder a `configuracion` limpia todas las marcas pero no borra ningún dato.
- Nunca se borran datos de participantes al cambiar de fase.

### 3.2 Dinámicas, columnas de envío y fases

| Dinámica (`{dinamica}` de `pe.enviar`) | Fase en la que se escribe | Columna en `pe_participantes` | "Completa" para el marcado automático (3.3) | Validación del botón Enviar |
|---|---|---|---|---|
| `aspiracion` | aspiracion_escribir | `aspiracion_enviada_en` | tiene `pe_aspiraciones.texto` no vacío | texto de 1 a 300 caracteres |
| `voto_aspiracion` | aspiracion_votar | `voto_aspiracion_enviado_en` | ≥ 1 voto | 1 a `max_votos_aspiracion` votos |
| `donde` | donde | `donde_enviado_en` | ≥ 1 voto en alguna dimensión | exactamente 1 voto en cada una de las 3 dimensiones (faltantes = dimensiones sin voto) |
| `capacidades` | capacidades_proponer | `capacidades_enviadas_en` | ≥ 1 capacidad propia | 0 a `max_capacidades_por_persona` propias (se permite enviar con 0: "no propongo") |
| `voto_capacidades` | capacidades_votar | `voto_capacidades_enviado_en` | ≥ 1 voto | 1 a `max_votos_capacidades` votos a capacidades visibles |
| `renuncias` | renuncias_proponer | `renuncias_enviadas_en` | ≥ 1 renuncia propia | 0 a `max_renuncias_por_persona` propias |
| `voto_renuncias` | renuncias_votar | `voto_renuncias_enviado_en` | ≥ 1 voto | un voto Sí/No por CADA renuncia visible (faltantes = ids sin voto) |

Retroceder a la fase X limpia las columnas de X y de las filas siguientes de esta tabla. Retroceder a `aspiracion_agrupar` limpia desde `voto_aspiracion` en adelante.

### 3.3 Marcado automático al avanzar (decisión, ver 9)

En DOFA se marca como enviado a "quien tenía la fase completa". En PE la unidad es un toque (una frase, un voto), y una entrada parcial sigue siendo información útil para el facilitador (dos de tres dimensiones en "dónde", un voto de tres en capacidades). Por eso `marcarEnviosCompletos` marca a **todo participante con rol participante que tenga al menos un registro en la dinámica que se cierra** y todavía no esté marcado. Consecuencia: en la práctica todo lo guardado cuenta en los resultados, salvo lo de quien no tocó nada; la marca de envío sirve para el panel (quién terminó), para bloquear la edición y para que un cálculo intermedio (antes de avanzar) solo cuente a quien pulsó Enviar.

### 3.4 Qué ve el participante en cada fase (`Pe/Participar`)

| Fase | Componente | Texto de espera |
|---|---|---|
| configuracion | `Espera` | "La sesión aún no ha comenzado" (en la práctica no se llega: `/participar` resuelve DOFA mientras PE esté en configuración) |
| aspiracion_escribir | `AspiracionEscribir` | — |
| aspiracion_agrupar | `Espera` | "El facilitador está agrupando las frases. Esta pantalla cambiará sola cuando abra la votación." |
| aspiracion_votar | `AspiracionVotar` | — |
| donde | `Donde` | — |
| capacidades_proponer | `CapacidadesProponer` | — |
| capacidades_votar | `CapacidadesVotar` | — |
| renuncias_proponer | `RenunciasProponer` | — |
| renuncias_votar | `RenunciasVotar` | — |
| cerrada | `Cierre` | "Gracias por participar" con el resumen público |

---

## 4. Reglas de cada dinámica

Comunes a todas las escrituras del participante (`/pe/sesiones/{sesion}/...`):
- Exige `participar_dofa`, inscripción con `rol_en_sesion = participante` (403), fase correcta (409, `DofaException::faseIncorrecta`), no haber enviado esa dinámica salvo `permitir_editar_tras_enviar` (409 con `motivo: 'ya_enviado'`). Se reutiliza `App\Services\Dofa\DofaException` tal cual (mismos códigos y misma vista `errors.dofa`); no se crea otra clase de excepción.
- Autoguardado en cada toque (el celular llama al endpoint al cambiar el valor, con un retardo de 400 ms para texto); el componente muestra `IndicadorGuardado` como en DOFA. Ante 409 el celular deja de reintentar y recarga.
- Inscripción automática: `ParticiparController::index` inscribe al usuario en la sesión PE resuelta con `firstOrCreate` (mismo patrón y misma tolerancia a carreras que `inscribirOActualizar`), salvo si es facilitador, si la sesión está cerrada o si no tiene `participar_dofa`. `/participar/estado` actualiza `ultimo_visto_en` de la inscripción PE cuando el módulo resuelto es `pe`.
- El facilitador nunca se inscribe como participante; el panel puede inscribir usuarios con `pe.sesiones.participantes`.

### 4.1 Aspiración: escribir (`aspiracion_escribir`)

- Una sola frase por persona, `texto` de 1 a 300 caracteres tras `trim`. La interfaz muestra el prefijo fijo "FEDEF gana cuando…" encima del campo y el contador `n / 300`; el participante escribe la continuación o la frase completa: el backend guarda exactamente lo que llega, sin anteponer nada. Si el texto no empieza por "FEDEF gana cuando" (comparación sin acentos ni mayúsculas), al presentarlo en resultados se antepone "FEDEF gana cuando " solo para la lectura; en la base queda el original.
- `PUT /aspiracion` hace `updateOrCreate` por (sesion_id, user_id). Texto vacío se guarda como cadena vacía (no borra la fila): así se conserva el `updated_at`.
- Enviar (`pe.enviar` con `aspiracion`): 409 con `faltantes: ['texto']` si está vacío.
- Monitor: contador `n_enviadas / n_inscritos` y las frases enviadas como tarjetas anónimas en orden de llegada (`updated_at`). Solo se muestran frases con marca de envío; no se muestran borradores.

### 4.2 Aspiración: agrupar (`aspiracion_agrupar`, facilitador)

- Pantalla `Pe/Agrupar`: frases a la izquierda (todas las que tienen texto, sin nombres ni iniciales; con etiqueta "sin agrupar" o el rótulo del grupo), grupos a la derecha (2 a 4). Asignación por clic: seleccionar una frase y tocar un grupo; o arrastrar. Cada frase pertenece a un grupo o a ninguno.
- `POST /grupos` guarda en lote `{grupos: [{id?, titulo, tema, orden, aspiracion_ids: []}], eliminar: [ids]}`. Validación: 0 a 4 grupos tras aplicar `eliminar`; `titulo` 1 a 300 caracteres; `tema` ∈ TEMAS o null; una frase no puede estar en dos grupos del mismo lote (422). Las frases no listadas en ningún `aspiracion_ids` quedan con `grupo_id = null`.
- Se permite guardar mientras la sesión esté en `aspiracion_escribir`, `aspiracion_agrupar` o `aspiracion_votar` (para corregir un título en vivo). Fuera de esas fases, 409.
- Botón "Agrupar con IA": `POST /agrupar/ia` ejecuta `PeAgrupadorIA` (sección 7) de forma síncrona (hasta 120 s), guarda una fila en `pe_agrupaciones_ia` con estado `propuesta` y devuelve la propuesta. "Aplicar propuesta" (`POST /grupos/aplicar-propuesta`) reemplaza los grupos actuales por los propuestos (borra los existentes, crea los nuevos con `origen = 'ia'`, asigna `grupo_id` a las frases citadas, deja las demás sin grupo), marca la agrupación como `aplicada` y las anteriores como `descartada`. Después el facilitador edita a mano con `POST /grupos`.
- Si la IA no está configurada (`ClaudeService::configurada() === false`) el botón aparece deshabilitado con la ayuda "La llave de la IA no está configurada; agrupe a mano".
- Celulares: `Espera` "El facilitador está agrupando".

### 4.3 Aspiración: votar (`aspiracion_votar`)

- `POST /aspiracion/votos` con `{grupo_ids: []}` reemplaza el conjunto de votos del participante. 0 a `max_votos_aspiracion` (2) ids distintos de grupos de la sesión (422 si sobran o no existen).
- Enviar: 409 `faltantes: ['grupo_ids']` si 0 votos.
- Monitor: barras por grupo con la formulación completa, número de votos y porcentaje sobre `n_votantes`; se resalta el grupo con más votos; si los dos primeros difieren en ≤ `margen_empate`, ambos con la etiqueta "Empate".

### 4.4 Dónde ganar (`donde`)

- Antes de abrir la fase (en cualquier fase anterior a `donde`, y también durante `donde` para corregir un texto) el facilitador cura las opciones con `POST /opciones-donde`: `{opciones: [{id?, dimension, texto, orden, visible}], eliminar: [ids]}`. Texto 1 a 150. Las opciones nuevas se crean con `es_personalizada = true`. Eliminar una opción con votos borra los votos por cascada: el panel lo advierte en el diálogo ("Esta opción tiene n votos"). No se puede dejar una dimensión sin opciones visibles si la fase es `donde` (409).
- `POST /donde/votos` con `{votos: {segmento: id|null, producto: id|null, territorio: id|null}}`; claves opcionales (upsert parcial por dimensión). `null` borra el voto de esa dimensión. 422 si la opción no pertenece a la sesión, no es visible o no es de esa dimensión.
- Enviar: 409 con `faltantes: [dimensiones sin voto]`.
- Monitor: tres columnas (Segmento, Producto, Territorio), barras en vivo por opción con votos y porcentaje sobre los votantes de esa dimensión; ganadora resaltada; empate (4.8) señalado con rótulo "Empate" en cada opción empatada.

### 4.5 Capacidades: proponer (`capacidades_proponer`)

- `POST /capacidades` `{texto}` crea una capacidad propia (`user_id` = participante, `es_base = false`, `visible = true`, `orden` = siguiente). Máximo `max_capacidades_por_persona` (2) visibles propias: 422 "Ya propuso el máximo de 2 capacidades". Texto 1 a 200.
- `PATCH /capacidades/{capacidad}` `{texto}` y `DELETE` solo sobre las propias (403) y solo en `capacidades_proponer` (409). Una capacidad propia ya fusionada por el facilitador (`fusionada_en_id` no nulo) no se puede editar ni borrar (409).
- El celular muestra arriba las cuatro capacidades base como referencia (solo lectura, con la ayuda "Ya están en la lista; proponga otras o precise una") y debajo "Mis propuestas".
- Enviar: se permite con 0 propuestas ("Enviar sin proponer"); el diálogo lo aclara.
- Curaduría del facilitador (en el panel, en `capacidades_proponer` y `capacidades_votar`): `POST /curar/capacidades` `{agregar: [{texto, tipo?}], editar: [{id, texto?, tipo?, visible?, orden?}], fusionar: [{origen_id, destino_id}], eliminar: [ids]}`.
  - Fusionar: la de `origen_id` pasa a `visible = false`, `fusionada_en_id = destino_id`; sus votos se mueven a `destino_id` (si el votante ya votó al destino, se descarta el duplicado); no se puede fusionar una ya fusionada ni consigo misma (422).
  - Ocultar (`visible = false`) no borra votos; una capacidad oculta no se puede votar (422) y no entra en resultados; sus votos existentes se ignoran mientras esté oculta.
  - Eliminar borra la fila y sus votos por cascada; solo se permite en `capacidades_proponer` (409 en votar: usar ocultar).
  - Marcar `tipo` tenemos/construimos en cualquier fase desde `capacidades_proponer` hasta `cerrada` inclusive (el facilitador lo hace al leer los resultados).
  - Las que agrega el facilitador quedan con `user_id = null`, `es_base = false`.

### 4.6 Capacidades: votar (`capacidades_votar`)

- `POST /capacidades/votos` `{capacidad_ids: []}` reemplaza; 0 a `max_votos_capacidades` (3) ids distintos, visibles, de la sesión.
- Enviar: 409 si 0 votos.
- Monitor: barras ordenadas por votos descendente (desempate por `orden`), con texto, votos, porcentaje sobre `n_votantes` y la etiqueta tenemos/construimos si ya está marcada; las tres primeras resaltadas.

### 4.7 Renuncias: proponer y votar

- Proponer (`renuncias_proponer`): igual que capacidades con `texto` 1 a 300 y `evidencia` 0 a 300 opcional; máximo `max_renuncias_por_persona` (2). El celular muestra las nueve base con su evidencia (plegable) y "Mis propuestas". Curaduría con `POST /curar/renuncias` (mismo cuerpo que capacidades más `evidencia?` en agregar y editar; sin `tipo`).
- Votar (`renuncias_votar`): `POST /renuncias/votos` `{votos: [{renuncia_id, voto: bool}]}` upsert parcial. 422 si la renuncia no es visible o no es de la sesión.
- Enviar: 409 con `faltantes: [ids de renuncias visibles sin voto]`.
- Fusionar renuncias durante la votación mueve los votos al destino; si el votante tenía voto en ambas, prevalece el que ya tenía en el destino.
- Monitor: una barra doble Sí/No por renuncia con `pct_si`; las que alcanzan el umbral (`umbral_aprobacion_renuncia`, 70 %) resaltadas con la etiqueta "Aprobada"; línea del umbral dibujada. Orden: `pct_si` descendente, luego `si` descendente, luego `orden`.

### 4.8 Ganadores, empates y porcentajes (`PeResultadosService`, clase pura `PeCalculadora` para lo aritmético)

- Solo cuentan los votos de participantes con la marca de envío de esa dinámica (tras avanzar, todos los que tocaron algo, por 3.3). `n_votantes` de una dinámica = número de participantes marcados que tienen al menos un voto en ella.
- `pct` = `votos / n_votantes × 100`, redondeado a 1 decimal; 0 si `n_votantes = 0`.
- Aspiración: `ganador_id` = grupo con más votos; si dos o más grupos comparten el máximo, `ganador_id = null` y `empatados` = sus ids. Además, `empate_cercano = true` si el segundo difiere del primero en ≤ `margen_empate` (1 voto); en ese caso `empatados` incluye a todos los que están a ≤ margen del máximo (el máximo incluido).
- Dónde (por dimensión): igual: `ganadora_id` (única con el máximo o null), `empatadas` (todas las opciones a ≤ `margen_empate` del máximo cuando hay al menos dos, incluida la máxima), `empate` bool. Con 0 votos: `ganadora_id = null`, `empatadas = []`, `empate = false`.
- Capacidades: ordenadas por votos desc, `orden` asc; `top = 3` primeras con votos > 0; `empatadas_en_corte` = ids con los mismos votos que la tercera cuando hay más de tres con ese valor.
- Renuncias: `si`, `no`, `n = si + no`, `pct_si = si / n × 100` (1 decimal, 0 si `n = 0`), `aprobada = n ≥ 1 && pct_si ≥ umbral`. Para el PDF se añade `pct_si_sobre_votantes = si / n_votantes × 100`.
- Precisión: enteros para votos; floats solo en porcentajes, redondeados al presentar (1 decimal). Nada de promedios ponderados en PE.

### 4.9 Versión de datos para el celular

`PeSession::versionDatos()` = máximo `updated_at` (string) entre `pe_grupos_aspiracion`, `pe_opciones_donde`, `pe_capacidades` y `pe_renuncias` de la sesión, o null. Va en `/participar/estado` como `datos_version` cuando `modulo = 'pe'`; `Pe/Participar` recarga cuando cambia (una fusión o una nueva opción durante la votación llega al celular en ≤ 5 s).

---

## 5. Resolución de `/participar` y sondeo (cambios en `Acceso/ParticiparController`)

Regla de resolución (`ParticiparController::resolverModulo(Project $project): array{modulo, sesion}`):

1. `$pe = PeSession::activaPara($project->id)`; `$dofa = Dofa2Session::activaPara($project->id)`.
2. Si `$pe` existe, no está cerrada y su fase no es `configuracion` → `['pe', $pe]`.
3. Si `$pe` existe y está cerrada: si `$dofa` es null → `['pe', $pe]`; si no, manda la sesión que se abrió más tarde: con `inicio = abierta_en ?? created_at`, si `inicio(pe) >= inicio(dofa)` → `['pe', $pe]`; si no → `['dofa', $dofa]`. La fase de la DOFA no cuenta: la PE del domingo, ya cerrada, gana a la DOFA del sábado aunque esta siga en `cruces` o se cierre después (corrección de la ronda 1: con la regla anterior, que comparaba `cerrada_en`, «Cerrar y consolidar» devolvía los celulares a la pantalla DOFA y cerrar la DOFA después lo dejaba así).
4. En cualquier otro caso (sin PE, o PE en configuración) → `['dofa', $dofa]` con el flujo actual sin cambios (incluida la pantalla `Acceso/Espera` cuando `$dofa` es null).

`GET /participar` renderiza `Pe/Participar` cuando el módulo es `pe` (props en el contrato) y `Dofa/Participar` en caso contrario, exactamente como hoy.

`GET /participar/estado` devuelve siempre `modulo`, `fase`, `sesion_id`, `factores_version` (solo DOFA; null en PE), `datos_version` (solo PE; null en DOFA) y `actualizado_en`. La página DOFA abierta en un celular compara `fase` y `sesion_id`: como los nombres de fase de PE (salvo `configuracion` y `cerrada`, que nunca se resuelven a PE con DOFA abierta en la misma fase e id) son distintos de los de DOFA, el cambio de módulo dispara la recarga sin tocar `Dofa/Participar.vue`. `Pe/Participar.vue` compara además `modulo`.

Middleware `RedirigirParticipanteAlRetiro`: añadir `'pe/*'` a `PERMITIDAS` (edición de una línea; la hace el equipo de acceso/integración).

Menú del facilitador (`AppLayout.vue`): nueva entrada `{clave: 'pe', texto: 'Pensamiento', href: route('pe.panel', dofa.project_id), activo: estaEn('pe.*')}` justo después de "DOFA", visible con las mismas condiciones (`puede_facilitar` y `project_id`), usando el prop compartido `$page.props.dofa` que ya existe (no se crea otro prop compartido).

---

## 6. Resultados (`pe_resultados.datos`) y exportaciones

### 6.1 Cálculo

`PeResultadosService::calcular(PeSession $sesion, ?User $por): PeResult` en cualquier fase desde `aspiracion_votar` (409 antes); guarda versión incremental; `participantes_incluidos` = `{aspiracion: [iniciales], voto_aspiracion: [...], donde: [...], voto_capacidades: [...], voto_renuncias: [...]}`. Al cerrar se calcula automáticamente. "Recalcular" desde `Pe/Resultados` crea otra versión.

### 6.2 Estructura de `datos` (= cuerpo de `resultados.json`)

```json
{
  "sesion": {"id": 1, "nombre": "Retiro 1 · Pensamiento estratégico", "fase": "cerrada", "calculado_en": "2026-09-06T09:40:00-05:00", "version": 1, "abierta_en": "...", "cerrada_en": "...", "n_inscritos": 16},
  "dofa": {
    "disponible": true,
    "sesion": {"id": 32, "nombre": "Retiro 1 · DOFA", "fase": "cerrada", "version_resultado": 3, "calculado_en": "..."},
    "mefi": 2.6267, "mefe": 2.5,
    "mime": {"x": 2.5, "y": 2.6267, "celda": "V", "zona": "RESISTA", "zona_descripcion": "Retener y mantener"},
    "cuadrante_dominante": "FO", "cuadrantes_empatados": [], "cuadrantes": {"FO": {"suma": 98.88, "pct": 29.0}, "FA": {...}, "DO": {...}, "DA": {...}},
    "top_cruces": [{"rango": 1, "interno": "D1", "externo": "A3", "interno_texto": "...", "externo_texto": "...", "cuadrante": "DA", "valor": 4.316, "calif": 8.67}],
    "top_tendencias": [{"rango": 1, "codigo": "T1", "texto": "...", "votos": 7, "pct": 77.8}],
    "referencia_2024": {"mefi": 2.63, "mefe": 2.5, "zona": "RESISTA", "cuadrante": "FO", "pct": 29.0}
  },
  "aspiracion": {
    "n_frases": 15, "n_votantes": 14, "max_votos": 2,
    "grupos": [{"id": 3, "letra": "A", "titulo": "FEDEF gana cuando…", "tema": "asociado", "orden": 1, "n_frases": 6, "votos": 9, "pct": 64.3, "frases": [{"id": 11, "texto": "..."}]}],
    "sin_grupo": [{"id": 12, "texto": "..."}],
    "ganador_id": 3, "empatados": [], "empate_cercano": false
  },
  "donde": {
    "n_votantes": 15, "margen_empate": 1,
    "dimensiones": {
      "segmento": {"etiqueta": "Segmento", "n_votantes": 15, "opciones": [{"id": 1, "texto": "Base actual (profundizar)", "es_personalizada": false, "votos": 8, "pct": 53.3, "ganadora": true, "empatada": false}], "ganadora_id": 1, "empatadas": [], "empate": false},
      "producto": {...}, "territorio": {...}
    },
    "resumen": {"segmento": "Base actual (profundizar)", "producto": "Crédito (más asociados con crédito)", "territorio": null}
  },
  "capacidades": {
    "n_propuestas": 9, "n_votantes": 15, "max_votos": 3,
    "lista": [{"rango": 1, "id": 4, "texto": "...", "tipo": "tenemos", "es_base": true, "votos": 11, "pct": 73.3, "top": true}],
    "top": [4, 7, 2], "empatadas_en_corte": [],
    "ocultas": [{"id": 8, "texto": "...", "fusionada_en_id": 7}]
  },
  "renuncias": {
    "n_propuestas": 11, "n_votantes": 15, "umbral": 70,
    "lista": [{"rango": 1, "id": 2, "texto": "...", "evidencia": "...", "es_base": true, "si": 13, "no": 2, "n": 15, "pct_si": 86.7, "pct_si_sobre_votantes": 86.7, "aprobada": true}],
    "aprobadas": [2, 1, 6]
  },
  "notas": {"frase_para_guardar": null, "meta_de_tamano": null, "que_dejamos_de_hacer": null, "tecnologia_que_notaria": null, "capacidad_unica": null, "renuncia_mas_costosa": null, "tension_principal": null, "divergencia_gerencia_junta": null, "pregunta_abierta": null, "desacuerdos_dofa": null},
  "por_participante": {"resumen": [{"user_id": 3, "iniciales": "NF", "aspiracion": true, "voto_aspiracion": 2, "donde": 3, "capacidades": 1, "voto_capacidades": 3, "renuncias": 0, "voto_renuncias": 11}]},
  "participantes_incluidos": {"aspiracion": ["NF", "JP"], "voto_aspiracion": ["..."], "donde": ["..."], "voto_capacidades": ["..."], "voto_renuncias": ["..."]},
  "version_motor": "1.0.0"
}
```

- `dofa.disponible = false` (y el resto de claves de `dofa` en null) cuando no hay sesión enlazada o no tiene `Dofa2Result`. `top_cruces` = 3 primeros de `cruzada.top_valor`, con los textos buscados en `datos.factores` por código. `top_tendencias` = 3 primeras de `tendencias`. `referencia_2024` son constantes (hoja de captura).
- `aspiracion.grupos[].letra` = A, B, C, D por `orden`. Las frases se listan sin nombre ni iniciales.
- `por_participante` solo lo ve el facilitador; `datosPublicos()` lo quita. Los participantes reciben `datosPublicos()` en `Cierre`.
- `resumen_publico` para `Cierre` (lo arma el controlador a partir de `datosPublicos()`): formulación ganadora (o las empatadas), dónde (tres textos), top 3 capacidades, renuncias aprobadas.

### 6.3 PDF (`resources/views/pdf/pe_hoja_captura.blade.php`, dompdf, tamaño carta)

Mismo orden y mismos rótulos que `CAPTURA_BLOQUE4.html`: cabecera ("Hoja de captura · Bloque 4 · Pensamiento estratégico", proyecto, sesión, fecha y hora de cálculo, versión); caja 0 Lo que dijo el DOFA (MEFI, MEFE con referencia 2024; MIME celda y zona; cuadrante dominante y ¿empate?; tabla de tres cruces y tres tendencias; desacuerdos = `notas.desacuerdos_dofa`); caja 1 Aspiración (tabla Formulación / Tarjetas (n_frases) / Votos / Tema por letra; frases textuales sin nombre en lista; pistas `frase_para_guardar` y `meta_de_tamano`); caja 2 Dónde ganar (tres tablas Opción / Votos con ganadora en negrita y "Empate" cuando aplique; línea "Empates o desacuerdos" generada automáticamente a partir de `empatadas` más `notas.que_dejamos_de_hacer`); caja 3 Capacidades (# / Capacidad / Votos / Tenemos ☑ / Construimos ☑; pistas `tecnologia_que_notaria` y `capacidad_unica`); caja 4 Renuncias (# / Renuncia / Sí / No / % Sí / Evidencia; aprobadas marcadas; pista `renuncia_mas_costosa`); caja 5 Tensiones y clima (`tension_principal`, `divergencia_gerencia_junta`, `pregunta_abierta`). Sin nombres ni iniciales en ninguna parte. Nombre del archivo: `hoja_captura_pe_{sesion}_v{version}.pdf`.

### 6.4 JSON (`pe.export.json`)

Descarga `resultados_pe_{sesion}_v{version}.json` con `datosPublicos()` (sin `por_participante`), `Content-Disposition: attachment`.

---

## 7. IA: skill de agrupación (`resources/ai/skills/pe_agrupacion.md`)

Cliente: `App\Services\ClaudeService::askJson($prompt, $system, $jsonSchema, maxTokens 8000, timeout 120)`. Carga de la skill con `App\Services\Dofa\DofaSkill::cargar('pe_agrupacion')` (el cargador es genérico; no se modifica). Registro en `ai_messages` con `module = 'pe'`. Servicio `App\Services\Pe\PeAgrupadorIA::proponer(PeSession $sesion, ?string $instrucciones, User $por): PeGrouping`.

Encabezado obligatorio del archivo (para que `DofaSkill::version` lo lea):

```
# Skill: agrupación de aspiraciones "FEDEF gana cuando…"

Versión: 1.0
Fecha: 6 de septiembre de 2026
Uso: Retiro 1 de FEDEF, bloque 4, dinámica 1 (aspiración), fase de agrupación
```

Contenido que la skill debe tener, redactado completo en español (es el prompt de sistema; el prompt de usuario lleva las frases):

1. **Rol.** Consultor senior en planeación estratégica de entidades solidarias colombianas (fondos de empleados), facilitador del retiro de FEDEF. Tarea única: agrupar las frases de aspiración de los participantes en 2 a 4 formulaciones para que el grupo vote. No propone estrategias, no juzga, no completa lo que no está.
2. **Entrada.** Lista de frases con `id` numérico y `texto` tal como se escribió (pueden traer cifras, errores de tecleo y no empezar por "FEDEF gana cuando"); opcionalmente instrucciones del facilitador (priman sobre las reglas de estilo, nunca sobre las prohibiciones) y el contexto corto de FEDEF (fondo de empleados de la Sabana de Occidente; 7.071 asociados activos; 49,3 % con crédito; misión "mejor aliado de asociados responsables") sin nombres de personas.
3. **Salida.** Un único objeto JSON, sin texto adicional, con este esquema exacto (es también el `$jsonSchema` que pasa `PeAgrupadorIA`):

```json
{
  "grupos": [
    {
      "titulo": "FEDEF gana cuando la mitad de los asociados activos tiene un crédito vigente y se queda más de tres años",
      "tema": "asociado",
      "aspiracion_ids": [11, 14, 19],
      "motivo": "Las tres frases hablan de profundizar la relación con la base actual; una trae la cifra del 49,3 % que se conserva."
    }
  ],
  "sin_grupo": [
    { "aspiracion_id": 21, "motivo": "Es una meta de tamaño sin efecto visible para el asociado; conviene leerla aparte." }
  ],
  "notas_para_el_facilitador": [
    "Dos frases mencionan cifras distintas para la misma idea (60 % y la mitad): el título usa la más conservadora."
  ]
}
```

   Esquema JSON (draft 2020-12) a pasar a `askJson`: objeto con `grupos` (array, minItems 2, maxItems 4, items: objeto con `titulo` string, `tema` enum ["asociado","sector","interno"], `aspiracion_ids` array de integer, `motivo` string; `required` los cuatro), `sin_grupo` (array de objetos `{aspiracion_id: integer, motivo: string}`), `notas_para_el_facilitador` (array de string); `required: ["grupos","sin_grupo","notas_para_el_facilitador"]`; `additionalProperties: false` en todos los niveles.
4. **Reglas de agrupación.** Entre 2 y 4 grupos (nunca 1, nunca 5). Cada frase va a UN solo grupo o a `sin_grupo`; ningún id repetido ni inventado; todos los ids de entrada deben aparecer en `grupos` o en `sin_grupo`. Agrupar por lo que cambia para el asociado, no por palabras parecidas. Si hay menos de 4 frases, devolver 2 grupos aunque uno tenga una sola frase. Un grupo con una frase es válido si es claramente distinto.
5. **Título.** Cada `titulo` es una formulación completa que empieza literalmente por "FEDEF gana cuando" y sigue con un resultado visible para alguien (asociado, sector, equipo) en 2029; máximo 30 palabras; conserva la cifra si una o más frases la traen (usar la más conservadora cuando difieren, y anotarlo); sin verbos en infinitivo como inicio; sin adjetivos vacíos ("excelente", "mejor"); sin nombres de personas ni cargos; sin siglas distintas de FEDEF y FNA.
6. **Tema.** `asociado` si el resultado lo nota el asociado (retención, crédito, ahorro, bienestar, cercanía); `sector` si es posición frente a otros fondos, bancos o el nivel 1 (ranking, tamaño relativo, reputación); `interno` si es capacidad, equipo, tecnología, gobierno o sostenibilidad financiera. Cuando dude, `asociado`.
7. **Sin grupo.** Frases vacías de contenido ("que nos vaya bien"), metas de tamaño puras sin efecto para el asociado, o frases que hablan de otra cosa. Siempre con motivo.
8. **Notas.** Para el facilitador: cifras en conflicto, frases que sugieren una tensión, una meta de tamaño que conviene preguntar "¿y eso qué cambiaría para el asociado?".
9. **Prohibido.** Proponer estrategias, apuestas, capacidades o renuncias; calificar frases; citar nombres; devolver texto fuera del JSON; inventar frases o ids.

`PeAgrupadorIA` valida la respuesta: ids existentes y sin repetir, 2 a 4 grupos, títulos no vacíos; si falla, lanza `DofaException::negocio` con el motivo y guarda la fila con estado `descartada`. Guarda `prompt`, `respuesta_cruda`, tokens y `skill_version`.

---

## 8. Reparto de archivos por equipo

| Equipo | Archivos que crea o edita (y ningún otro) |
|---|---|
| **Contrato** (este agente) | `docs/ESPECIFICACION_PE.md`, `docs/CONTRATO_RUTAS_PE.md` |
| **Base PE** | `database/migrations/2026_09_06_100000_create_pe_tables.php`, `app/Models/Pe*.php`, `resources/data/fedef/pe_semillas.json`, `app/Services/Pe/PeCalculadora.php` (pura), `app/Policies/PeSessionPolicy.php` (+ registro en `AuthServiceProvider` o `Gate::policy` en `AppServiceProvider`, según dónde esté `Dofa2SessionPolicy`), relaciones nuevas en `User` y `Project`, `routes/pe.php` registrado en `bootstrap/app.php` (`then:`, bajo `['web','auth','acceso.vigente']`) y vacío, `tests/Feature/Pe/PeEsquemaTest.php`, `tests/Feature/Pe/PeCalculadoraTest.php` |
| **Backend PE** | `routes/pe.php` (rutas), `app/Http/Controllers/Pe/*` (`PeController` base, `PanelController`, `AgruparController`, `CuraduriaController`, `ParticipacionController`, `ResultadosController`), `app/Services/Pe/*` (salvo `PeCalculadora`), `resources/ai/skills/pe_agrupacion.md`, `resources/views/pdf/pe_hoja_captura.blade.php`, `tests/Feature/Pe/PeFlujoTest.php` |
| **Frontend PE** | `resources/js/Pages/Pe/*.vue`, `resources/js/Components/Pe/*` (incluido `utilidades.js` y `participante.css`/`facilitador.css` propios si hacen falta; puede importar los de `Components/Dofa`), `resources/js/Layouts/AppLayout.vue` (solo la entrada "Pensamiento") |
| **Acceso e integración** | `app/Http/Controllers/Acceso/ParticiparController.php` (resolución de módulo y props de `Pe/Participar`), `app/Http/Middleware/RedirigirParticipanteAlRetiro.php` (`'pe/*'`), `docker/ensayo_pe.php` (ensayo HTTP completo como `ensayo_retiro.php`), `npm run build` final, `php artisan migrate`, pruebas de humo |

Nadie edita `routes/web.php`, `routes/dofa.php`, `routes/acceso.php` (salvo que Acceso necesite nada: `/participar` ya existe), ni archivos del módulo DOFA fuera de los listados.

---

## 9. Decisiones tomadas por el agente de contrato

1. Marcado automático al avanzar: "al menos un registro" en vez de "dinámica completa" (3.3), porque en PE una entrada parcial sigue siendo un voto que el facilitador quiere leer.
2. El monitor y el panel muestran en vivo (`tablero` del sondeo) TODOS los votos guardados, con marca de envío o sin ella, rotulados "en vivo"; el cálculo de resultados solo cuenta a los marcados. Como al avanzar se marca a todo el que tocó algo, la cifra final coincide con la última del monitor salvo por quien votó y luego borró su voto.
3. Se reutilizan `DofaException`, `DofaSkill`, `usePolling`, `ParticipanteLayout`, `AppLayout`, el kit UI y los permisos `participar_dofa` / `facilitar_dofa`. No hay permisos nuevos ni columnas nuevas en `users`.
4. Se añade la columna `notas` json en `pe_sesiones` para no perder las pistas y la caja 5 de la hoja de captura (2.3), y `pe_agrupaciones_ia` para la trazabilidad de la IA, como `dofa2_consolidaciones`.
5. `tema` de la formulación usa los valores de la hoja de captura: `asociado`, `sector`, `interno`.
6. Las capacidades y renuncias propuestas por participantes son visibles para el facilitador desde que se guardan (no esperan al Enviar), para que la curaduría se haga en paralelo con la escritura. En el celular solo se ven las propias durante "proponer" y todas las visibles durante "votar".
7. Fusionar conserva la fila absorbida con `visible = false` y `fusionada_en_id`, y mueve los votos; nunca se borran votos al fusionar.
8. `datos_version` (4.9) sustituye a `factores_version` en el sondeo cuando el módulo es PE, para que el celular recargue cuando el facilitador cambia opciones, capacidades, renuncias o grupos en vivo.
9. La sección 0 de la hoja se llena solo si hay `Dofa2Result`; si la sesión DOFA 32 sigue en `cruces` el domingo, el panel avisa "La sesión DOFA enlazada no tiene resultados calculados; la sección 0 saldrá vacía" y el facilitador puede calcular desde el panel DOFA sin cerrar la sesión.
10. El PDF es una sola plantilla blade (sin SVG de la MIME: se imprime celda y zona en texto), para no depender de nada nuevo.

---

## 10. Criterios de aceptación (verificables)

Comandos: `docker compose -f docker/compose.yaml exec -T app php artisan migrate`, `... php artisan test --filter Pe`, `... php docker/ensayo_pe.php --participantes=3`, `... php docker/smoke_get_routes.php consultor@fycls.com 1 1`.

**Base**
1. `php artisan migrate` crea las 13 tablas `pe_*` sin tocar ninguna otra; `php artisan migrate:status` no muestra migraciones anteriores modificadas; `SHOW CREATE TABLE dofa2_sesiones` es idéntico al de antes.
2. `PeEsquemaTest`: crea sesión con `PeSesionService::crear` y comprueba 11 opciones, 4 capacidades base y 9 renuncias base con los textos exactos de 2.4; comprueba únicas (segunda aspiración del mismo usuario en la misma sesión falla), cascadas (borrar sesión borra todo) y `nullOnDelete` en `users`.
3. `PeCalculadoraTest`: con votos de fixture verifica `pct` a 1 decimal, `ganador_id` null con empate exacto, `empatadas` con margen 1, `aprobada` con 70 % (7 de 10 sí = aprobada; 6 de 9 = 66,7 no aprobada), `top` de capacidades y `empatadas_en_corte`.

**Backend**
4. `php artisan route:list --path=pe` lista exactamente las rutas del contrato con sus nombres.
5. `PeFlujoTest` recorre las 10 fases con 3 participantes: escribe, agrupa (2 grupos), vota, dónde, capacidades, renuncias; cierra; verifica que `pe_resultados.datos` tiene todas las claves de 6.2; verifica 409 al escribir fuera de fase, 409 `ya_enviado` tras enviar, 422 con 3 votos de aspiración, 403 al editar la capacidad de otro, marcado automático al avanzar, limpieza de marcas al retroceder, fusión que mueve votos, `datosPublicos()` sin `por_participante`.
6. `pe.sesiones.estado?monitor=1` no contiene la clave `nombre` ni `cargo` en ningún participante; sin el parámetro sí.
7. Exportar PDF devuelve `application/pdf` con al menos 2 páginas y sin ninguna cadena igual al `name` de un usuario participante; `pe.export.json` devuelve `attachment` y JSON válido sin `por_participante`.
8. `POST /agrupar/ia` con la llave sin configurar devuelve 400 `{ok:false, error:'La llave de la IA no está configurada…'}` sin lanzar excepción no controlada; con llave (ensayo `--ia`) guarda una fila en `pe_agrupaciones_ia` y la propuesta cumple el esquema.

**Acceso**
9. Con sesión DOFA 32 en `cruces` o `cerrada` y sesión PE en `aspiracion_escribir`, un participante que pide `/participar` recibe `Pe/Participar`; `/participar/estado` responde `modulo: 'pe'`; con la PE en `configuracion` sigue recibiendo `Dofa/Participar` y `modulo: 'dofa'`. Un GET de `/pe/sesiones/1/aspiracion` por un participante no se redirige a `/participar` (PERMITIDAS).
10. La inscripción automática crea la fila `pe_participantes` al primer `/participar` y no la crea para el facilitador ni con la sesión cerrada.

**Frontend**
11. En 360 px de ancho todas las pantallas del participante se usan sin desplazamiento horizontal; botones de ≥ 44 px; contador de caracteres visible; botón "Enviar" con diálogo del kit; `IndicadorGuardado` tras cada toque.
12. El panel muestra la línea de 10 fases, el botón grande con los rótulos de la sección 3, "Volver a …", la lista de sesiones con "Nueva sesión", participantes con estado por dinámica (sondeo 5 s), accesos Monitor / Resultados / Agrupar (Agrupar visible desde `aspiracion_agrupar` en adelante), la curaduría de opciones/capacidades/renuncias según la fase, y los avisos de víspera (IA, varias sesiones abiertas, DOFA sin resultado, assets sin compilar).
13. El monitor no muestra nombres en ninguna fase; en `aspiracion_escribir` muestra `n/16` y tarjetas; en votaciones, barras que cambian en ≤ 5 s tras un voto; en `renuncias_votar`, línea del 70 %.
14. `Pe/Resultados` muestra las cinco cajas de la hoja, el formulario de `notas`, "Recalcular", "Exportar PDF" y "Exportar JSON".
15. `Cierre.vue` muestra la formulación ganadora (o las empatadas), los tres "dónde", las tres capacidades y las renuncias aprobadas, sin nombres.

**Integración**
16. `docker/ensayo_pe.php --participantes=15` termina en "SIN FALLOS" en menos de 3 minutos contra la aplicación en marcha y borra lo que creó.
17. `npm run build` compilado una sola vez al final por el integrador; `smoke_get_routes.php` devuelve 200 en `projects/1/pe`.
