Skip to content

El asistente (chat)

El asistente de Futuros no es un chatbot que "sabe cosas". Es una capa de razonamiento sobre el corpus verificado: toda cifra que enuncia viene de un resultado de herramienta, ligada a su cita, nunca de la memoria del modelo. Esa disciplina es lo que lo distingue de un asistente genérico — y lo que lo hace utilizable por un regulador o un inversor. Está disponible en toda la plataforma (el lanzador vive en el layout raíz, en cualquier página); superficies como /sala le pasan preguntas con contexto vía sus tarjetas de "profundizar en el chat". Encarna el Pilar 2 — Modelo Soberano: el foso no son los pesos base, es el corpus con procedencia y la disciplina de anclaje.

El patrón de razonamiento

El asistente no dispara herramientas contra la primera lectura de la pregunta. El orden es explícito: pensar la pregunta → mirar el corpus → investigar en línea si hace falta → preguntar si la pregunta es ambigua → razonar → responder. La frase que lo motivó, en la llamada que originó este diseño, fue "tiene que parar un minuto y razonar": el fallo que corrige no es responder mal, es responder rápido con la primera cifra que la recuperación devolvió.

Ese orden es también el que hace que las secciones siguientes tengan sentido: la pregunta de especificación llega antes de gastar el presupuesto de herramientas, y la relevancia estricta se aplica al elegir qué cifra del corpus merece entrar en la respuesta, no al maquetarla.

Profundidad de la respuesta: completa en el primer turno

Hay tres niveles, y el que se sirve por defecto es el completo:

modeQué esRondas de herramientasTokens de salida
fullEl modo por defecto. La respuesta completa en el primer turno: veredicto, indicadores, marco regulatorio, lectura de financiamiento, precedente, quién mueve el indicador y el cierre de mejora. Antes era lo que el botón Expand compraba.68.192
briefEl brief ejecutivo corto. Sigue disponible en la API si se pide mode:"brief"; la interfaz de producto ya no lo sirve ni ofrece un control para ampliar.64.096
briefingDeep Briefing: entregable largo listo para directorio, con siete secciones fijas. Sigue disponible en la API con mode:"briefing"; la interfaz de producto ya no ofrece el botón Briefing — ese hueco del compositor lo ocupa el selector de modelo.916.384

full es el modo por defecto. expand y el valor retirado normal se aceptan como alias de full. La interfaz no ofrece un botón Expand / Ampliar ni un botón Briefing: la primera respuesta ya es la versión completa, en los tres idiomas del producto (ES / EN / PT). Junto a Enviar vive el selector de modelo (OpenRouter). Un brief explícito y sustantivo todavía puede llevar expandable: true en el cable, para que un cliente de API reenvíe el mismo turno con mode:"full". Un brief que se cortó a mitad no se ofrece como expandible: primero se declara truncado.

Lo que cambia entre brief y full no son las reglas de anclaje, que son idénticas en los tres modos. Lo que cambia es cuántos ingredientes se exigen. El verificador de completitud (server/chat/completeness.ts) aplica ENFORCED_BY_MODE: un brief sólo exige el cierre de mejora, mientras full y briefing exigen además el marco regulatorio y la lectura de financiamiento. La calificación no cambia con el modo — el informe sigue reportando el estado real de cada ingrediente, para que la telemetría siga siendo comparable entre modos —; lo único que se estrecha es la lista de lo que se puede exigir. Un brief es más corto, nunca menos citado: la regla interna es que un brief sin números es un fallo, y el cierre de "cómo mejorar" no se elimina nunca, porque es el punto. Ser más exhaustivo no autoriza inventar tasas, tenores, tickets ni conteos.

Cuando el brief sirve una decisión de financiamiento

El brief decía explícitamente que dejara fuera la lectura de financiamiento. Para un ministro que repasa un eje, eso es correcto. Para un oficial de inversión de BID o CAF, el dinero es la pregunta, y quitarlo dejaba una respuesta de 1.357 caracteres con cero valores, cero años y cero fuentes frente a un prompt que pedía exactamente esas tres cosas.

detectDfiIntent (server/chat/dfi-intent.ts) resuelve eso de forma determinista: sin llamada al modelo, sin latencia, misma pregunta y mismo veredicto siempre. Dispara con una sola señal —una contraparte de banca de desarrollo (BID/IDB/CAF), capex u opex, ticket, tenor, bancabilidad, comité de inversión, cierre financiero, o un monto en moneda— y devuelve cuáles señales coincidieron, para que un fallo sea diagnosticable en vez de opaco. Los acrónimos se comparan con mayúsculas y minúsculas distinguidas a propósito: un \bbid\b insensible al caso dispararía con el verbo inglés bid y con bidding en cualquier pregunta de compras públicas.

