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:
mode | Qué es | Rondas de herramientas | Tokens de salida |
|---|---|---|---|
full | El 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. Con presupuesto de densidad para pantalla compartida: una página de gabinete (~4-5k caracteres), veredicto en 4 líneas o menos, UNA tabla comparativa cuando se comparan 3+ entidades, metodología y salvedades largas comprimidas a una línea al final; ante evidencia sin fuente se prefiere añadir la [[cite:id]] faltante a la celda antes que escribir más prosa. Cuando la respuesta es en inglés o el lector es un asignador, el presupuesto aprieta un punto más: veredicto en 3 líneas o menos y una sola línea de metodología al cierre. | 6 | 8.192 |
brief | El 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. | 6 | 4.096 |
briefing | Deep 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. | 9 | 16.384 |
full es el modo por defecto. expand y el valor retirado normal se aceptan como alias de full.
El país seleccionado en el atlas viaja con el visitante: quien elige BRA y luego abre el chat desde /, /governance/* o /mercados sigue preguntando por Brasil, salvo que la ruta o ?c= nombre otro país, o Sala pida toda la región (?c=LATAM). En el servidor, el sesgo de país se retira solo cuando el sujeto real es otro: si la pregunta (o el propio texto de búsqueda que el modelo generó) nombra otro país —por nombre, código ISO3 o gentilicio: «la banca peruana», «o agro brasileiro»— o si la pregunta es explícitamente regional («América Latina», «Latinoamérica», «LATAM», «Sudamérica»). Retirarse degrada a búsqueda sin filtro, nunca a un filtro equivocado: forzar BRA sobre una pregunta por Uruguay descartaría el registro correcto y la respuesta afirmaría una ausencia falsa. El compositor registra chat_composer_open al abrir (lanzador, atajo, sala, enlace compartido). Cada envío dispara chat_submit y chat_query con el mismo generation_id que el servidor usa en chat_generation. Un fallo visible dispara chat_fail con esa clave; Detener no cuenta como fallo. El modo que se registra por defecto es full, nunca el alias retirado normal. 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/WB/WBG/IFC/MIGA/GCF/FONPLATA, por acrónimo o por su nombre completo en es/en/pt: "Banco Interamericano de Desarrollo", "World Bank", "Fundo Verde para o Clima"…), capex u opex, ticket, tenor, bancabilidad, comité de inversión, cierre financiero, un monto en moneda, el vocabulario de contraparte en sus colocaciones de financiamiento ("contrapartida", "counterpart funding", "obligación de contraparte"; la "contraparte" diplomática a secas no dispara), un instrumento financiero nombrado (finanzas combinadas / blended finance, primera pérdida, bonos verdes o sostenibles, sustainability-linked / SLB, canje de deuda, financiamiento climático y sus desembolsos, apalancamiento, garantías soberanas o parciales, memo de inversión / IC memo; también MDB, DFI, BNDES y Eco Invest como contrapartes), o la persona del asignador ("allocator", "asignador", "alocador", un fund manager o gestor de fondos, un fondo climático, o la colocación de portafolio de inversión: "portafolio de infraestructura", "investment portfolio"; quien nombra su propio rol ya pide la lectura de dinero), y devuelve cuáles señales coincidieron, para que un fallo sea diagnosticable en vez de opaco. Tras la remedición post-#826 se sumaron las formas de dinero restantes que la crítica marcaba y el clasificador no veía, cada una con su propia señal: la posición de deuda soberana que un comité o un ministerio de finanzas dimensiona (deuda/PIB o debt-to-GDP, el ancla fiscal / âncora fiscal, la trayectoria de la deuda, el benchmarking de deuda, la carga de intereses), la cobertura cambiaria de portafolio ("coberturas cambiarias", FX hedging, hedge cambial), el hidrógeno verde solo cuando va emparejado con vocabulario de inversión/capex/financiamiento (el tema solo sigue siendo energía, no dinero), y PPA / offtaker como instrumento. Las preguntas de proceso que la crítica sobre-marca (datos abiertos, implementación previsional, migración, residencia de datos) siguen sin disparar a propósito: precisión antes que cobertura. Las colocaciones son deliberadas: "fondo verde nacional", "garantía de calidad", "portafolio de políticas" o un "leilão" de espectro no disparan. 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. Los nombres completos, en cambio, son inequívocos en cualquier caja y se comparan sin distinguirla; "Banco Central" o "un fondo verde nacional" no disparan.
El flag se calcula para los tres modos, y cada modo hace lo suyo:
- En
brief, el bloque añadido 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. - En
full(el modo por defecto) la lectura de financiamiento ya es un ingrediente exigido, pero un párrafo genérico de asequibilidad todavía dejaba al asignador sin lo esencial del comité. Cuando el clasificador dispara, la lectura de dinero toma una forma fija: la subsección con encabezado markdown exacto### Dinero / ticket / contraparte(en inglés### Money / ticket / counterpart, en portugués### Dinheiro / ticket / contraparte), inmediatamente después del veredicto y los indicadores, de 3 a 5 viñetas: rango de ticket e instrumento con su emisor sólo desde resultados de herramientas; lo que el corpus no tiene (tasa, tenor, calificación, concesionalidad) dicho en una viñeta propia; la contraparte como cargo más institución (el nombre sólo con registrorecent). La subsección es obligatoria cuando el clasificador dispara: si el corpus está vacío para todos los campos de dinero, la subsección aparece igual, con las viñetas de hueco honestas dentro ("Futuros no tiene ticket ni instrumento para esto todavía"); un corpus vacío cambia el contenido de las viñetas, nunca la presencia de la subsección, y nunca autoriza inventar un ticket, una tasa, un tenor ni un emisor. La subsección reemplaza la sección de dinero simple, no se suma a ella, así el presupuesto de densidad no crece. El prompt además exige intentar primero la sustancia: una tabla markdown (o lista de hasta tres filas) de instrumentos candidatos desde resultados de herramientas, emisor, tipo de instrumento, rango de ticket, colocada encima de la subsección; las viñetas de hueco solas son el respaldo cuando las herramientas no devolvieron nada, nunca la forma por defecto. Y desde la remedición post-#826 el encabezado además se aplica en el servidor, no solo en el prompt (server/chat/dfi-money-header.ts), con una aplicación que desde la remedición post-#828 es sensible a la sustancia: si la respuesta ya trae filas de instrumentos (una fila de tabla o viñeta que nombra un instrumento con un monto), la subsección inyectada apunta a esas filas y hace eco solo de los emisores ya escritos arriba, en vez de negar lo que la respuesta ya mostró; si no las trae, las viñetas de hueco tienen forma de asignador, cada una nombra el paso siguiente (qué documento del emisor la responde) y la subsección cierra con la pregunta de primera pérdida (quién toma el tramo junior). Si el modelo escribió el encabezado exacto pero dejó debajo solo texto de relleno (sin cifra, sin cita, sin emisor, sin lectura de primera pérdida), ese cuerpo delgado se mejora en el lugar; un cuerpo sustantivo nunca se toca, una variante cercana nunca se duplica, y todo lo inyectado sigue sin una sola cifra y sin inventar ticket, tasa, tenor ni emisor. Dos precisiones que la crítica offline del 2026-09-10 hizo visibles, ambas sobre cómo se lee la subsección más que sobre lo que afirma: (1) el eco de emisores deduplica por institución, no por cadena de texto, así BID e IDB (o BCIE y CABEI, Banco Mundial y World Bank) no aparecen nunca como dos contrapartes distintas, mientras que BID Invest y BID Lab sí se mantienen separados porque son ventanillas con tickets propios; (2) cuando no hay bloque de cierre al que anclarse, la subsección se inserta antes del párrafo de despedida de la respuesta (esa última línea corta que ofrece el paso siguiente), no después: una lectura de dinero pegada tras la firma se lee como relleno anexado, no como parte de la respuesta. - En
briefingel flag no cambia nada: sus siete secciones fijas ya llevan la lectura de financiamiento dentro de "Situación actual".
No cuesta una ronda extra de recuperación: recommend_action ya devuelve capex, opex, instrumentos ajustados por ticket y los tomadores de decisión ordenados por relevancia.
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.
El default en la primera pregunta es responder, no aclarar. Una pregunta de apertura con alcance de decisión, nombra país (o la página lo aporta), tema y una señal de decisión: comparar, priorizar, financiar, regular, o un término sectorial que ya nombra el objeto de la decisión (pipeline, subasta, licitación, concesión, NPL, curtailment, nearshoring, tarifa), recibe la respuesta sustantiva completa en ese primer turno, con el supuesto secundario declarado en una cláusula. El bloque de aclaración se reserva para la pregunta materialmente sub-especificada, donde lecturas razonables producen respuestas accionables distintas. Y un corpus vacío nunca es motivo para aclarar: si la pregunta con alcance de decisión cae en un hueco, la respuesta honesta es el hueco (declarado en la primera frase, con el dato citado más cercano después), nunca una pregunta de vuelta que lo esquive.
Son preguntas aclaratorias y 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.
El JSON de ese bloque lo escribe el modelo a mano, y el bloque suele ser toda la respuesta del turno: un rechazo estricto no borraba un adorno, borraba el turno entero y el usuario leía «No pude completar la respuesta». Por eso el parseo es tolerante y en tres pasos: JSON.parse estricto; si falla, una reparación mecánica (coma sobrante antes de }/], prosa envolviendo el objeto) que respeta las comas dentro de cadenas; y si aun así falla, se rescatan las cadenas question/text y se muestran como prosa numerada. Las opciones nunca se rescatan —una lista de opciones leída a medias es peor que prosa— y un cuerpo del que no se recupera ninguna pregunta sigue descartándose entero.
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, y tampoco una frase de preparación interna ("Tengo todo lo que necesito", "I now have all the data needed"): narrar la propia recuperación es una fuga de razonamiento, no una respuesta.
- Cada cita es auditable en su primer uso. La primera vez que una respuesta usa un
[[cite:id]], el token va acompañado en la misma frase (o celda) de una etiqueta legible de la fuente con su año ("según World Bank SPI 2024[[cite:…]]"), tomada del propio registro de la cita; los usos siguientes del mismo id pueden ir solos. La etiqueta acompaña al token, nunca lo sustituye. Para un id externoweb-el emparejamiento es obligatorio en cada uso: un[[cite:web-…]]a secas nunca es el único puntero: lleva el nombre de la publicación (o URL resoluble) y su fecha, o la afirmación se marca[?]o se elimina. Una celda de tabla cuyo único respaldo es un idweb-lo dice en línea: "fuente externa". Ese emparejamiento no depende solo del prompt: el servidor lo aplica de forma determinista (server/chat/cite-label-assist.ts) anteponiendo "(fuente, año)" al token, tomando la etiqueta únicamente del registro real de la cita (su camposource, o el host de su URL; nunca eltitle, que en un resultado externo es texto de página no confiable). Para un id de corpus se aplica al primer uso; para un id externoweb-, a cada uso, exactamente como pide la regla, con un presupuesto propio para que un id externo repetido en una tabla no consuma el que necesita la primera cita de otra fuente. Si el registro no puede respaldar una etiqueta honesta, el token se deja a la maquinaria de[?]/rescate en vez de vestirse con un nombre inventado. - Los números de ley y decreto son bibliografía. Un número de decreto, DL, resolución o ley que ninguna herramienta devolvió recibe la misma disciplina que un título de informe inventado: no se confirma, no se cita, no se construye sobre él; se declara no verificable y se retracta cualquier mención previa.
- Tabla obligatoria al comparar 3+. Cuando la respuesta compara tres o más países, entidades o proyectos, o el usuario pidió una tabla o una comparación, la comparación debe ser una tabla markdown (una fila por entidad, cada valor con su vintage y su
[[cite:id]]en la celda). Una enumeración en prosa no la sustituye, en ningún idioma ni profundidad. - Fidelidad de citas bajo desafío. Si el usuario cuestiona una cita previa o pide el título exacto, URL y fecha de un informe nombrado, los datos bibliográficos salen sólo de un registro devuelto por una herramienta en esa conversación; si ninguno lo respalda, el asistente se retracta en la primera frase y reafirma lo que sí puede sostener con citas reales. Nunca inventa ni reconstruye un título, URL o año de CEPAL, Banco Mundial o cualquier fuente. Y si piden datos de un año que el corpus no tiene, la primera frase declara el hueco ("No tengo datos de 2025 en el corpus") y después entrega el último vintage disponible, etiquetado como tal.
- Pregunta antes de adivinar. Ante una pregunta genuinamente ambigua en alcance, el asistente emite un bloque
futuros:clarifyy 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_pulsepara contradicciones documentadas (lista curada, no exhaustiva). - Desnutrición Ecuador:
cepal_desnutricionen 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. - Tecnologías de frontera (97 indicadores × 25 países, diez dominios):
frontier-ranking(cobertura observada, no capacidad; muchos valores son ceros reales de la fuente),frontier-country(con filtrodomain=para nuclear / biotech / quantum / space / patents),frontier-domain(qué mide y qué no cada dominio),frontier-gaps(las ausencias medidas: FLOPs instalados, Tbps de cable, regiones cloud, densidad robótica IFR, registros de despliegue público de IA, VC deep-tech, slots orbitales ITU) yfrontier-sources. Unsourced-zerose enuncia como hallazgo; unno_sourceno se lee como cero. - 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 comocivicus_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:
| Herramienta | Qué hace |
|---|---|
resolve_metric | Traduce lo que el usuario pide a un indicador concreto del corpus, en el idioma del usuario: compara por igual las etiquetas es/en/pt del registro gobernado, así que «taxa de desemprego» o «unemployment rate» resuelven a la misma métrica que «tasa de desempleo» en vez de producir una ausencia falsa. |
get_parameter_data | Recupera el valor de un indicador por país, con su cita. |
search_corpus / get_dataset | Búsqueda semántica sobre el corpus y acceso directo a un dataset catalogado. Los filtros opcionales de ambos (país, pilar) se normalizan contra el catálogo antes de buscar — «Brasil» o «educación» filtran igual que BRA / educacion, en vez de ocultar el corpus etiquetado tras una ausencia falsa — y la búsqueda por texto del catálogo legal ignora mayúsculas y acentos («cambio climatico» encuentra la Ley General de Cambio Climático). |
compare_countries | Compara países sobre un indicador. |
get_news / get_regional_pulse | Noticias citadas y pulso regional. |
compute | Cálculo determinista. Incluye pronósticos (marcados como proyección, no como hecho) y explain_change (que declara asociación, no causalidad). Los cálculos multi-país (ranking, brecha a la frontera, correlación) declaran además qué países pedidos quedaron fuera por falta de dato en el año solicitado (countries_without_data, con el último año disponible de la serie cuando esta existe): un ranking parcial nunca se presenta como completo. |
get_data_health | Estado de salud y frescura del dato. |
get_discoveries | Hallazgos precalculados (movimientos, anomalías). |
find_pilots / get_pilot | Pilotos bancables por criterio y ficha de piloto. La búsqueda y el ajuste al problema comparan por igual el texto del piloto en es/en/pt (nombre y pitch) — una pregunta en portugués encuentra el piloto cuyo propio name_pt la describe — y el nombre y el pitch llegan en el idioma de la pregunta. |
recommend_action | Encadena 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; autorización humana para exportar). El emparejamiento por ticket declara además cuándo la lista mostrada es un recorte (buckets_truncated, con lo mostrado y lo que realmente encajó): se muestran hasta 6 ventanas que encajan y hasta 3 de cada borde, y sobre el portafolio real 56 de los 68 pilotos recortan algún grupo —uno llega a 17 instrumentos que encajan para 6 lugares—, así que una preselección nunca se presenta como el conjunto completo de instrumentos que pueden financiar el piloto. candidates_considered no basta: cuenta los tres grupos a la vez. |
web_search / fetch_url / perplexity_search | Bú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.
| Bloque | Qué renderiza |
|---|---|
futuros:viz | Grá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:map | Mapa 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:pilots | Pilotos alineados al problema real de quien pregunta (ver relevancia estricta). |
futuros:personas | Personas del corpus citadas como contraparte de decisión. |
futuros:news | Noticias y pulso social, marcados explícitamente como "últimas noticias, no datos 100% verificados" — nunca con la cita azul de un indicador. |
futuros:laws | El 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.
El prompt refuerza la misma jerarquía dentro del texto: la evidencia tiene niveles, y el nivel viaja con la afirmación. Una señal extraída por máquina (id sig-) es una afirmación de prensa citada con puntaje de confianza, no una serie estadística verificada: se cita con su medio y su fecha, etiquetada como señal, y nunca carga el veredicto cuando una serie de indicador cubre la misma medida. Un resultado web (web-) queda un nivel más abajo: su marcador y su fecha acompañan la afirmación también en tablas (una cita web- en una fila la hace externa), y una afirmación web sin fecha o desafiada por el usuario se retira antes que defenderse. Presentar una señal o una cifra web con la misma confianza que una serie del corpus es un fallo de fidelidad, tan grave como una cita inventada.
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_corpustampoco 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/vaciosse compone a partir del país y el eje. La regla completa vive en la cabecera deserver/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 una nota explícita, en tres variantes, porque «vacío» significa tres cosas distintas:
- Ausencia real (
empty: true+corpus_empty_note). 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 (
empty: true+corpus_empty_note).search_corpus,resolve_metricy 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. - Llamada fallida (
call_failed_note, nuncaempty: true). Un error de ejecución, un presupuesto agotado o un argumento mal escrito no son un veredicto del corpus. El resto del sistema ya lo trataba así (una llamada con error nunca se registra como vacío, no puntúa «n/a» ni abre ticket), pero un resultado de error es{error: …}, que el predicado compartido cuenta como vacío, así que el cable le ponía la nota de ausencia real y el modelo abría con «Futuros no tiene ese dato» sobre datos que el corpus sí tiene. La nota de llamada fallida pide corregir los argumentos y reintentar, o decir que no se pudo recuperar, sin afirmar ausencia y sin fabricar.
Las 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 precalcular los 1.346 perfiles:
formerse 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. En la búsqueda híbrida, un resultado que llega por ambas vías (semántica y léxica) conserva el título del recorrido en vivo: el marcador[ex-cargo]de un ex titular sobrevive a la fusión aunque el índice semántico precalculado sea más viejo.- Cada fila lleva su fecha. No existe campo
verified_as_ofen el corpus, así que el sello se deriva delast_updated, presente en 1.346 de 1.346 perfiles y ya cargado por el ranking:verified_as_of,verified_days_agoycurrency(recent≤30 días,aging≤90,unverifiedmás allá). El perfil medio tiene ~66 días, razón por la cualrecentno puede significar 90. - Los números de confianza se condicionan a la vigencia. Cuando
currencyno esrecent,readinessyrelationship_statussalen ennullcon unreadiness_withheldque dice desde cuándo no se re-verificó. La justificación está en los datos:readinessvale 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-loanes una etiqueta de catálogo, no un grado verificado de concesionalidad. Lo mismo para losfinancing[]de un piloto: son un reparto de diseño, nunca cofinanciamiento comprometido. - Detector determinista.
detectFinancialTermsmarca 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 eventoverifycomofabricated_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, nuncabacklog:gaps:v1. Nada que lea el backlog puede alcanzarlo, ni siquiera por accidente, y se purgan por separado. - La ruta pública no cambia.
sanitizeTicketsigue reconstruyendo cada ticket campo por campo, así que una pregunta no puede salir porGET /api/backlogni por/vacios. Esto añade un almacén; no retira ninguna garantía. - La lectura falla cerrada.
GET /api/questionsexigeAuthorization: Bearer $QUESTION_LOG_TOKENy rechaza toda petición cuando el token no está configurado (la postura deapi/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_queryde PostHog (y el de embudochat_submit) lleva longitud, idioma, ruta, modo y ungeneration_id: nunca el texto. El mismogeneration_idune esas mitades con el registro privado de preguntas y con los eventos de servidorchat_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.
Desde el historial por cuenta, este registro sólo recibe los turnos sin sesión o cuyo guardado en Postgres falló; con DATABASE_URL, GET /api/questions lee de Postgres y marca cada fila con user_hash.
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-turnscon el mismoQUESTION_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 elstatusdel turno (ok|error|declined|empty|aborted). - La escritura es best-effort en el
finallydel handler: cubre turnos que abortan, fallan o son rechazados, no solo losdonelimpios. - 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=.
Misma transición que el log de preguntas: un turno con fila en message ya no se escribe aquí, y GET /api/chat-turns lee de Postgres cuando hay DATABASE_URL (source: "postgres", user_hash por fila).
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):
| Evento | Payload | Qué 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 incremental | Anexa 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. |
title | {conversationId, title} | Sólo en la primera respuesta de una conversación: el título generado por Haiku sustituye al provisional (primeros 60 caracteres de la pregunta). La barra lateral de /chat lo muestra. |
error | {code, message, reason?, status?} | Se adjunta en línea sin borrar el parcial. reason (auth/credits/unavailable, derivado del status HTTP más una pista depurada del cuerpo del proveedor — un 403 cuyo cuerpo dice "Key limit exceeded" es credits, no auth — sin secretos) decide si "reintentar" tiene sentido. message no se muestra: se traduce a una causa acotada para la telemetría (ver abajo). |
Un mismo code no es una misma falla. internal lo emiten cinco condiciones sin relación entre sí: un fetch que falló en el navegador antes de salir (network_error), un stream cortado a media respuesta (stream_error), un turno que no produjo texto alguno (empty — un vacío honesto del corpus, no una caída), una excepción no controlada dentro del turno y un fallo al abrir el stream (ambos internal). El evento chat_fail lleva por eso una propiedad cause derivada del message del cable contra una lista blanca cerrada (network · stream · empty · server · http · declined, y other para todo lo demás), de forma que un chat_fail code=internal en producción se pueda diagnosticar en vez de agrupar cinco fallas distintas bajo un solo código. La lista blanca es lo que impide que un message de texto libre (una pista de límite de tasa, un mensaje de validación) entre como propiedad de analítica. | done | {usage, truncated?, conversationId?} | Cierra el turno. Piso final: si quedó vacío pero hay sombra, la restaura — un turno nunca termina en blanco si existió texto previo. conversationId llega cuando el turno se registró en la cuenta: el cliente lo adopta para los turnos siguientes. | | persisted | {conversationId, seq} | Llega después de done: la fila del turno ya está en Postgres. El cliente vacía su caché de sesión y no reintenta. Si no llega, reintenta la persistencia una sola vez (ver Historial de conversaciones). |
El cliente añade un centinela propio para el stream cortado, en sus dos formas: un EOF sin evento terminal done/error, y una lectura que falla a media respuesta. Ambas son el mismo hecho —la conexión murió mientras se transmitía— y cuál de las dos ocurre es un detalle del transporte que el lector no puede percibir, así que ambas reciben la misma revelación: el parcial se marca truncado, jamás completo, y nunca se borra. La mitad que falla además reporta stream_error, que es la causa que el operador necesita en chat_fail; la revelación es lo que se le debe al lector y el reporte lo que se le debe al operador, y no se sustituyen. Si ya llegó un evento terminal, el corte posterior es un cierre de conexión y no se vuelve a reportar: el turno ya declaró su resultado.
Un turno sano es siempre 200 + SSE: el handler codifica hasta sus propios rechazos (rate_limited, bad_request, not_configured) como evento error sobre un 200. Por eso una respuesta HTTP no-200 nunca llegó al modelo, y el cliente la clasifica por lo que de verdad significa en lugar de llamarla upstream: 401/403/404/405 → not_configured (el despliegue no sirve chat, p. ej. CHAT_ENABLED sin poner), 429 → rate_limited, 413 y demás 4xx → bad_request, 408 y 5xx → upstream con reason: unavailable (el único caso donde reintentar es consejo honesto). Todas llevan el status, así que un solo chat_fail basta para diagnosticar.
Historial de conversaciones (cuenta)
Desde la llegada de las cuentas, cada conversación con el asistente queda guardada en la cuenta de quien pregunta (tablas conversation y message en Postgres, Neon, región São Paulo) y se retoma desde /chat. Tres reglas gobiernan el diseño:
- La conversación se crea perezosamente. El cliente envía
POST /api/chatconconversationId: nullla primera vez; el handler crea la fila, guarda el turno del usuario y transmite la respuesta exactamente como antes. El evento terminaldonellevaconversationId, y el cliente lo adopta para los turnos siguientes. UnconversationIdajeno, borrado o inválido nunca anexa a esa conversación: el servidor abre una nueva, sin interrumpir la respuesta. - La generación sigue siendo sin estado. El cliente sigue enviando los últimos 12 turnos en el cuerpo; el ensamblado del prompt, la fidelidad y la completitud no cambian. La persistencia es un efecto lateral (
server/chat/persist.ts) con su propio plazo (2,5 s): si la base de datos no responde, la respuesta sale igual y el turno cae en los registros Redis de siempre. - Una fila por turno, idempotente. El turno del asistente se guarda en el
finallydel handler, después dedone(texto, citas completas, seguimientos,generation_id, modelo,truncated, verificación, proveedor, traza de herramientas), y el stream emitepersisted. Si ese evento no llega, el cliente reintenta una sola vez conPOST /api/conversations/:id/messages(oPOST /api/conversationscon el turno, si nunca recibió un id); elgeneration_iddeduplica, así que un reintento jamás produce dos filas. Un turno rechazado antes de responder (cuota, chat no configurado) no crea nada.
| Qué se guarda | Dónde | Notas |
|---|---|---|
| Turno de usuario | message(role=user) | Texto, idioma, modo y contexto de página (en meta). |
| Turno del asistente | message(role=assistant) | Texto con [[cite:id]], las citas completas (para que el hilo re-renderice con procedencia), seguimientos, modelo, truncated, verificación, proveedor, herramientas, ticket de hueco de datos y estado del turno. |
| Título | conversation.title | Primeros 60 caracteres de la primera pregunta; tras la primera respuesta, un título generado por Haiku (una sola llamada, en paralelo con los seguimientos; nunca se regenera). Renombrable desde /chat. |
| Visibilidad | conversation.visibility | private por defecto; org la comparte con la organización activa (lectura para sus miembros; renombrar, borrar y seguir escribiendo siguen siendo del autor). Dejar de compartir devuelve la fila al ámbito personal. |
Lo que no cambia. El enlace público compartido (/atlas#ask=…) sigue funcionando para un hilo suelto y nunca apunta a una conversación guardada. sessionStorage (futuros_chat_session) pasa a ser sólo una caché de recuperación ante caídas: se vacía en cuanto el turno queda guardado y, al recargar, el cliente rehidrata la conversación desde GET /api/conversations/:id.
La interfaz. /chat lista las conversaciones agrupadas por día (hoy, ayer, últimos 7 días, últimos 30 días, anteriores), con búsqueda por título sin distinguir tildes, filtro de ámbito (mis conversaciones / todas, que incluye las compartidas con la organización), renombrar, eliminar y el conmutador de compartir. Los paneles de chat de /atlas y /sala enlazan a /chat y conservan su panel en línea.
Los registros anónimos en Redis dejan de escribirse para un turno que tiene fila en Postgres: chat:questions:v1 y chat:turns:v1 sólo reciben los turnos sin sesión o cuyo guardado falló. GET /api/questions y GET /api/chat-turns (mismo QUESTION_LOG_TOKEN, misma forma de respuesta, más source y user_hash) leen primero Postgres cuando DATABASE_URL existe, con el id de usuario hasheado (SHA-256 con sal, 16 caracteres), y sólo sin base de datos caen a Redis. Sin DATABASE_URL todo se comporta como antes de esta versión, y los endpoints /api/conversations* responden 503 history_not_configured.
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):
- 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. - 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 conjuntoreturnedIds: 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. Como las peticiones son sin estado,returnedIdsse siembra con los ids que los turnos previos del asistente ya citaron (collectHistoryCiteIds, sólo turnos con rol asistente: un token pegado en un mensaje de usuario nunca se licencia a sí mismo), así el follow-up honesto que reutiliza una cita del turno anterior sin re-consultar no queda marcado como alucinación. - 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. - Auto-corrección de una pasada: si la respuesta final cita ids no anclados, el handler emite un evento
revisingy 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 cada token de cita inválido reemplazado por el marcador visible[?], nunca eliminado en silencio, porque un borrado convierte "cifra citada" en "cifra sin fuente con más confianza aparente", que es peor que el defecto que esconde (en los bloques de visualización el campo"cite"inválido sí se elimina, porque un marcador rompería el JSON; el punto de datos conserva su valor como cifra honesta sin cita). El salvamento corre después del bucle, no solo en unend_turnlimpio: el reloj puede expirar antes o a mitad de la revisión, y aun así debe restaurarse el borrador completo.
Respuesta vacía: un reintento acotado antes de rendirse
Un turno puede llegar a su ronda terminal sin nada que entregar, y ninguna de las tres formas tiene que ver con el reloj: el proveedor devuelve una compleción degenerada (end_turn sin contenido, lo que OpenRouter hace cuando su upstream está degradado); el turno terminal viene entero dentro del andamiaje privado ```futuros:reasoning, que se elimina del lado servidor y debe seguir eliminándose; o su bloque ```futuros:clarify no sobrevive a la normalización.
Antes, el handler se rendía en la primera: emitía error: internal/empty y el usuario leía «No pude completar la respuesta. Probá de nuevo» mientras quedaban rondas y reloj sin gastar. Ahora gasta exactamente un reintento, con el problema nombrado explícitamente en la instrucción, y prefiere el siguiente proveedor de la cadena cuando lo hay: una compleción vacía es una señal de salud del proveedor, no un problema del prompt. Es seguro por construcción —una respuesta vacía nunca llegó a enc("text"), y una normalización que vació un borrador ya mandó reset_text—, así que la pantalla está en blanco y el reintento no puede duplicar nada. El reintento exige ≥ 15 s de reloj (una ronda de modelo más margen para el done terminal) y una ronda libre en el presupuesto; sin eso, se concede de inmediato.
Si el reintento también vuelve vacío se emite el mismo error honesto de siempre —una burbuja en blanco nunca es aceptable— pero la línea de log lleva ahora el estado que la hace atribuible sin reconstruir tiempos de petición: rondas usadas, herramientas ejecutadas, milisegundos transcurridos, truncated (que separa el camino del reloj de los del modelo), shaping_emptied (el modelo sí escribió algo y lo borró la normalización) y el proveedor activo.
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.
Los artefactos exportables heredan el mismo contrato. Los botones de la respuesta (copiar, CSV, XLSX, compartir) resuelven las citas sin distinguir mayúsculas — el modelo a veces escribe [[cite:icr-BRA]] para el registro icr-bra y la verificación del servidor lo acepta, así que el export no puede perder esa fuente — y un token de cita cuyo id no resuelve a ningún registro nunca se borra en silencio: queda como marcador visible [?] y se lista como «referencia sin verificar» en la lista de fuentes. Borrarlo convertiría una cifra no verificada en prosa limpia sin cita en el artefacto que un tercero reenvía — exactamente el lavado que la verificación existe para impedir (un id no anclado puede llegar al cliente: un borrador truncado salta la corrección). Las hojas de cálculo exportadas —el CSV de fuentes y el libro XLSX— neutralizan además las celdas que empiezan con un carácter de fórmula (=, +, -, @, también tras espacios o tabulaciones iniciales): el título de una cita web llega de fuera del corpus y, abierto en Excel o Sheets, sería ejecutable — queda como texto inerte, con un apóstrofo inicial que la hoja no muestra. Un único sanitizeSpreadsheetCell sirve a los dos caminos, porque un artefacto reenviado a un ministerio o a una IFI no puede depender de cuál de los dos botones se pulsó. Las celdas numéricas (la columna de año) pasan sin tocar en ambos, y el CSV entrecomilla también el retorno de carro: las filas se unen con \r\n, así que un CR sin entrecomillar dentro de un título partiría el registro en dos. Y el enlace de compartir decodifica con la misma fidelidad con la que codifica: los topes del lado receptor son al menos tan generosos como el techo de 24 000 bytes del emisor, así que un briefing largo legítimo nunca pierde su cola —ni sus citas— en silencio, y un token fabricado más grande que ese techo se rechaza entero en vez de truncarse. Los bloques de máquina tampoco se filtran como JSON crudo: el briefing y el XLSX vuelcan el bloque futuros:viz como líneas de cifras con sus referencias numeradas — así el XLSX lista las mismas fuentes que el CSV de la misma respuesta, incluidas las que solo el gráfico citaba — y los bloques futuros:pilots / futuros:clarify se convierten a texto legible (pilotos y preguntas, no JSON) en copiar, briefing y XLSX.
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.
Cuotas de uso y disponibilidad
Dos superficies, dos posturas. El chat en el sitio (mismo origen, SPA) es gratuito y no pide clave. El POST /api/chat programático — scripts, agentes u otros orígenes — exige una clave Futuros prepago (Authorization: Bearer ft_live_…) comprada en /acceso: 5 créditos por turno, 15 por un Deep Briefing. Sin clave → api_key_required; sin créditos → insufficient_credits. El servidor MCP se cobra igual (véase MCP).
El gasto del chat en el sitio sigue acotado con cuotas explícitas cobradas antes de tocar el modelo (api/chat.ts, server/http/rate-limit.ts):
| Cuota | Techo | Qué acota |
|---|---|---|
| Por IP | 20 turnos por minuto | El uso individual. Se cobra sobre la IP de confianza (x-real-ip, o el extremo derecho de x-forwarded-for), nunca sobre la cabecera falsificable. |
| Global por minuto | 300 turnos por minuto sumando a todos los usuarios | La ráfaga, dimensionada muy por encima del tráfico real. |
| Global por hora | 300 turnos por hora sumando a todos los usuarios | El gasto sostenido. Es el techo que acota una campaña distribuida que rota IPs, contra la cual el límite por IP no hace nada. |
Un turno de Deep Briefing cuesta tres unidades en lugar de una en las tres cuotas (una al admitirlo, dos de recargo una vez leído el modo), porque consume hasta 9 rondas de herramientas, 16.384 tokens de salida y búsqueda web de pago. En la práctica: unos 6 briefings por minuto y por IP, y unos 100 briefings por hora en toda la plataforma. El recargo no bloquea el turno que lo dispara; se cobra sobre la ventana en curso.
Al rebasar una cuota, la respuesta es un evento SSE error con código rate_limited y cabecera Retry-After: "too many requests; slow down" para la cuota por IP, "service is busy; retry shortly" para las globales.
Techo degradado en vez de servicio sin medir. En un despliegue gestionado (Vercel), si el almacén compartido de cuotas está configurado pero no se puede consultar, el chat sigue respondiendo bajo un techo mucho más estrecho, contado en la memoria de cada instancia: 6 turnos por minuto y por IP (en vez de 20), 30 por minuto y 120 por hora en toda la plataforma (en vez de 300 y 300). Degradar a los techos completos multiplicaría cada uno por el número de instancias vivas, es decir, quitaría el único límite al gasto justo cuando el almacén falla; rechazar todo, en cambio, convierte una caída de Redis en una caída del producto. El techo degradado es el punto medio: nadie que use el chat de verdad lo nota (un turno tarda decenas de segundos), y un atacante obtiene un múltiplo pequeño de un número pequeño.
Esos cuatro números son por instancia, no totales de la plataforma: sin almacén compartido no hay forma de contar entre instancias, así que la exposición real en el peor caso es cada techo multiplicado por el número de instancias vivas. Ese es justamente el motivo de que sean tan estrechos — con los techos completos el multiplicador se aplicaría a 20, 300 y 300. Es una degradación medida, no un techo duro; el techo duro vuelve cuando vuelve el almacén.
Al rebasar ese techo de emergencia la respuesta sigue siendo honesta y distinta de la de cuota rebasada: "rate limiting is temporarily unavailable; retry shortly". No es que estés sobre tu cuota publicada; es que la cuota no se puede consultar. GET /api/chat lo expone en limiter: store_configured, store_reachable y degraded — la señal que un operador debe vigilar, porque la configuración por sí sola no es salud. Ese sondeo va al almacén de verdad (no cobra ninguna cuota, con tiempo límite de 2 s) y se cachea 10 segundos por instancia, para que un monitor con sondeo periódico no se convierta él mismo en carga sobre el almacén que quizá ya esté fallando; el veredicto puede entonces ir hasta 10 s por detrás. El mismo endpoint informa la credencial de inferencia en provider_key: probed, ok, el status upstream y, cuando OpenRouter es el primario, la postura del límite de gasto de la clave (exhausted, limit_remaining). Una clave válida cuyo techo por clave ya se gastó sigue fallando en todos los turnos con un 403 "Key limit exceeded" (2026-09-15: todos los turnos en producción, con la credencial en sí aceptada), así que ok pasa a false con el techo agotado exactamente como con una clave rechazada: el arreglo es recargar o subir el techo, no rotar.
Un despliegue que nunca configuró un almacén (desarrollo local, self-hosting) sigue con los techos completos por instancia y un aviso en los logs. Un operador que prefiera rechazar antes que servir sin medir pone RATE_LIMIT_REQUIRE_STORE=1: eso restituye el rechazo duro, tanto para un almacén ausente como para uno caído.
La cadena de proveedores y el modo soberano
server/chat/provider.ts construye una cadena de proveedores con failover transparente (providerChain() / providerChainForRequest(model)). 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.
Antes de gastar un cupo de failover, un 400 pre-respuesta reintenta una vez la misma ronda sin cache_control: OpenRouter (y algunos shims Messages) rechazan el breakpoint de caché cuando ningún endpoint ruteado soporta prompt caching de Anthropic, y ese 400 mataba el turno entero con un upstream opaco. Un 400 real de esquema vuelve a fallar al instante (un viaje de más); un rechazo de caché recupera la respuesta. El error SSE de un proveedor agotado lleva ahora reason (auth / credits / unavailable / upstream) y status HTTP — derivados solo del status, sin secretos — para que la copia al lector deje de invitar a reintentar una clave muerta.
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 vía providerChainForRequest(parsed.model) después del parse (el gate de configuración sigue usando providerChain(); pasar esa cadena al runChat descartaba el model y el selector era un no-op en el cable). Esto anula el camino soberano para esa petición. Los seguimientos siguen en Haiku. Catálogo:
| Etiqueta | Slug OpenRouter |
|---|---|
| Kimi K3 | moonshotai/kimi-k3 |
| DeepSeek V4 Pro | deepseek/deepseek-v4-pro |
| Claude Sonnet 5 (default) | anthropic/claude-sonnet-5 |
| GPT-5.6 Terra | openai/gpt-5.6-terra |
| Claude Fable 5 | anthropic/claude-fable-5 |
| GPT-5.6 Sol | openai/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:
| Clase | Datasets | Qué devuelve la herramienta |
|---|---|---|
| Oficial y citable | colombia-alocucion, colombia-contexto, colombia-balance (solo el corte oficial), colombia-promesas, colombia-alivios | El registro más su ColombiaCitation en línea. |
| Demostración | colombia-zonas, colombia-albergues, colombia-fondo, colombia-obras, y los dos cortes reconstruidos de colombia-balance | El 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.
Pack de decisión para expedientes (GuIA y AIE)
Toda respuesta del asistente puede descargarse como pack de decisión (acción "Pack de decisión" bajo la respuesta): un artefacto Markdown pensado para adjuntarse a un expediente de compra pública o de política — una oficina ministerial, un regulador o una DFI lo pega en Word sin retrabajo. Cinco secciones fijas (src/lib/guia-pack.ts, funciones puras con tests; la descarga ocurre íntegra en el navegador):
- Consulta y respuesta citada — la pregunta, la respuesta con referencias numeradas
[1]y una línea de propósito / uso previsto que la persona revisora completa (la plataforma nunca la adivina). - Anexo de fuentes citadas — solo las fuentes que la respuesta cita realmente, con URL y año cuando existen; cada fila declara su origen (
corpusoweb (externa): la separación corpus/web viaja también en el export), y las citadas únicamente dentro de un gráfico se listan marcadas comográfico. - Qué NO está en el corpus — vacíos declarados a partir de señales que el turno ya lleva: la verificación determinista (citas que ninguna herramienta devolvió, cifras sin fuente, cifras citadas que no coinciden con ningún valor registrado de su fuente, términos de financiamiento que el corpus no puede sostener, contradicciones entre fuentes), el ticket de vacío de datos, los cortes (respuesta truncada o detenida) y los marcadores de cita sin registro en la sesión, tanto los que se retiraron de la prosa como los que solo aparecían dentro de un gráfico. Si la verificación no llegó a ejecutarse (transmisión interrumpida, conversación restaurada), el pack lo declara en vez de fingir un chequeo limpio; sin señales registradas, lo dice explícitamente — nunca se sintetiza un vacío ni un riesgo.
- Aprobación humana (obligatoria) — bloque en blanco por construcción: nombre y cargo de la persona revisora, fecha, decisión (aprobar / revisar / rechazar), plan de reversión (rollback) y plan de monitoreo. Futuros no aprueba nada automáticamente; el pack solo tiene valor con esa firma.
- Mapa de etapas GuIA / IA pública — tabla que ancla cada sección del pack a los puntos de control de dos guías públicas de 2026: el GuIA de Brasil (Guia Unificado de IA para o Setor Público, NIA/SGD — 7 etapas, Prospecção → Desativação, con la AIE en las etapas 1–2, el gate de rollback en la 4 y el monitoreo en la 6) y la guía de implementación de la CEPAL (LC/TS.2026/33). Es una lectura temática de apoyo: Futuros no está certificado ni avalado por ninguna de las dos guías, y el pack no sustituye la evaluación de impacto algorítmico que exija la normativa aplicable.
Anexo de citas en pantalla
Al cerrar un turno, ChatMessage monta CiteAppendix (src/components/chat/CiteAppendix.tsx) bajo la respuesta, colapsado en un <details> con el mismo idioma visual que la traza de consultas al corpus. El renglón visible sin abrir es § ANEXO DE CITAS · {n}, donde {n} es la cantidad de filas resueltas que el export numera. Si hay filas web- con registro, el mismo renglón suma {n} fuera del corpus. Una fila [?] (id citado sin registro en la sesión) se cuenta aparte como {n} sin registro y no entra en el conteo de fuera del corpus: no hay registro que clasificar como externo. Una fila que el export no numera (un punto de gráfico con cite y sin valor, que fencesToText no reemite) aparece como {n} sin numerar.
Cada id citado en la prosa o en una cerca futuros:viz es una fila, en orden de primera aparición, con deduplicación sin distinguir mayúsculas. El [n] de una fila resuelta es el mismo que imprimen toBriefing() y answerToWorkbook() en src/lib/chat-export.ts: ambos recorren el texto ya pasado por fencesToText (las citas del gráfico con valor vuelven a salir como [[cite:id]]) y resuelven el id sin distinguir mayúsculas. La fila sin registro se muestra como [?] y abre el cajón de procedencia.
El enlace de la fila sigue linkGradeFor (src/lib/cite-link-grade.ts). Grado record (la URL del propio registro) y grado deep (una url_template con parámetros) se muestran como enlace. Grado portal (solo hay página de aterrizaje) no muestra URL: el texto dice que el editor no publica un enlace por registro, y el control abre el cajón, donde esa página sí aparece etiquetada como portal.
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.