Saltar al contenido
# Arquitectura ## El recorrido de una predicción ```text Fuentes públicas │ ingesta concurrente, 4 workers, cada 15 min ▼ FeedIngestor ──► NormalizedEvent ──► tabla feed_events │ │ recent_events(): colapsa títulos y reparte por fuente ▼ Contexto (≤ 25 eventos) │ ▼ MiroFishSwarm ──► una llamada a Ollama ──► JSON con 7 dictámenes │ ▼ weighted_consensus() ponderado por el Brier histórico de cada agente │ ▼ tabla predictions ──► resolución posterior ──► tabla agent_scores │ └──► pesa el voto de la siguiente ``` El bucle se cierra: los agentes que aciertan pesan más la próxima vez. ## Módulos | Módulo | Responsabilidad | | ------ | --------------- | | `engine/config.py` | Configuración agrupada, alimentada por un `.env` plano | | `engine/models.py` | Cuatro tablas: eventos, predicciones, auditoría, marcadores | | `engine/database.py` | Motor y sesiones; SQLite o PostgreSQL según `DATABASE_URL` | | `engine/llm/` | Cliente de Ollama, prompts y contrato JSON tolerante a fallos | | `engine/mirofish/` | Enjambre, consenso ponderado y los siete agentes | | `engine/feeds/` | Catálogo, registro, ingesta, normalización y caché | | `engine/prediction/` | Brier, calibración, persistencia y resolución | | `engine/embedding/` | Embeddings opcionales, con degradación a CPU | | `engine/api/` | Doce endpoints, SSE con heartbeats, middleware y autenticación | | `ui/osiris/` | Interfaz React: globo, señales y predicciones | ## Las tres decisiones que definen el diseño ### Una llamada al modelo, siete agentes La especificación describía siete agentes analizando «en paralelo». Contra un 7B cuantizado en CPU eso son minutos por predicción: un único proceso de Ollama con un modelo cargado atiende las peticiones de una en una, por mucho `asyncio.gather` que haya en el cliente. `build_swarm_prompt()` describe los siete roles en un único prompt y pide un JSON con un objeto por agente. Una generación en lugar de siete. Lo que se conserva intacto es lo que aportaba valor: cada agente mantiene su persona, su dominio y su historial de calibración, y el consenso sigue ponderando por Brier. Lo que se pierde es el aislamiento entre razonamientos — los siete dictámenes salen del mismo paso de decodificación, así que se contaminan entre sí más de lo que lo harían siete llamadas independientes. Es el precio de que el sistema sea usable en el hardware objetivo. `Agent.analyze()` mantiene el camino de un solo agente, y es el que usa `/chat`. ### El contexto se reparte entre fuentes El contexto son 4096 tokens y hay que dejar sitio para siete dictámenes, así que caben unos 25 eventos. Elegirlos por relevancia a secas no funciona: - NWS emite el **mismo aviso para cada zona afectada**. Son filas legítimas, con identificadores distintos, pero tres líneas idénticas en el prompt no aportan nada. Se colapsan por título. - Peor: pedir simplemente las N filas más relevantes devuelve una **ventana ya sesgada**. NWS y EONET copan la franja alta de saliencia, y GDELT, divisas o cripto no entran ni a competir. Por eso el reparto se hace en la consulta, con `ROW_NUMBER() OVER (PARTITION BY source)`. Medido sobre datos reales, el contexto pasó de nueve avisos de NWS sobre diez a cinco fuentes representadas. El cupo por fuente es una **reserva, no un tope**: garantiza representación a las minoritarias, pero si sobran huecos se rellenan por relevancia en vez de desperdiciar tokens ya pagados. ### El catálogo declara; el código implementa `catalog.yaml` lista las 51 fuentes que el proyecto reconoce. Sólo ocho tienen clase. Las otras 43 llevan su `homepage` y una nota con lo que falta por resolver, **pero ningún endpoint inventado**: el endpoint vive en la clase de cada fuente, de modo que no hay dos sitios que puedan desincronizarse. El registro valida el cruce al cargar: una entrada marcada como implementada sin clase detrás —o una clase que no figura en el catálogo— es un error de arranque, no un fallo silencioso a mitad de la ingesta. ## Streaming SSE El endpoint `/predict/stream` mantiene la conexión abierta mientras procesa. Como una predicción tarda ~8 minutos, se emiten **heartbeats cada segundo** (comentarios SSE: `: heartbeat\n`) para evitar que FastAPI o Vite buffreen la respuesta hasta el final. El cliente (navegador) recibe eventos: `started` (7 agentes listos), `completed` (Ollama terminó, N segundos), `result` (JSON completo), `error` (si ocurre fallo). El dashboard anima una barra de progreso mientras llegan heartbeats, y muestra el resultado en cuanto termina. ## Tolerancia a fallos | Fallo | Qué ocurre | | ----- | ---------- | | Una fuente se cae o tarda | Se anota en el informe; las demás siguen | | El modelo devuelve JSON inválido | Un reintento con el error como contexto; a la segunda, 502 | | El modelo confunde probabilidad con porcentaje | `AgentVerdict` normaliza `78`, `"78%"` y `"0.78"` | | El modelo omite la clave `verdicts` | Se acepta el objeto plano si las claves son nombres de agentes | | El modelo se inventa un agente | Se descarta | | Ollama no responde | 503, no 500: es una dependencia externa y se puede reintentar | | Una ronda de ingesta falla entera | Se registra y el bucle continúa | ## Base de datos `DATABASE_URL` decide el motor. SQLite por defecto para que el proyecto arranque y los tests corran sin levantar nada; PostgreSQL en producción, con el extra `[postgres]`. Las cuatro tablas y sus índices se crean con `scripts/init_db.py`. No hay migraciones todavía: mientras el esquema no esté asentado, recrear es más barato que versionar.