En el modo por defecto (full) la lectura de financiamiento ya es un ingrediente exigido, así que una pregunta DFI la recibe en el primer turno. Cuando detectDfiIntent dispara, y sólo en brief (full y briefing ya exigen la lectura de financiamiento), se añade un bloque que invierte exactamente una línea: la lectura de dinero se queda. Todo lo demás de la lista de exclusiones sigue fuera. El objetivo sube a 180-260 palabras, y el bloque insiste en que un rango de ticket es lo que el emisor puede prestar, nunca lo que se comprometió a este piloto. No cuesta una ronda extra de recuperación: recommend_action ya devuelve capex, opex, instrumentos ajustados por ticket y los tomadores de decisión rankeados.

Hasta tres preguntas de especificación

Ante una pregunta genuinamente ambigua o compleja, el asistente puede devolver hasta tres preguntas antes de responder (MAX_QUESTIONS = 3, con un máximo de 4 opciones cada una). Nunca en una consulta simple: preguntar de vuelta ante un dato directo es fricción, no rigor.

No son sólo preguntas aclaratorias sino de especificación: sirven para que quien pregunta acote qué busca. Por eso una pregunta puede legítimamente no llevar opciones — es texto libre. Cuando ninguna de las preguntas lleva opciones, el bloque futuros:clarify se sustituye por prosa, de modo que un cliente antiguo muestre las preguntas como texto en vez de una burbuja vacía. El campo questions lleva siempre la lista ordenada completa; question/options reflejan la primera pregunta con opciones, para que un cliente que sólo entiende la forma antigua siga dibujando sus chips.

Reglas de anclaje

Las reglas son estrictas y explícitas:

  • Cada figura sale de un resultado de herramienta, marcada con un token [[cite:id]]. Sin herramienta, no hay número.
  • Sin respuestas de memoria. El modelo no rellena cifras de su entrenamiento; si no las obtuvo de una herramienta, dice que no las tiene.
  • Las contradicciones se muestran. Si dos fuentes discrepan, el asistente lo señala en vez de promediar en silencio.
  • El vintage se marca. Una cifra vieja se presenta con su año, no como si fuera actual.
  • Respuesta primero (BLUF). La primera frase lleva el veredicto y la cifra citada más importante — no un preámbulo.
  • Pregunta antes de adivinar. Ante una pregunta genuinamente ambigua en alcance, el asistente emite un bloque futuros:clarify y se detiene, en vez de suponer en silencio (hasta tres preguntas — ver arriba).
  • Relevancia estricta. Una cifra del corpus sólo entra si responde a esta pregunta. El caso que fijó la regla: ante una consulta de soberanía de cómputo en México, "desarrolladores activos de GitHub" es la cifra más grande de la celda de innovación y no es un proxy de nada — lo relevante era la red eléctrica. La evidencia se ordena por pertinencia a la pregunta, no por tamaño ni por orden de la celda.
  • Las personas se citan. Cuando la respuesta toca quién decide algo, el corpus de personas clave se cita como cualquier otra fuente, con su persona-…-src-….
  • Responde en el idioma de la pregunta — cualquiera. No solo ES/EN/PT: neerlandés para Surinam, francés o kreyòl para Haití, inglés caribeño.
  • No inventa cobertura. El producto vivo es 25 países + la fila LATAM, 10 ejes, 260 celdas citadas. GDB (Oxford Insights) y GIRAI no están ingeridos; ILIA no es un índice vivo del corpus. Nunca se afirma ~9.000 fuentes. INE.Stat de Chile está vivo sólo para desempleo e informalidad. Una respuesta exhaustiva nombra el hueco cuando la fuente no está en el catálogo.

Cobertura viva frente a lo prometido

Ser exhaustivo no autoriza ampliar el producto. Si una sala pregunta por un índice o una oficina que el corpus no ingerió, la primera frase lo dice, y después se entrega el dato citado más cercano, etiquetado como adyacente. Rutas vivas para las preguntas recurrentes:

  • CEPAL frente a INEC / INDEC / DANE: las dos series de la celda, citadas, sin promediar; get_discoveries / get_regional_pulse para contradicciones documentadas (lista curada, no exhaustiva).
  • Desnutrición Ecuador: cepal_desnutricion en salud y cohesión; vintage citado.
  • Dueños de ES/investigación en Ecuador: personas + regulación + pilotos (CEDIA; SENESCYT absorbida en el viceministerio, Decreto 100 / 2025). INEC es la oficina de estadísticas, no el dueño de ES.
  • Preparación en IA frente a ILIA/GDB, y quién tiene estrategia: ai-governance / national_ai_strategy.status. GDB, GIRAI e ILIA no se citan como dato Futuros.
  • Energía del corpus de entrenamiento (Ember/IRENA): series Ember e IRENA vivas en medio ambiente + frontier-country.
  • Cobertura ODS y huecos de espacio cívico: get_data_health (coverage_gaps); civic-pulse (GDELT, señal direccional); civic-attention (21/25); resiliencia democrática; V-Dem en las celdas. El espacio cívico comparable de los 25 países, cuando está ingerido, se cita como civicus_monitor_score (CIVICUS Monitor, ficha de país) — no se inventa un segundo índice.
  • Fiscal Argentina / BOOST: boost_spending_* en las celdas ARG (vintage 2022, envejecido) + markets-screener.
  • Chile INE.Stat salud/innovación: no ingerido; la celda de salud o innovación se cita por sus fuentes reales (OMS, Banco Mundial, CEPAL).

La suite de herramientas

El asistente actúa a través de herramientas deterministas, con un presupuesto de 6 rondas de herramientas por respuesta (9 en modo briefing). En lenguaje llano:

HerramientaQué hace
resolve_metricTraduce lo que el usuario pide a un indicador concreto del corpus.
get_parameter_dataRecupera el valor de un indicador por país, con su cita.
search_corpus / get_datasetBúsqueda semántica sobre el corpus y acceso directo a un dataset catalogado.
compare_countriesCompara países sobre un indicador.
get_news / get_regional_pulseNoticias citadas y pulso regional.
computeCálculo determinista. Incluye pronósticos (marcados como proyección, no como hecho) y explain_change (que declara asociación, no causalidad).
get_data_healthEstado de salud y frescura del dato.
get_discoveriesHallazgos horneados (movimientos, anomalías).
find_pilots / get_pilotPilotos bancables por criterio y ficha de piloto.
recommend_actionEncadena piloto → instrumento de financiamiento → tomador de decisión: del dato a la acción. Incluye policy_screen (Bhutan/NZ/Humphrey; rúbrica en docs/plataforma/cribado-politicas.md; anulación humana para exportar).
web_search / fetch_url / perplexity_searchBúsqueda externa, último recurso y visiblemente badgeada.

La clave de diseño: agregar un dataset (server/chat/datasets.ts) lo vuelve automáticamente catalogado, recuperable y buscable — sin escribir código de herramienta nuevo. La recuperación combina embeddings Voyage (voyage-3-lite, 512-dim) sobre search-index.json con un respaldo BM25/keyword y reranking.

Modo briefing. Con mode: "briefing" el asistente produce un entregable largo listo para directorio, con siete secciones fijas (resumen ejecutivo, situación actual, tendencia, comparación con pares, calidad y vigencia de los datos, contradicciones y riesgos, "qué seguir") y el presupuesto mayor de la tabla de arriba. Las mismas reglas de anclaje aplican dentro del briefing. La interfaz de producto ya no expone un botón Briefing (ese hueco es el selector de modelo); el modo sigue disponible para clientes de API.

Componentes que la respuesta puede emitir

Además de la prosa citada, una respuesta puede incluir bloques que la interfaz renderiza como componentes. Todos comparten la misma regla: sólo muestran valores que una herramienta devolvió, y cada punto conserva su cita.

BloqueQué renderiza
futuros:vizGráfico o matriz (5 tipos). Máximo uno por respuesta normal; un briefing puede usar uno por sección. En un brief se prefiere ninguno.
futuros:mapMapa de puntos por país. Es la superficie donde la competencia nos ganaba, y la que hace legible un piloto multi-país: cada dato mapeado lleva su ISO3, su valor y su cita, y los chips de cita se dibujan bajo el mapa.
futuros:pilotsPilotos alineados al problema real de quien pregunta (ver relevancia estricta).
futuros:personasPersonas del corpus citadas como contraparte de decisión.
futuros:newsNoticias y pulso social, marcados explícitamente como "últimas noticias, no datos 100% verificados" — nunca con la cita azul de un indicador.
futuros:lawsEl snapshot de ley y artículos (ver abajo).

Corpus verificado frente a fuente externa: azul y dorado

La distinción de procedencia es visual y tiene leyenda:

  • Azul — dentro del corpus Futuros: verificado, con procedencia y deep link.
  • Dorado — fuera del corpus: una fuente externa que una búsqueda web devolvió en ese turno. No está verificada por nosotros y se dice así.

El dorado se marca además con el glifo junto a la cita, y la paleta tiene tres tonos separados (--gold para texto, --gold-line para el trazo, --gold-soft para el fondo) precisamente porque en superficie clara el ámbar de advertencia y el dorado de procedencia son vecinos y no deben confundirse. El lenguaje se suaviza ("dato no verificado") pero la distinción no se elimina nunca: es la que sostiene la promesa de procedencia.

Regulación: la ley y sus artículos, no "51 instrumentos"

Ante una pregunta regulatoria, la respuesta nombra la ley y los artículos que regulan el tema y ofrece un snapshot emergente del fragmento relevante, en vez de reportar un agregado ("51 instrumentos") y mandar al lector a un portal oficial en crudo. El bloque futuros:laws se construye a partir del registro de cita del propio instrumento — un id de cita regulatoria es el id del instrumento y empieza por el ISO3 del país en mayúsculas, lo que hace la partición exacta y no heurística.

Los datasets regulation-instruments y regulation-provisions cuentan como evidencia regulatoria pero no como dato de núcleo: son búsquedas puntuales, así que una respuesta puramente legal nunca se gradúa como "sustantiva" sólo por haberlas consultado.

Cuando el modelo nombra una ley en prosa o en una tabla pero omite el token [[cite:id]], el renderizador subraya el nombre si coincide con un alias de un instrumento ya presente en la carga de citas de ese turno — acrónimos (LGPD), referencias oficiales (Ley N° 26.743), títulos completos. Nunca inventa enlaces a instrumentos que la respuesta no haya anclado: si la ley no está en el payload de citas, el nombre queda como texto plano. La regla 9 del prompt del sistema exige [[cite:id]] en cada ley nombrada; este fallback es honestidad de interfaz para el caso en que el modelo cumple el grounding pero olvida el token inline.

Ticket automático de hueco de datos

Cuando una herramienta del corpus vuelve vacía, el servidor abre un ticket estructurado en vez de dejar que la señal muera con la petición, y lo publica en /vacios vía GET /api/backlog. Es el mecanismo que convierte un unknown unknown en un known unknown, ordenado por demanda real.

Dos precisiones que importan:

  • Un fallo de red o un error de herramienta nunca abre un ticket. La señal es returnedEmpty, que sólo registra vacíos reales del corpus; una caída no se confunde con una laguna. search_corpus tampoco cuenta: una formulación pobre se ve igual que un hueco.
  • El ticket no guarda la pregunta. Lleva el país, el eje y las herramientas que volvieron vacías — todo vocabulario de la plataforma. (El esquema admite además missing_indicators, pero hoy el handler no lo pasa nunca, así que el campo siempre viene ausente: se documenta como lo que es, no como lo que promete.) El endpoint es público y sin autenticar, así que cualquier cosa que el ticket llevara sería algo que quien preguntó publicó sin habérselo propuesto. El titular de cada fila en /vacios se compone a partir del país y el eje. La regla completa vive en la cabecera de server/backlog/gaps.ts; el endpoint está documentado en la API pública.

El vacío llega al modelo, no sólo al backlog

Registrar el hueco no sirve de nada si la respuesta lo tapa. El vacío llegaba a tres destinos —la telemetría, el backlog público y el chip del cliente— y nunca al único consumidor que decide qué se escribe. En el cable, una consulta sin resultados eran 68 bytes ({"dataset":"financing","total_matches":0,"records":[]}): ninguna prosa, ninguna prohibición. Un array vacío se lee como «aquí no hay nada que añadir», no como «no tienes con qué escribir esto». Una pregunta de financiamiento registró financing → 0 registros dos veces, abrió su ticket, y la respuesta imprimió igualmente una tabla de seis filas con plazos, tasas y grados de concesionalidad.

Ahora cada resultado vacío viaja al modelo con empty: true y un corpus_empty_note explícito, en dos variantes, porque «vacío» significa dos cosas distintas:

  • Ausencia real. Una herramienta del corpus preguntada por una celda concreta que no existe. La nota prohíbe producir una tabla, una fila, una cifra, un nombre, una fecha, una tasa, un plazo, un grado o un monto para ese dataset, y pide decir en la primera frase qué falta y qué fuente primaria lo publicaría.
  • Ausencia no probada. search_corpus, resolve_metric y las herramientas web pueden volver vacías por la formulación. Ahí la nota dice justamente eso: reformula o reconoce que no lo encontraste, pero no inventes ni afirmes que el corpus carece del tema.

Las dos variantes comparten una sola definición de vacío (isEmptyResult) y una sola lista de qué herramientas cuentan como ausencia (NOT_A_GAP, ahora en server/chat/empty-note.ts, importada por gaps.ts). Que la nota y el ticket pudieran discrepar sobre el significado de «vacío» era el defecto de fondo.

Y una regla que corre en dirección contraria a la intuición: el modelo ya no puede afirmar que el hueco quedó registrado. No puede observarlo —el ticket se escribe después de que termina la respuesta, y el cliente muestra el enlace al backlog por su cuenta—, así que afirmarlo era una declaración falsa sobre el comportamiento de la propia plataforma. Una respuesta en producción aseguró que «Futuros registra automáticamente estos gaps en el backlog público» mientras la telemetría mostraba cero tickets. Describir el hueco, sí; prometer su registro, no.

Un cargo sobrevive a un cambio de gabinete; un nombre no

El activo diferencial del corpus es que sabe a quién llamar, y era justo donde más fallaba: en una evaluación con diez preguntas de banca de desarrollo aparecieron titulares equivocados en seis, siempre en un punto que cargaba peso. Un ministro que había dejado el cargo diez días antes seguía apareciendo con «readiness 90/100, sentiment favorable» — un número de confianza que convierte un registro con fecha en una afirmación sobre el presente.

Tres cambios, ninguno de los cuales requiere volver a hornear los 1.346 perfiles:

  • former se respeta. 47 entradas del índice marcan a un ex titular y ningún consumidor las leía. Al filtrarlas, 41 celdas país×eje dejan de ofrecer a alguien que ya no está (en 18 aparecía en primer lugar). El filtro es gratis: de 278 celdas con resultados, ninguna queda vacía ni baja de tres candidatos.
  • Cada fila lleva su fecha. No existe campo verified_as_of en el corpus, así que el sello se deriva de last_updated, presente en 1.346 de 1.346 perfiles y ya cargado por el ranking: verified_as_of, verified_days_ago y currency (recent ≤30 días, aging ≤90, unverified más allá). El perfil medio tiene ~66 días, razón por la cual recent no puede significar 90.
  • Los números de confianza se condicionan a la vigencia. Cuando currency no es recent, readiness y relationship_status salen en null con un readiness_withheld que dice desde cuándo no se re-verificó. La justificación está en los datos: readiness vale exactamente 55 en 1.281 de 1.346 filas porque es el valor por defecto del stub, no una medición.

El prompt cierra el círculo: cuando la fila no es recent, se nombra el cargo y la institución y sólo después, opcionalmente, el último titular registrado con su fecha. Los contactos incrustados en los pilotos reciben el mismo trato: sólo 155 de 336 traen persona_id, y 172 de los 297 nombres distintos no existen en el directorio, así que un nombre sólo se emite si su id resuelve; si no, sobrevive el cargo y desaparece la persona.

El corpus no tiene precio

El catálogo de financiamiento guarda emisor, tipo, rango de ticket, texto de elegibilidad y URL de solicitud. No guarda tasa, spread, plazo, gracia, grado de concesionalidad ni calificación crediticia — verificado campo por campo en los 64 instrumentos. Eran exactamente las cifras que una respuesta llegó a inventar: dinero soberano del BID a «1,5-2,5%», CAF descrita como «AAA», y un mecanismo por el cual un wrap de MIGA comprimía 40-60 puntos base de un spread fijado por directorio. Son las cifras con más probabilidad de acabar en un term sheet.

Dos defensas, una preventiva y otra de medición:

  • Regla de prompt. Nombra el instrumento y su emisor, da su rango de ticket con cita, apunta a application_url, y di con claridad que Futuros no lleva precios ni condiciones. concessional-loan es una etiqueta de catálogo, no un grado verificado de concesionalidad. Lo mismo para los financing[] de un piloto: son un reparto de diseño, nunca cofinanciamiento comprometido.
  • Detector determinista. detectFinancialTerms marca la coincidencia, en una misma unidad de sentido, de vocabulario crediticio y un token de condición (porcentaje, puntos base, benchmark + margen, duración, grado de rating). Es advertencia, no reescritura: baja el chip de confianza y viaja en el evento verify como fabricated_terms. Las tablas se leen combinando encabezado de columna + etiqueta de fila + celda, porque en una tabla de condiciones la palabra vive en el encabezado y el número en la celda. Lo que no marca importa igual: la tasa de descuento (0,07-0,14 en los 67 pilotos), el horizonte en años, la contrapartida, el desembolso, el TRL y todo el panel de asequibilidad son datos reales del corpus, y un falso positivo ahí desacreditaría una respuesta correcta.

Registro privado de preguntas

El ticket describe la forma del hueco, no la pregunta — y esa restricción, que es la correcta para una superficie pública, dejaba sin registrar la señal más útil para mejorar la recuperación: qué se pregunta de verdad. Por eso existe un segundo almacén, privado y separado por construcción:

  • Otra clave. chat:questions:v1, nunca backlog:gaps:v1. Nada que lea el backlog puede alcanzarlo, ni siquiera por accidente, y se purgan por separado.
  • La ruta pública no cambia. sanitizeTicket sigue reconstruyendo cada ticket campo por campo, así que una pregunta no puede salir por GET /api/backlog ni por /vacios. Esto añade un almacén; no retira ninguna garantía.
  • La lectura falla cerrada. GET /api/questions exige Authorization: Bearer $QUESTION_LOG_TOKEN y rechaza toda petición cuando el token no está configurado (la postura de api/alerts-digest.ts). Un despliegue que olvide ponerlo no sirve nada, en vez de publicar el registro.
  • El cliente sigue siendo sólo forma; el servidor lleva el texto. El evento chat_query de PostHog (y el de embudo chat_submit) lleva longitud, idioma, ruta, modo y un generation_id — nunca el texto. El mismo generation_id une esas mitades con el registro privado de preguntas y con los eventos de servidor chat_generation / $ai_generation, que sí incluyen la pregunta (y un recorte de la respuesta) para trazas LLM. Las palabras no viajan en el pipeline de cliente.

Cada fila guarda pregunta, fecha, idioma, país, eje, ruta, modo y generation_id. La escritura es best-effort y se dispara antes de abrir el stream: nunca bloquea ni retrasa la respuesta, y un fallo del almacén cuesta un dato, nunca un turno.

Registro privado de turnos (aprendizaje)

Para aprender del uso real del asistente hace falta más que la pregunta: la respuesta completa, el modelo, las herramientas, las citas y la verificación. Eso vive en un tercer almacén privado, chat:turns:v1 (server/backlog/turns.ts):

  • Misma postura de privacidad que el log de preguntas: otra clave Redis, lectura fail-closed en GET /api/chat-turns con el mismo QUESTION_LOG_TOKEN, Cache-Control: no-store.
  • Cada fila guarda: pregunta, respuesta (tope 48k caracteres), modelo/proveedor, traza de herramientas (nombre / detalle / found — sin el JSON crudo del resultado), ids de cita emitidos, resumen de verify, seguimientos, usage, y el status del turno (ok | error | declined | empty | aborted).
  • La escritura es best-effort en el finally del handler: cubre turnos que abortan, fallan o son rechazados, no solo los done limpios.
  • El log de preguntas se mantiene (señal temprana al abrir el stream); los turnos son el almacén de aprendizaje.

Filtros opcionales en la lectura: ?country=, ?pillar=, ?status=, ?limit=.

Anatomía de una respuesta (el protocolo SSE)

Una respuesta es un stream SSE de eventos nombrados (server/chat/handler.ts produce; src/lib/chat-client.ts parsea a mano el formato de cable, porque EventSource solo hace GET). El bucle: transmitir un turno del modelo → si pidió herramientas, ejecutarlas contra el corpus estático, anexar resultados, transmitir el siguiente turno → repetir hasta end_turn. El contexto volátil (país, parámetro, reply_language) se anexa al último turno de usuario, nunca al system prompt — así el prefijo cacheado (cache_control: ephemeral) queda byte-estable entre requests. La petición entrante se recorta antes de tocar el modelo: últimos 12 turnos, 2.000 caracteres por mensaje, el último turno debe ser de usuario.

Catálogo de eventos, con lo que la interfaz hace con cada uno (src/store/chatStore.ts):

EventoPayloadQué hace la interfaz
provider{id, sovereign, inRegion?, model}Fija la insignia de proveedor / modo soberano del turno. Lo primero en emitirse; se re-emite en cada failover.
text{d} — delta incrementalAnexa el delta. El primer token tras un borrado descarta la "sombra" y arranca el texto fresco.
reset_text{}Nunca borra a negro: mueve el texto ya emitido a una capa sombra atenuada — el usuario no ve respuesta → puntos → respuesta.
tool{name, detail, found?}Agrega un paso a la traza "mostrar el trabajo". found se deriva del resultado real, después de ejecutar.
citations{items}Registra los chips nuevos (dedupe por id) antes del siguiente turno de texto, para que [[cite:id]] resuelva mientras la prosa fluye.
revising{unverified_ids}Sombra + chip "verificando…" + marca el turno como auto-corregido; limpia verify/followups previos.
verify{claims, traceable, sources, unverified_ids, uncited_figures, contradictions, values_confirmed, values_checked}Alimenta la insignia de confianza. Solo se emite si hay algo que reportar.
followups{items}Muestra las 3–4 preguntas de seguimiento sugeridas.
error{code, message, reason?, status?}Se adjunta en línea sin borrar el parcial. reason (auth/credits/unavailable, derivado solo del status HTTP, sin secretos) decide si "reintentar" tiene sentido.
done{usage, truncated?}Cierra el turno. Piso final: si quedó vacío pero hay sombra, la restaura — un turno nunca termina en blanco si existió texto previo.

El cliente añade un centinela propio: un EOF del stream sin evento terminal done/error es un stream cortado — se marca la respuesta parcial como truncada, jamás como completa, y jamás se enruta a error (eso borraría el parcial).

Verificación de fidelidad

Antes de entregar una respuesta, el asistente se audita a sí mismo con una verificación determinista de anclaje — sin ninguna llamada extra al modelo (server/chat/faithfulness.ts):

  1. Extracción de afirmaciones — determinista, sobre los tokens [[cite:id]], incluyendo los puntos de datos de los bloques de visualización y las celdas de tablas.
  2. Verificación de anclaje — cada id citado debe ser un id que una herramienta realmente devolvió en esta conversación (verifyGrounding). El juez es el conjunto returnedIds — que incluye ids embebidos en los payloads aunque nunca se hayan emitido como chips — no el registro de citas: juzgar contra el registro dispararía correcciones sobre respuestas honestas. Un id que no vino de ninguna herramienta es una cita inventada, y se trata como tal.
  3. Confirmación de valores — no basta con que la cita resuelva: el número que el modelo escribió debe coincidir con el valor de la fuente (confirmValues), cosechado de los propios resultados de herramienta. La mecánica es conservadora: se toma la cifra más cercana a la cita (saltando años 1900–2099), se compara a la precisión con que fue escrita (75,9 → un decimal), y los saltos de escala ×/÷100 solo se aceptan si la cifra fue escrita como porcentaje o la fuente es una fracción (|fuente| < 1) — un comodín incondicional daría visto bueno a un error de dos órdenes de magnitud. La señal es positivo-solamente: una cifra no parseable no confirma ni acusa.
  4. Auto-corrección de una pasada — si la respuesta final cita ids no anclados, el handler emite un evento revising y gasta una ronda extra corrigiendo. Un salvamento garantiza que la respuesta nunca queda en blanco ni peor que el borrador: la revisión se descarta como degenerada si sale vacía, si es un acuse (la familia "Tienes razón…", o una apertura de disculpa combinada con un verbo de re-hacer — la disculpa sola nunca dispara, porque también abre abstenciones honestas), o si no achica el conjunto de ids no anclados (la señal semántica manda sobre la textual). En ese caso se restaura el borrador previo con los tokens de cita inválidos eliminados — exactamente la reescritura que se pidió. El salvamento corre después del bucle, no solo en un end_turn limpio: el reloj puede expirar antes o a mitad de la revisión, y aun así debe restaurarse el borrador completo.

También existe un juez LLM que clasifica afirmaciones como SUPPORTED / PARTIAL / UNSUPPORTED, pero es evaluación de fidelidad offline (scripts/chat-faithfulness.ts), no un paso del runtime — las dos cosas no se confunden.

El resultado viaja en un evento verify con conteos y listas de ids — afirmaciones totales, cuántas son rastreables, fuentes verificadas distintas, ids no verificados, figuras sin cita — más las contradicciones detectadas y la confirmación de valores. Solo se emite cuando hay algo que reportar. Es señal interna de runtime (corrección automática, telemetría); la interfaz del chat ya no muestra la insignia de confianza ni el panel de conteos — la procedencia vive en las cifras subrayadas del texto y en el pie de fuentes clicables.

También hay defensa contra inyección de prompt: quarantine.ts aísla el contenido no confiable traído por herramientas para que no reescriba las instrucciones del asistente.

Visualizaciones ancladas

El asistente puede emitir como máximo un bloque JSON futuros:viz por respuesta normal (un briefing puede usar uno por sección) — gráfico o matriz — que la interfaz renderiza de forma interactiva. Sus puntos de datos llevan ids de cita y pasan por la misma verificación de anclaje que la prosa: un gráfico que cita un id no devuelto por herramientas dispara la misma pasada de corrección.

Límites honestos

La respuesta corre bajo presupuestos explícitos: ~100 s de reloj de pared (dentro del maxDuration de 120 s de la función, con ~20 s reservados para verificación y seguimientos), 8.192 tokens de salida (16.384 en briefing), 12 turnos de historial y 2.000 caracteres por mensaje. El reloj es compartido entre rondas: cada turno del modelo corre con una señal de aborto al presupuesto restante (y a la desconexión del cliente — cerrar la pestaña aborta el turno en vuelo, las herramientas y los seguimientos, sin facturar tokens que nadie lee). Gobierna también las herramientas, no solo al modelo: las externas llevan timeouts fijos de 12–20 s en serie, así que con presupuesto agotado las restantes se degradan a un resultado is_error desde el que el modelo aterriza con lo que ya tiene — el protocolo exige un resultado por tool_use, y esto evita un kill silencioso de la plataforma sin done terminal. Si el presupuesto se agota, el corte se declara: el evento final lleva done.truncated, la interfaz marca la respuesta como "truncada" en vez de presentar un fragmento como completo, y un guardián (stripDanglingFence) elimina cualquier bloque de visualización a medio emitir: un número impar de marcadores de fence significa que el último bloque nunca cerró — se recorta desde ahí, para que el JSON trunco ni se renderice roto ni infle el conteo de figuras sin cita.

La cadena de proveedores y el modo soberano

server/chat/provider.ts construye una cadena de proveedores con failover transparente (providerChain()). La precedencia primaria sigue siendo soberano → OpenRouter → Anthropic — pero cada proveedor configurado se anexa además como respaldo: si el primario falla en transporte (timeout de conexión, 4xx de auth o créditos, 5xx tras los reintentos del propio SDK), la ronda se reintenta contra el siguiente sin consumir presupuesto de recuperación (round -= 1), re-resolviendo el id de modelo por espacio de nombres del proveedor (anthropic/claude-sonnet-4.6 en OpenRouter vs claude-sonnet-4-6 directo). El failover tiene una compuerta estricta: solo dispara si la ronda aún no emitió texto visible — con tokens ya en pantalla, un segundo proveedor re-transmitiendo duplicaría la respuesta, así que nunca se cambia de host a mitad de un texto. Los seguimientos también corren en el proveedor que realmente respondió — tras un failover el primario está muerto. El default es Sonnet para las respuestas; las preguntas de seguimiento corren en Haiku (claude-haiku-4-5), más barato.

Selector de modelo (OpenRouter)

El compositor del chat ofrece un selector de modelo junto al botón Enviar (persistido en localStorage por navegador) — en el lugar que antes ocupaba el botón Briefing. El cliente envía un campo model allowlisteado; el servidor lo valida en server/chat/models.ts y, si es válido, fuerza la cadena a OpenRouter con ese slug — esto anula el camino soberano para esa petición. Los seguimientos siguen en Haiku. Catálogo:

EtiquetaSlug OpenRouter
Kimi K3moonshotai/kimi-k3
DeepSeek V4 Prodeepseek/deepseek-v4-pro
Claude Sonnet 5 (default)anthropic/claude-sonnet-5
GPT-5.6 Terraopenai/gpt-5.6-terra
Claude Fable 5anthropic/claude-fable-5
GPT-5.6 Solopenai/gpt-5.6-sol

Sin model (o con un id no allowlisteado) se conserva la cadena habitual. Sin OPENROUTER_API_KEY, la selección se ignora.

La función usingSovereign() comprueba si están configuradas SOVEREIGN_INFERENCE_URL / _KEY; si lo están, la inferencia corre en un gateway en-región (LiteLLM/vLLM) sobre pesos abiertos, y el corpus y la consulta nunca salen del host. Esa es la costura del Pilar 2: la soberanía es un toggle de proveedor, no una reescritura, y la calidad de frontera sigue siendo el default hasta alcanzar paridad en las evaluaciones.

El proveedor activo se transmite como evento SSE provider en toda respuesta — lo primero que se emite, y se re-emite en cada failover, de modo que la insignia nombra al host que realmente respondió. La insignia de "modo soberano" en la interfaz aparece solo cuando la inferencia fue soberana: el usuario sabe, en la propia conversación, dónde vive la inferencia que está leyendo.

Búsqueda externa como último recurso

La web abierta solo se usa cuando el corpus no basta, y siempre badgeada como externa (citas web-). Un hecho de la web nunca se presenta al mismo nivel que un dato del corpus verificado — la separación es visible, como se explica en Procedencia y citas.

Y es opcional: con el alcance solo-corpus (corpusOnly) las herramientas externas (web_search, fetch_url, perplexity_search) se retiran por completo, y el modelo responde únicamente desde el corpus verificado — o declina.

El corpus de crisis Colombia: datos reales y datos de demostración en la misma respuesta

La suite «Colombia · Sala de Crisis» agrega nueve datasets al catálogo (server/chat/colombia-corpus.ts, registrados con una sola línea en datasets.ts). Plantea un problema que ningún otro dataset del corpus tenía: cifras oficiales y registros sintéticos conviviendo en el mismo dominio. Las cifras agregadas del balance que el Presidente leyó el 12 de agosto de 2026 son reales y citables; cada hogar, expediente de desaparecido, albergue, envío, movimiento del Fondo Milagro y obra de reconstrucción es sintético, generado para una demostración.

La separación es mecánica, no una recomendación en el prompt:

ClaseDatasetsQué devuelve la herramienta
Oficial y citablecolombia-alocucion, colombia-contexto, colombia-balance (solo el corte oficial), colombia-promesas, colombia-aliviosEl registro más su ColombiaCitation en línea.
Demostracióncolombia-zonas, colombia-albergues, colombia-fondo, colombia-obras, y los dos cortes reconstruidos de colombia-balanceEl registro marcado origen: "demostracion", sin cita de ningún tipo.

Un registro sintético no puede llegar con cita porque la proyección que lo produce no emite ninguna, y la línea de catálogo que el modelo lee antes de elegir herramienta lo dice explícitamente. server/chat/colombia-corpus.test.ts fija ambas propiedades, más la coincidencia entre las citas incrustadas en el módulo y citations-colombia.json en disco, y las seis pruebas vivas correspondientes están en scripts/chat-eval.ts.

Dos advertencias que el corpus transporta y el asistente debe repetir. La primera: la única grabación disponible de la alocución trae subtítulos traducidos automáticamente al inglés, así que las frases en español son reconstrucciones fieles y no transcripción certificada — cada sección conserva su text_en_auto original para que la diferencia sea verificable. Las cifras sí son citables como oficiales. La segunda: el Fondo Milagro fue anunciado y no existe jurídicamente, de modo que la pregunta "cuánto ha recaudado" no tiene respuesta oficial, y el libro mayor solo muestra cómo se vería su contabilidad pública.

Handoffs

El asistente no es un callejón sin salida. Puede pasar el control a superficies analíticas: a /explorar para consultar los datos con SQL en el navegador (DuckDB-WASM), y a un tablero de embed (/embed/board) para fijar y compartir un conjunto de hallazgos con su procedencia intacta. El mismo corpus se expone a agentes externos vía el servidor MCP — las herramientas de corpus del chat más dos exclusivas de MCP (get_series, resolve_citations).

El cierre también es acción, no solo dato: toda respuesta sustantiva termina con una palanca de "cómo mejorar" y un bloque futuros:pilots de 1–3 tarjetas de pilotos validados — con reglas explícitas para omitirlo (aclaraciones, negativas, sin datos, preguntas que ya son sobre pilotos). Y tras cada respuesta llegan 3–4 preguntas de seguimiento sugeridas, generadas best-effort por el modelo barato (Haiku) — incluso cuando la respuesta quedó truncada. El sobre completo de una respuesta: prosa citada (con su tabla, si compara) → visualización → enlace al workbench/tablero → pilotos → seguimientos.


En una frase: el asistente convierte un corpus con procedencia en respuestas que declaran su propia confianza, corrigen lo que no soportan, y dicen dónde corre su inferencia. La disciplina de honestidad que lo rige se detalla en Metodología y honestidad.

Cada cifra con su fuente — la trazabilidad es el contrato.