From d96222e9fc0fb360fa518f8cabd881c3038e75b7 Mon Sep 17 00:00:00 2001 From: Rafael Herrera Date: Thu, 4 Jun 2026 18:10:37 -0500 Subject: [PATCH 1/9] docs: agregar guia de agente de AI Radar --- AGENTS.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d0c28c2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# AI Radar Agent Guide + +AI Radar is the course project for learning Codex with a real product surface. + +The current repository state is intentionally small. Treat the README as product direction, not as proof that the full system already exists. + +## Current State + +- The project currently has a README and repository rules. +- The implementation is built class by class. +- Do not assume app files, scripts, databases, skills, deploy config, or automations exist until they are present in the repo. + +## Product Direction + +AI Radar will collect AI news, papers, repos, tools, and launches, then turn them into verifiable signals for builders. + +The final system should support: + +- source evidence; +- normalized signals; +- duplicate detection; +- ranking; +- practical action guides; +- an operator view; +- deploy and automation. + +## Working Rules + +- Inspect the repo before editing. +- Keep changes scoped to the current class objective. +- Prefer small, reproducible files over chat-only state. +- Do not commit secrets, local caches, generated weekly snapshots, build output, videos, screenshots, or temporary reports. +- When a class creates a reusable process, prefer a skill. +- When a class creates deterministic work, prefer a tool or script. +- When adding data examples, use fixtures or contracts unless the class explicitly requires a durable seed. + +## Validation + +For each class branch, leave a clear state: + +- what was added; +- how to verify it; +- what remains intentionally missing. + +If commands do not exist yet, do not invent them in docs as if they already work. From 6d84d6e44d4791b3c84968a107d9d0fa5bad16d5 Mon Sep 17 00:00:00 2001 From: Juan Melo Date: Sun, 21 Jun 2026 11:55:05 +0200 Subject: [PATCH 2/9] chore: guardar busqueda diaria de IA --- contracts/ai-radar-daily-signals.schema.json | 147 +++++++++++++++++++ data/daily/2026-06-21.json | 95 ++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 contracts/ai-radar-daily-signals.schema.json create mode 100644 data/daily/2026-06-21.json diff --git a/contracts/ai-radar-daily-signals.schema.json b/contracts/ai-radar-daily-signals.schema.json new file mode 100644 index 0000000..250bc14 --- /dev/null +++ b/contracts/ai-radar-daily-signals.schema.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ai-radar.local/contracts/ai-radar-daily-signals.schema.json", + "title": "AI Radar Daily Signals", + "description": "Contrato local para guardar busquedas diarias de noticias de IA como senales accionables de AI Radar.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "contrato", + "fecha", + "generado_en", + "busqueda", + "senales" + ], + "properties": { + "$schema": { + "type": "string" + }, + "contrato": { + "const": "ai-radar.daily-signals.v1" + }, + "fecha": { + "type": "string", + "format": "date" + }, + "generado_en": { + "type": "string", + "format": "date-time" + }, + "busqueda": { + "type": "object", + "additionalProperties": false, + "required": [ + "consulta", + "idioma", + "criterio", + "fuentes_consultadas" + ], + "properties": { + "consulta": { + "type": "string", + "minLength": 1 + }, + "idioma": { + "type": "string", + "minLength": 2 + }, + "criterio": { + "type": "string", + "minLength": 1 + }, + "fuentes_consultadas": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "format": "uri" + } + } + } + }, + "senales": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "titulo", + "tema", + "fuente", + "evidencia", + "impacto", + "accion", + "estado" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "titulo": { + "type": "string", + "minLength": 1 + }, + "tema": { + "type": "string", + "minLength": 1 + }, + "fuente": { + "type": "object", + "additionalProperties": false, + "required": [ + "nombre", + "url", + "publicado", + "consultado" + ], + "properties": { + "nombre": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "format": "uri" + }, + "publicado": { + "type": "string", + "format": "date" + }, + "consultado": { + "type": "string", + "format": "date" + } + } + }, + "evidencia": { + "type": "string", + "minLength": 1 + }, + "impacto": { + "type": "string", + "minLength": 1 + }, + "accion": { + "type": "string", + "minLength": 1 + }, + "estado": { + "type": "string", + "enum": [ + "alta_prioridad_activo", + "riesgo_regulatorio_en_desarrollo", + "senal_tecnica_accionable", + "infraestructura_critica_activo", + "estrategica_emergente", + "observacion" + ] + } + } + } + } + } +} diff --git a/data/daily/2026-06-21.json b/data/daily/2026-06-21.json new file mode 100644 index 0000000..7362dc7 --- /dev/null +++ b/data/daily/2026-06-21.json @@ -0,0 +1,95 @@ +{ + "$schema": "../../contracts/ai-radar-daily-signals.schema.json", + "contrato": "ai-radar.daily-signals.v1", + "fecha": "2026-06-21", + "generado_en": "2026-06-21T11:48:28.6948203+02:00", + "busqueda": { + "consulta": "5 noticias recientes de IA como senales de AI Radar", + "idioma": "es", + "criterio": "Noticias recientes con impacto estrategico, tecnico, regulatorio o de infraestructura para builders.", + "fuentes_consultadas": [ + "https://www.axios.com/2026/06/20/ai-tech-moguls-g7", + "https://www.theverge.com/ai-artificial-intelligence/951703/anthropic-shutdown-export-controls", + "https://deepmind.google/blog/securing-the-future-of-ai-agents/", + "https://apnews.com/article/power-electricity-ai-plants-data-centers-grid-506e3d206871111f15c3c62fc5368be5", + "https://www.theguardian.com/technology/2026/jun/20/europe-sleepwalking-ai-disaster-us-china" + ] + }, + "senales": [ + { + "id": "g7-ceos-ia-geopolitica", + "titulo": "Los CEOs de IA entran en la mesa geopolitica del G7", + "tema": "gobernanza", + "fuente": { + "nombre": "Axios", + "url": "https://www.axios.com/2026/06/20/ai-tech-moguls-g7", + "publicado": "2026-06-20", + "consultado": "2026-06-21" + }, + "evidencia": "En el G7, lideres de OpenAI, Google DeepMind, Anthropic, Meta y Mistral participaron en discusiones con jefes de Estado sobre control, reglas, estandares y seguridad de IA.", + "impacto": "La IA frontier deja de ser solo una carrera de producto y se consolida como asunto de seguridad, soberania y politica internacional.", + "accion": "Crear una watchlist de acuerdos G7, estandares internacionales y posiciones regulatorias de paises clave antes de elegir proveedores frontier.", + "estado": "alta_prioridad_activo" + }, + { + "id": "anthropic-export-controls-fable-mythos", + "titulo": "EE. UU. aplica controles de exportacion a modelos frontier de Anthropic", + "tema": "regulacion", + "fuente": { + "nombre": "The Verge", + "url": "https://www.theverge.com/ai-artificial-intelligence/951703/anthropic-shutdown-export-controls", + "publicado": "2026-06-17", + "consultado": "2026-06-21" + }, + "evidencia": "El gobierno estadounidense ordeno cortar el acceso de extranjeros a Fable 5 y Mythos 5; Anthropic termino bloqueando los modelos para todos mientras negociaba con la administracion.", + "impacto": "El acceso remoto a modelos alojados en la nube puede quedar sujeto a restricciones politicas o de seguridad nacional, incluso sin transferencia de pesos o codigo.", + "accion": "Mapear dependencias de modelos cerrados de EE. UU., preparar alternativas multi-proveedor y revisar riesgos para equipos o clientes internacionales.", + "estado": "riesgo_regulatorio_en_desarrollo" + }, + { + "id": "deepmind-control-agentes-autonomos", + "titulo": "Google DeepMind publica una hoja de ruta para controlar agentes autonomos", + "tema": "seguridad de agentes", + "fuente": { + "nombre": "Google DeepMind", + "url": "https://deepmind.google/blog/securing-the-future-of-ai-agents/", + "publicado": "2026-06-18", + "consultado": "2026-06-21" + }, + "evidencia": "DeepMind presento un AI Control Roadmap que trata a agentes internos como posibles amenazas, con monitoreo, permisos graduados y respuesta ante acciones riesgosas.", + "impacto": "Los agentes con herramientas y autonomia requieren controles de ciberseguridad, no solo prompts o politicas de uso.", + "accion": "Agregar logging, sandboxing, permisos minimos, revision humana para acciones criticas y mecanismos de apagado en cualquier flujo con agentes.", + "estado": "senal_tecnica_accionable" + }, + { + "id": "ferc-grid-data-centers-ia", + "titulo": "La red electrica se vuelve cuello de botella para data centers de IA", + "tema": "infraestructura", + "fuente": { + "nombre": "Associated Press", + "url": "https://apnews.com/article/power-electricity-ai-plants-data-centers-grid-506e3d206871111f15c3c62fc5368be5", + "publicado": "2026-06-18", + "consultado": "2026-06-21" + }, + "evidencia": "FERC ordeno a seis operadores regionales acelerar la conexion de grandes consumidores como data centers de IA y pidio planes en plazos de 30 a 60 dias.", + "impacto": "La disponibilidad y el costo de compute dependeran cada vez mas de energia, permisos, upgrades de red y oposicion local.", + "accion": "Rastrear regiones con capacidad energetica, politicas de interconexion y proveedores con acuerdos de energia propia o cargas flexibles.", + "estado": "infraestructura_critica_activo" + }, + { + "id": "europa-soberania-ia-europe-2031", + "titulo": "Europa acelera el debate de soberania en IA", + "tema": "soberania tecnologica", + "fuente": { + "nombre": "The Guardian", + "url": "https://www.theguardian.com/technology/2026/jun/20/europe-sleepwalking-ai-disaster-us-china", + "publicado": "2026-06-20", + "consultado": "2026-06-21" + }, + "evidencia": "El escenario Europe 2031 se volvio viral entre politicos europeos y conecto el caso Anthropic/Fable con el riesgo de depender de infraestructura y modelos estadounidenses.", + "impacto": "Puede crecer la presion por compute europeo, data centers locales, modelos soberanos y compras publicas con requisitos regionales.", + "accion": "Monitorear fondos, regulaciones y proveedores europeos de IA para identificar oportunidades de adopcion y compliance temprano.", + "estado": "estrategica_emergente" + } + ] +} From c0acc5350e8bf89e744d5fdbf6156ad689903a17 Mon Sep 17 00:00:00 2001 From: Juan Melo Date: Sun, 21 Jun 2026 15:56:39 +0200 Subject: [PATCH 3/9] chore: agregar subagentes de busqueda IA --- .agents/comunidad.yaml | 28 ++++++++++++++++++++++++++++ .agents/fuentes-oficiales.yaml | 29 +++++++++++++++++++++++++++++ .agents/medios-secundarios.yaml | 28 ++++++++++++++++++++++++++++ .agents/repo-tecnico.yaml | 29 +++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 .agents/comunidad.yaml create mode 100644 .agents/fuentes-oficiales.yaml create mode 100644 .agents/medios-secundarios.yaml create mode 100644 .agents/repo-tecnico.yaml diff --git a/.agents/comunidad.yaml b/.agents/comunidad.yaml new file mode 100644 index 0000000..5c34a83 --- /dev/null +++ b/.agents/comunidad.yaml @@ -0,0 +1,28 @@ +id: "ai-radar-comunidad" +display_name: "Comunidad" +agent_type: "default" +reasoning_effort: "medium" +source_type: "community" +scope: + include: + - "Hacker News, Reddit tecnico, foros de Hugging Face y GitHub discussions." + - "Blogs personales tecnicos, posts publicos y reportes de usuarios con reproduccion." + - "Incidentes, adopcion temprana, fricciones reales y feedback operativo." + exclude: + - "Rumores sin enlace o sin evidencia reproducible." + - "Opiniones que no indiquen impacto practico para builders." +instructions: >- + Busca senales recientes de IA nacidas en comunidad. Distingue hechos + verificables de testimonios u opiniones. Cuando una senal sea autoinformada, + marcala claramente y busca una segunda fuente tecnica u oficial si existe. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" diff --git a/.agents/fuentes-oficiales.yaml b/.agents/fuentes-oficiales.yaml new file mode 100644 index 0000000..28ecf61 --- /dev/null +++ b/.agents/fuentes-oficiales.yaml @@ -0,0 +1,29 @@ +id: "ai-radar-fuentes-oficiales" +display_name: "Fuentes oficiales" +agent_type: "default" +reasoning_effort: "high" +source_type: "official" +scope: + include: + - "Anuncios, blogs, changelogs y documentacion oficial de companias de IA." + - "Comunicados de organismos regulatorios, gobiernos, estandares y laboratorios." + - "Paginas primarias de producto, API, seguridad, compliance o investigacion." + exclude: + - "Cobertura secundaria sin enlace a fuente primaria." + - "Opinion, rumor o resumen no verificable." +instructions: >- + Busca senales recientes de IA en fuentes primarias. Prioriza lanzamientos de + modelos, cambios de API, regulacion, seguridad, agentes, compute, + herramientas developer y adopcion enterprise. Verifica fecha exacta, + deduplica anuncios repetidos y evita hype. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" diff --git a/.agents/medios-secundarios.yaml b/.agents/medios-secundarios.yaml new file mode 100644 index 0000000..5b8b276 --- /dev/null +++ b/.agents/medios-secundarios.yaml @@ -0,0 +1,28 @@ +id: "ai-radar-medios-secundarios" +display_name: "Medios secundarios" +agent_type: "default" +reasoning_effort: "medium" +source_type: "secondary_media" +scope: + include: + - "Medios tecnologicos, financieros y de negocio confiables." + - "Reportes sobre regulacion, chips, data centers, adopcion enterprise y mercado." + - "Contexto que complemente fuentes primarias o revele impacto estrategico." + exclude: + - "Notas sin fecha clara." + - "Contenido puramente promocional o sin evidencia enlazable." +instructions: >- + Busca reportes recientes de IA en medios secundarios. Prioriza informacion + con impacto para estrategia, producto o infraestructura. Marca siempre que la + fuente sea secundaria y, cuando sea posible, enlaza tambien la fuente primaria. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" diff --git a/.agents/repo-tecnico.yaml b/.agents/repo-tecnico.yaml new file mode 100644 index 0000000..28328d4 --- /dev/null +++ b/.agents/repo-tecnico.yaml @@ -0,0 +1,29 @@ +id: "ai-radar-repo-tecnico" +display_name: "Repo tecnico" +agent_type: "default" +reasoning_effort: "xhigh" +source_type: "technical_repository" +scope: + include: + - "Releases, changelogs, issues y discussions de repositorios tecnicos." + - "Model cards, datasets, papers, arXiv, Hugging Face y benchmarks." + - "Frameworks de inferencia, entrenamiento, agentes, evals y tooling developer." + exclude: + - "Repos sin evidencia tecnica reciente." + - "Papers sin artefacto, benchmark o consecuencia practica clara." +instructions: >- + Busca senales recientes de IA con evidencia tecnica verificable. Prioriza + cambios que afecten builders: compatibilidad, rendimiento, licencias, + migraciones, nuevos modelos, bugs bloqueantes y patrones emergentes. Revisa + fechas, estado del artefacto y riesgos de adopcion. +output: + language: "es" + fields: + - "titulo" + - "url" + - "fecha" + - "fuente" + - "evidencia" + - "impacto" + - "accion" + - "estado" From c76aaf150bea0ddbb81dad6e34f817db45136400 Mon Sep 17 00:00:00 2001 From: Juan Melo Date: Sun, 21 Jun 2026 15:57:30 +0200 Subject: [PATCH 4/9] Add agents .md + scritp + .json --- AGENTS.md | 50 ++++----- data/daily/2026-06-21.json | 62 +++++------ scripts/consultar_senales.py | 202 +++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 62 deletions(-) create mode 100644 scripts/consultar_senales.py diff --git a/AGENTS.md b/AGENTS.md index d0c28c2..1d74c11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,33 @@ -# AI Radar Agent Guide +# Guia del Repositorio -AI Radar is the course project for learning Codex with a real product surface. +## Estructura del Proyecto y Organizacion -The current repository state is intentionally small. Treat the README as product direction, not as proof that the full system already exists. +Este workspace contiene el starter clonado de AI Radar en `platzi-codex-clase-02-agents-md/`. Ese proyecto es intencionalmente pequeno: `README.md` define la direccion del producto, `AGENTS.md` define reglas para agentes y `.gitignore` excluye caches locales, secretos, datos generados y salidas de build. Todavia no existen `src/`, `tests/`, `assets/`, manifiesto de paquete ni runtime de aplicacion. Agrega nuevos directorios solo cuando el objetivo de la clase lo requiera. -## Current State +## Comandos de Build, Prueba y Desarrollo -- The project currently has a README and repository rules. -- The implementation is built class by class. -- Do not assume app files, scripts, databases, skills, deploy config, or automations exist until they are present in the repo. +Todavia no existen comandos de build o pruebas porque no hay implementacion de aplicacion ni `package.json`. Usa comandos de inspeccion mientras trabajas: -## Product Direction +```powershell +cd platzi-codex-clase-02-agents-md +git status --short +git log --oneline -5 +``` -AI Radar will collect AI news, papers, repos, tools, and launches, then turn them into verifiable signals for builders. +No documentes comandos como `npm test` o `npm run build` hasta que realmente existan. -The final system should support: +## Estilo de Codigo y Convenciones de Nombres -- source evidence; -- normalized signals; -- duplicate detection; -- ranking; -- practical action guides; -- an operator view; -- deploy and automation. +El contenido actual es solo Markdown. Manten encabezados claros, parrafos breves y nombres descriptivos para futuros archivos, por ejemplo `fixtures/signals.json` o `scripts/normalize-sources.js`. No incluyas en control de versiones salidas generadas, snapshots, grabaciones, credenciales ni bases de datos locales. -## Working Rules +## Guia de Pruebas -- Inspect the repo before editing. -- Keep changes scoped to the current class objective. -- Prefer small, reproducible files over chat-only state. -- Do not commit secrets, local caches, generated weekly snapshots, build output, videos, screenshots, or temporary reports. -- When a class creates a reusable process, prefer a skill. -- When a class creates deterministic work, prefer a tool or script. -- When adding data examples, use fixtures or contracts unless the class explicitly requires a durable seed. +No hay un framework de pruebas configurado todavia. Cuando se introduzcan modulos JavaScript, prefiere `node:test` para la logica de dominio. Cuando exista una interfaz visual, agrega verificaciones con Playwright para flujos de usuario. Los archivos de prueba deben vivir junto al comportamiento que verifican o dentro de un directorio `tests/` claro cuando la estructura este definida. -## Validation +## Guia de Commits y Pull Requests -For each class branch, leave a clear state: +El historial existente usa prefijos convencionales cortos como `docs:` y `chore:`. Manten ese estilo, por ejemplo `docs: aclarar objetivos de AI Radar` o `chore: actualizar reglas de ignore`. Los pull requests deben describir que cambio, como se verifico y que queda intencionalmente pendiente. Incluye capturas solo cuando exista una interfaz. -- what was added; -- how to verify it; -- what remains intentionally missing. +## Instrucciones Especificas para Agentes -If commands do not exist yet, do not invent them in docs as if they already work. +Inspecciona el repositorio antes de editar. Trata el README como direccion de producto, no como prueba de funcionalidades implementadas. Manten los cambios acotados a la leccion actual y evita inventar servicios, scripts, bases de datos o automatizaciones que no esten presentes. diff --git a/data/daily/2026-06-21.json b/data/daily/2026-06-21.json index 7362dc7..e262570 100644 --- a/data/daily/2026-06-21.json +++ b/data/daily/2026-06-21.json @@ -2,17 +2,17 @@ "$schema": "../../contracts/ai-radar-daily-signals.schema.json", "contrato": "ai-radar.daily-signals.v1", "fecha": "2026-06-21", - "generado_en": "2026-06-21T11:48:28.6948203+02:00", + "generado_en": "2026-06-21T12:07:40.0817391+02:00", "busqueda": { "consulta": "5 noticias recientes de IA como senales de AI Radar", "idioma": "es", "criterio": "Noticias recientes con impacto estrategico, tecnico, regulatorio o de infraestructura para builders.", "fuentes_consultadas": [ "https://www.axios.com/2026/06/20/ai-tech-moguls-g7", - "https://www.theverge.com/ai-artificial-intelligence/951703/anthropic-shutdown-export-controls", + "https://www.anthropic.com/news/fable-mythos-access", "https://deepmind.google/blog/securing-the-future-of-ai-agents/", "https://apnews.com/article/power-electricity-ai-plants-data-centers-grid-506e3d206871111f15c3c62fc5368be5", - "https://www.theguardian.com/technology/2026/jun/20/europe-sleepwalking-ai-disaster-us-china" + "https://openai.com/index/ai-chemist-improves-reaction/" ] }, "senales": [ @@ -26,29 +26,29 @@ "publicado": "2026-06-20", "consultado": "2026-06-21" }, - "evidencia": "En el G7, lideres de OpenAI, Google DeepMind, Anthropic, Meta y Mistral participaron en discusiones con jefes de Estado sobre control, reglas, estandares y seguridad de IA.", - "impacto": "La IA frontier deja de ser solo una carrera de producto y se consolida como asunto de seguridad, soberania y politica internacional.", - "accion": "Crear una watchlist de acuerdos G7, estandares internacionales y posiciones regulatorias de paises clave antes de elegir proveedores frontier.", + "evidencia": "Axios reporto que lideres de OpenAI, Google DeepMind, Anthropic, Meta, Mistral y otros participaron en conversaciones del G7 sobre control, reglas, estandares y seguridad de la IA.", + "impacto": "La IA frontier se esta tratando como infraestructura economica y de seguridad nacional, no solo como una categoria de producto.", + "accion": "Mantener una watchlist de acuerdos del G7, propuestas de estandarizacion y posiciones regulatorias antes de elegir proveedores frontier para productos criticos.", "estado": "alta_prioridad_activo" }, { - "id": "anthropic-export-controls-fable-mythos", - "titulo": "EE. UU. aplica controles de exportacion a modelos frontier de Anthropic", + "id": "anthropic-fable-mythos-control-exportacion", + "titulo": "Anthropic suspende Fable 5 y Mythos 5 por una directiva del gobierno de EE. UU.", "tema": "regulacion", "fuente": { - "nombre": "The Verge", - "url": "https://www.theverge.com/ai-artificial-intelligence/951703/anthropic-shutdown-export-controls", - "publicado": "2026-06-17", + "nombre": "Anthropic", + "url": "https://www.anthropic.com/news/fable-mythos-access", + "publicado": "2026-06-12", "consultado": "2026-06-21" }, - "evidencia": "El gobierno estadounidense ordeno cortar el acceso de extranjeros a Fable 5 y Mythos 5; Anthropic termino bloqueando los modelos para todos mientras negociaba con la administracion.", - "impacto": "El acceso remoto a modelos alojados en la nube puede quedar sujeto a restricciones politicas o de seguridad nacional, incluso sin transferencia de pesos o codigo.", - "accion": "Mapear dependencias de modelos cerrados de EE. UU., preparar alternativas multi-proveedor y revisar riesgos para equipos o clientes internacionales.", + "evidencia": "Anthropic dijo que una directiva de control de exportacion obligaba a suspender el acceso de extranjeros a Fable 5 y Mythos 5, y que la empresa deshabilito ambos modelos para todos sus clientes para cumplir.", + "impacto": "El acceso a modelos frontier alojados en la nube puede interrumpirse por decisiones regulatorias, incluso sin transferencia de pesos ni despliegues locales.", + "accion": "Mapear dependencias de modelos cerrados, definir alternativas multi-proveedor y documentar planes de continuidad para usuarios o clientes internacionales.", "estado": "riesgo_regulatorio_en_desarrollo" }, { - "id": "deepmind-control-agentes-autonomos", - "titulo": "Google DeepMind publica una hoja de ruta para controlar agentes autonomos", + "id": "deepmind-control-agentes-ia", + "titulo": "Google DeepMind publica una hoja de ruta para asegurar agentes de IA", "tema": "seguridad de agentes", "fuente": { "nombre": "Google DeepMind", @@ -56,13 +56,13 @@ "publicado": "2026-06-18", "consultado": "2026-06-21" }, - "evidencia": "DeepMind presento un AI Control Roadmap que trata a agentes internos como posibles amenazas, con monitoreo, permisos graduados y respuesta ante acciones riesgosas.", - "impacto": "Los agentes con herramientas y autonomia requieren controles de ciberseguridad, no solo prompts o politicas de uso.", - "accion": "Agregar logging, sandboxing, permisos minimos, revision humana para acciones criticas y mecanismos de apagado en cualquier flujo con agentes.", + "evidencia": "DeepMind publico su AI Control Roadmap, que trata agentes internos como posibles amenazas internas y combina monitoreo, permisos graduados, supervision de otros modelos y respuestas sincronas para acciones de mayor riesgo.", + "impacto": "Los equipos que construyen agentes con herramientas necesitan controles de seguridad operativa, no solo prompts, politicas de uso o evaluaciones offline.", + "accion": "Agregar logging, sandboxing, permisos minimos, aprobacion humana para acciones irreversibles y monitores de comportamiento en cualquier flujo agentico.", "estado": "senal_tecnica_accionable" }, { - "id": "ferc-grid-data-centers-ia", + "id": "ferc-red-electrica-data-centers-ia", "titulo": "La red electrica se vuelve cuello de botella para data centers de IA", "tema": "infraestructura", "fuente": { @@ -71,24 +71,24 @@ "publicado": "2026-06-18", "consultado": "2026-06-21" }, - "evidencia": "FERC ordeno a seis operadores regionales acelerar la conexion de grandes consumidores como data centers de IA y pidio planes en plazos de 30 a 60 dias.", - "impacto": "La disponibilidad y el costo de compute dependeran cada vez mas de energia, permisos, upgrades de red y oposicion local.", + "evidencia": "AP informo que FERC ordeno a seis operadores regionales acelerar la conexion de grandes usuarios como data centers de IA y pedir planes de respuesta en plazos de 30 a 60 dias.", + "impacto": "La disponibilidad, precio y latencia del compute dependeran cada vez mas de energia, permisos, interconexion de red y negociacion local.", "accion": "Rastrear regiones con capacidad energetica, politicas de interconexion y proveedores con acuerdos de energia propia o cargas flexibles.", "estado": "infraestructura_critica_activo" }, { - "id": "europa-soberania-ia-europe-2031", - "titulo": "Europa acelera el debate de soberania en IA", - "tema": "soberania tecnologica", + "id": "openai-quimico-autonomo-medicinal", + "titulo": "OpenAI muestra un agente casi autonomo que mejora una reaccion de quimica medicinal", + "tema": "investigacion aplicada", "fuente": { - "nombre": "The Guardian", - "url": "https://www.theguardian.com/technology/2026/jun/20/europe-sleepwalking-ai-disaster-us-china", - "publicado": "2026-06-20", + "nombre": "OpenAI", + "url": "https://openai.com/index/ai-chemist-improves-reaction/", + "publicado": "2026-06-17", "consultado": "2026-06-21" }, - "evidencia": "El escenario Europe 2031 se volvio viral entre politicos europeos y conecto el caso Anthropic/Fable con el riesgo de depender de infraestructura y modelos estadounidenses.", - "impacto": "Puede crecer la presion por compute europeo, data centers locales, modelos soberanos y compras publicas con requisitos regionales.", - "accion": "Monitorear fondos, regulaciones y proveedores europeos de IA para identificar oportunidades de adopcion y compliance temprano.", + "evidencia": "OpenAI reporto que GPT-5.4, conectado a Maria de Molecule.one, propuso y ejecuto ciclos experimentales que mejoraron rendimientos en una reaccion Chan-Lam para mas del 80% de los sustratos probados.", + "impacto": "La automatizacion cientifica esta pasando de demos de razonamiento a bucles de investigacion con laboratorio, datos experimentales y validacion humana.", + "accion": "Identificar flujos internos donde un agente pueda proponer experimentos, ejecutar tareas acotadas y producir resultados auditables con humanos en el ciclo.", "estado": "estrategica_emergente" } ] diff --git a/scripts/consultar_senales.py b/scripts/consultar_senales.py new file mode 100644 index 0000000..638ea48 --- /dev/null +++ b/scripts/consultar_senales.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Consulta senales desde snapshots diarios de AI Radar.""" + +from __future__ import annotations + +import argparse +import json +import sys +from datetime import date +from pathlib import Path +from typing import Any, Callable + + +ORDER_CHOICES = ( + "archivo", + "archivo_desc", + "publicado_asc", + "publicado_desc", + "titulo_asc", + "titulo_desc", + "estado_asc", + "estado_desc", + "id_asc", + "id_desc", +) + + +class QueryError(Exception): + """Error esperado al consultar snapshots locales.""" + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def non_negative_int(raw_value: str) -> int: + try: + value = int(raw_value) + except ValueError as exc: + raise argparse.ArgumentTypeError("debe ser un entero") from exc + + if value < 0: + raise argparse.ArgumentTypeError("debe ser mayor o igual a 0") + return value + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Devuelve N senales JSON desde un snapshot diario de AI Radar.", + ) + parser.add_argument( + "--dia", + "--day", + dest="dia", + metavar="YYYY-MM-DD", + help="Dia del snapshot. Si se omite, usa el snapshot mas reciente en data/daily.", + ) + parser.add_argument( + "-n", + "--cantidad", + "--count", + dest="cantidad", + type=non_negative_int, + default=5, + help="Cantidad maxima de senales a devolver. Default: 5.", + ) + parser.add_argument( + "--orden", + "--order", + dest="orden", + choices=ORDER_CHOICES, + default="archivo", + help="Orden de salida. Default: archivo.", + ) + parser.add_argument( + "--data-dir", + type=Path, + default=repo_root() / "data" / "daily", + help="Directorio de snapshots diarios. Default: data/daily.", + ) + parser.add_argument( + "--indent", + type=non_negative_int, + default=2, + help="Espacios de indentacion para el JSON. Usa 0 para salida compacta.", + ) + return parser.parse_args(argv) + + +def parse_day(raw_day: str) -> date: + try: + return date.fromisoformat(raw_day) + except ValueError as exc: + raise QueryError(f"dia invalido: {raw_day!r}; usa YYYY-MM-DD") from exc + + +def available_days(data_dir: Path) -> list[date]: + if not data_dir.exists(): + raise QueryError(f"no existe el directorio de snapshots: {data_dir}") + + days: list[date] = [] + for path in data_dir.glob("*.json"): + try: + days.append(parse_day(path.stem)) + except QueryError: + continue + return sorted(days) + + +def resolve_snapshot_path(data_dir: Path, raw_day: str | None) -> Path: + if raw_day: + selected_day = parse_day(raw_day) + else: + days = available_days(data_dir) + if not days: + raise QueryError(f"no hay snapshots diarios en {data_dir}") + selected_day = days[-1] + + snapshot_path = data_dir / f"{selected_day.isoformat()}.json" + if not snapshot_path.exists(): + raise QueryError(f"no existe snapshot para {selected_day.isoformat()}: {snapshot_path}") + return snapshot_path + + +def load_signals(snapshot_path: Path) -> list[dict[str, Any]]: + try: + with snapshot_path.open("r", encoding="utf-8") as file: + payload = json.load(file) + except json.JSONDecodeError as exc: + raise QueryError(f"JSON invalido en {snapshot_path}: {exc}") from exc + + if not isinstance(payload, dict): + raise QueryError(f"snapshot invalido en {snapshot_path}: la raiz debe ser un objeto") + + signals = payload.get("senales") + if not isinstance(signals, list): + raise QueryError(f"snapshot invalido en {snapshot_path}: falta lista 'senales'") + + normalized: list[dict[str, Any]] = [] + for index, signal in enumerate(signals, start=1): + if not isinstance(signal, dict): + raise QueryError(f"senal #{index} en {snapshot_path} no es un objeto") + normalized.append(signal) + return normalized + + +def nested_string(signal: dict[str, Any], *keys: str) -> str: + value: Any = signal + for key in keys: + if not isinstance(value, dict): + return "" + value = value.get(key) + return value if isinstance(value, str) else "" + + +def sort_signals(signals: list[dict[str, Any]], order: str) -> list[dict[str, Any]]: + if order == "archivo": + return list(signals) + if order == "archivo_desc": + return list(reversed(signals)) + + key_name, direction = order.rsplit("_", 1) + key_functions: dict[str, Callable[[dict[str, Any]], str]] = { + "publicado": lambda signal: nested_string(signal, "fuente", "publicado"), + "titulo": lambda signal: nested_string(signal, "titulo").casefold(), + "estado": lambda signal: nested_string(signal, "estado").casefold(), + "id": lambda signal: nested_string(signal, "id").casefold(), + } + + key_function = key_functions[key_name] + ordered = sorted( + enumerate(signals), + key=lambda item: (key_function(item[1]), item[0]), + ) + if direction == "desc": + ordered.reverse() + return [signal for _, signal in ordered] + + +def dump_json(signals: list[dict[str, Any]], indent: int) -> None: + json_indent: int | None = indent if indent > 0 else None + json.dump(signals, sys.stdout, ensure_ascii=False, indent=json_indent) + sys.stdout.write("\n") + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + try: + snapshot_path = resolve_snapshot_path(args.data_dir, args.dia) + signals = load_signals(snapshot_path) + ordered_signals = sort_signals(signals, args.orden) + dump_json(ordered_signals[: args.cantidad], args.indent) + except QueryError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From be8da629ff3e002123bad2ed0d2cd89f609ec58d Mon Sep 17 00:00:00 2001 From: Juan Melo Date: Sun, 21 Jun 2026 18:20:08 +0200 Subject: [PATCH 5/9] chore: agregar script de subagentes AI Radar --- scripts/llamar_subagentes.py | 617 +++++++++++++++++++++++++++++++++++ 1 file changed, 617 insertions(+) create mode 100644 scripts/llamar_subagentes.py diff --git a/scripts/llamar_subagentes.py b/scripts/llamar_subagentes.py new file mode 100644 index 0000000..9ce4efe --- /dev/null +++ b/scripts/llamar_subagentes.py @@ -0,0 +1,617 @@ +#!/usr/bin/env python3 +"""Genera llamadas de subagentes para busquedas de AI Radar.""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from datetime import UTC, date, datetime +from pathlib import Path +from typing import Any + + +DEFAULT_AGENT_ORDER = ( + "fuentes-oficiales.yaml", + "repo-tecnico.yaml", + "comunidad.yaml", + "medios-secundarios.yaml", +) + +REQUIRED_TOP_LEVEL_FIELDS = ( + "id", + "display_name", + "agent_type", + "reasoning_effort", + "source_type", + "scope", + "instructions", + "output", +) + +VALID_REASONING_EFFORTS = {"low", "medium", "high", "xhigh"} + + +class AgentConfigError(Exception): + """Error esperado al leer una configuracion de subagente.""" + + +@dataclass(frozen=True) +class AgentConfig: + path: Path + id: str + display_name: str + agent_type: str + reasoning_effort: str + source_type: str + include: list[str] + exclude: list[str] + instructions: str + language: str + fields: list[str] + + +@dataclass(frozen=True) +class SourcesCache: + path: Path + status: str + fallback_reason: str | None + generated_at: str | None + notion_database_url: str | None + groups: dict[str, list[dict[str, str]]] + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def non_negative_int(raw_value: str) -> int: + try: + value = int(raw_value) + except ValueError as exc: + raise argparse.ArgumentTypeError("debe ser un entero") from exc + + if value < 0: + raise argparse.ArgumentTypeError("debe ser mayor o igual a 0") + return value + + +def parse_day(raw_value: str) -> str: + try: + return date.fromisoformat(raw_value).isoformat() + except ValueError as exc: + raise argparse.ArgumentTypeError("usa YYYY-MM-DD") from exc + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Lee .agents/*.yaml y genera payloads para llamar subagentes " + "de AI Radar con multi_agent_v1.spawn_agent." + ), + ) + parser.add_argument( + "consulta", + nargs="?", + default="senales recientes de IA", + help="Consulta o objetivo de busqueda. Default: senales recientes de IA.", + ) + parser.add_argument( + "--agents-dir", + type=Path, + default=repo_root() / ".agents", + help="Directorio con configuraciones YAML. Default: .agents.", + ) + parser.add_argument( + "--sources-cache", + type=Path, + default=repo_root() / "config" / "sources.json", + help="Cache JSON de fuentes activas consultadas desde Notion. Default: config/sources.json.", + ) + parser.add_argument( + "--desde", + type=parse_day, + help="Inicio de ventana de busqueda en formato YYYY-MM-DD.", + ) + parser.add_argument( + "--hasta", + type=parse_day, + help="Fin de ventana de busqueda en formato YYYY-MM-DD.", + ) + parser.add_argument( + "-n", + "--cantidad", + type=non_negative_int, + default=5, + help="Candidatos a pedir por subagente. Default: 5.", + ) + parser.add_argument( + "--solo", + action="append", + default=[], + metavar="ID_O_ARCHIVO", + help="Filtra subagentes por id, nombre de archivo o stem. Puede repetirse.", + ) + parser.add_argument( + "--formato", + choices=("json", "markdown"), + default="json", + help="Formato de salida. Default: json.", + ) + parser.add_argument( + "--indent", + type=non_negative_int, + default=2, + help="Espacios de indentacion para JSON. Usa 0 para salida compacta.", + ) + return parser.parse_args(argv) + + +def scalar(raw_value: str) -> str: + value = raw_value.strip() + if len(value) >= 2 and value[0] == '"' and value[-1] == '"': + return value[1:-1] + return value + + +def indentation(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + +def fold_block(lines: list[str], start_index: int) -> tuple[str, int]: + block_lines: list[str] = [] + index = start_index + + while index < len(lines): + line = lines[index] + if line.strip() and indentation(line) == 0: + break + if line.strip(): + block_lines.append(line.strip()) + index += 1 + + return " ".join(block_lines), index + + +def parse_nested_block(lines: list[str], start_index: int) -> tuple[dict[str, Any], int]: + result: dict[str, Any] = {} + current_list: str | None = None + index = start_index + + while index < len(lines): + line = lines[index] + stripped = line.strip() + if not stripped: + index += 1 + continue + + indent = indentation(line) + if indent == 0: + break + + if indent == 2 and stripped.endswith(":"): + key = stripped[:-1] + result[key] = [] + current_list = key + elif indent == 2 and ":" in stripped: + key, raw_value = stripped.split(":", 1) + result[key] = scalar(raw_value) + current_list = None + elif indent == 4 and stripped.startswith("- "): + if current_list is None: + raise AgentConfigError(f"lista sin clave en linea {index + 1}") + result[current_list].append(scalar(stripped[2:])) + else: + raise AgentConfigError(f"YAML no soportado en linea {index + 1}: {line}") + + index += 1 + + return result, index + + +def parse_agent_yaml(path: Path) -> dict[str, Any]: + lines = path.read_text(encoding="utf-8").splitlines() + data: dict[str, Any] = {} + index = 0 + + while index < len(lines): + line = lines[index] + stripped = line.strip() + if not stripped or stripped.startswith("#"): + index += 1 + continue + + if indentation(line) != 0 or ":" not in stripped: + raise AgentConfigError(f"YAML no soportado en {path}: linea {index + 1}") + + key, raw_value = stripped.split(":", 1) + value = raw_value.strip() + index += 1 + + if value == ">-": + data[key], index = fold_block(lines, index) + elif value == "": + data[key], index = parse_nested_block(lines, index) + else: + data[key] = scalar(value) + + return data + + +def require_string(data: dict[str, Any], key: str, path: Path) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise AgentConfigError(f"{path}: falta string requerido '{key}'") + return value + + +def require_list(data: dict[str, Any], key: str, path: Path) -> list[str]: + value = data.get(key) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise AgentConfigError(f"{path}: falta lista requerida '{key}'") + return value + + +def load_agent(path: Path) -> AgentConfig: + raw = parse_agent_yaml(path) + missing = [key for key in REQUIRED_TOP_LEVEL_FIELDS if key not in raw] + if missing: + raise AgentConfigError(f"{path}: faltan campos requeridos: {', '.join(missing)}") + + scope = raw["scope"] + output = raw["output"] + if not isinstance(scope, dict): + raise AgentConfigError(f"{path}: 'scope' debe ser un objeto") + if not isinstance(output, dict): + raise AgentConfigError(f"{path}: 'output' debe ser un objeto") + + reasoning_effort = require_string(raw, "reasoning_effort", path) + if reasoning_effort not in VALID_REASONING_EFFORTS: + valid = ", ".join(sorted(VALID_REASONING_EFFORTS)) + raise AgentConfigError(f"{path}: reasoning_effort invalido {reasoning_effort!r}; usa {valid}") + + return AgentConfig( + path=path, + id=require_string(raw, "id", path), + display_name=require_string(raw, "display_name", path), + agent_type=require_string(raw, "agent_type", path), + reasoning_effort=reasoning_effort, + source_type=require_string(raw, "source_type", path), + include=require_list(scope, "include", path), + exclude=require_list(scope, "exclude", path), + instructions=require_string(raw, "instructions", path), + language=require_string(output, "language", path), + fields=require_list(output, "fields", path), + ) + + +def discover_agent_paths(agents_dir: Path) -> list[Path]: + if not agents_dir.exists(): + raise AgentConfigError(f"no existe el directorio de subagentes: {agents_dir}") + + paths = list(agents_dir.glob("*.yaml")) + if not paths: + raise AgentConfigError(f"no hay configuraciones *.yaml en {agents_dir}") + + order = {name: index for index, name in enumerate(DEFAULT_AGENT_ORDER)} + return sorted(paths, key=lambda path: (order.get(path.name, len(order)), path.name)) + + +def selected(agent: AgentConfig, filters: list[str]) -> bool: + if not filters: + return True + + aliases = { + agent.id.casefold(), + agent.path.name.casefold(), + agent.path.stem.casefold(), + agent.display_name.casefold(), + } + return any(item.casefold() in aliases for item in filters) + + +def relative_to_repo(path: Path) -> str: + try: + return str(path.relative_to(repo_root())) + except ValueError: + return str(path) + + +def as_string(value: Any) -> str: + return value if isinstance(value, str) else "" + + +def source_item(raw: dict[str, Any]) -> dict[str, str] | None: + name = as_string(raw.get("name")).strip() + url = as_string(raw.get("url")).strip() + if not name or not url: + return None + + source: dict[str, str] = {"name": name, "url": url} + for key in ("type", "status", "priority", "cadence", "notes", "notion_page_url"): + value = as_string(raw.get(key)).strip() + if value: + source[key] = value + return source + + +def load_sources_cache(path: Path) -> SourcesCache: + if not path.exists(): + return SourcesCache( + path=path, + status="missing", + fallback_reason=f"no existe {relative_to_repo(path)}; usar scope YAML por subagente", + generated_at=None, + notion_database_url=None, + groups={}, + ) + + try: + with path.open("r", encoding="utf-8") as file: + payload = json.load(file) + except OSError as exc: + return SourcesCache( + path=path, + status="unreadable", + fallback_reason=f"no se pudo leer el cache: {exc}", + generated_at=None, + notion_database_url=None, + groups={}, + ) + except json.JSONDecodeError as exc: + return SourcesCache( + path=path, + status="invalid_json", + fallback_reason=f"JSON invalido en cache: {exc}", + generated_at=None, + notion_database_url=None, + groups={}, + ) + + if not isinstance(payload, dict): + return SourcesCache( + path=path, + status="invalid_shape", + fallback_reason="la raiz del cache debe ser un objeto", + generated_at=None, + notion_database_url=None, + groups={}, + ) + + raw_groups = payload.get("fuentes_por_subagente") + if not isinstance(raw_groups, dict): + return SourcesCache( + path=path, + status="invalid_shape", + fallback_reason="falta objeto fuentes_por_subagente", + generated_at=as_string(payload.get("generado_en")) or None, + notion_database_url=as_string(payload.get("notion_database_url")) or None, + groups={}, + ) + + groups: dict[str, list[dict[str, str]]] = {} + for agent_id, raw_sources in raw_groups.items(): + if not isinstance(agent_id, str) or not isinstance(raw_sources, list): + continue + + active_sources: list[dict[str, str]] = [] + for raw_source in raw_sources: + if not isinstance(raw_source, dict): + continue + status = as_string(raw_source.get("status")).strip().casefold() + if status and status != "activa": + continue + source = source_item(raw_source) + if source: + active_sources.append(source) + + groups[agent_id] = active_sources + + return SourcesCache( + path=path, + status="loaded", + fallback_reason=None, + generated_at=as_string(payload.get("generado_en")) or None, + notion_database_url=as_string(payload.get("notion_database_url")) or None, + groups=groups, + ) + + +def search_window(args: argparse.Namespace) -> str: + if args.desde and args.hasta: + return f"desde {args.desde} hasta {args.hasta}" + if args.desde: + return f"desde {args.desde}" + if args.hasta: + return f"hasta {args.hasta}" + return "reciente" + + +def format_sources(sources: list[dict[str, str]]) -> str: + if not sources: + return ( + "- No hay fuentes activas configuradas para este subagente en " + "config/sources.json. Usa el scope YAML como fallback y reportalo." + ) + + lines = [] + for source in sources: + details = [source["url"]] + if source.get("priority"): + details.append(f"prioridad={source['priority']}") + if source.get("cadence"): + details.append(f"cadencia={source['cadence']}") + if source.get("notes"): + details.append(f"notas={source['notes']}") + lines.append(f"- {source['name']}: " + "; ".join(details)) + return "\n".join(lines) + + +def build_agent_message( + agent: AgentConfig, + args: argparse.Namespace, + sources: list[dict[str, str]], + sources_cache: SourcesCache, +) -> str: + include = "\n".join(f"- {item}" for item in agent.include) + exclude = "\n".join(f"- {item}" for item in agent.exclude) + fields = ", ".join(agent.fields) + today = date.today().isoformat() + source_block = format_sources(sources) + cache_status = sources_cache.status + + return ( + f"Eres el subagente de {agent.display_name} para AI Radar.\n" + f"Fecha actual: {today}.\n" + f"Consulta: {args.consulta}.\n" + f"Ventana de busqueda: {search_window(args)}.\n" + f"Candidatos esperados: {args.cantidad}.\n" + f"Tipo de fuente: {agent.source_type}.\n\n" + "Fuentes activas asignadas desde cache local:\n" + f"{source_block}\n" + f"Estado del cache de fuentes: {cache_status}.\n\n" + "Incluye:\n" + f"{include}\n\n" + "Excluye:\n" + f"{exclude}\n\n" + "Instrucciones:\n" + f"{agent.instructions}\n\n" + "Fallback obligatorio:\n" + "- Si una fuente asignada no responde, no tiene contenido reciente o no se puede verificar, " + "continua con fuentes equivalentes dentro del scope del subagente.\n" + "- Reporta cada fallback con fuente_original, motivo y fuente_usada.\n" + "- Si el cache no esta disponible o no trae fuentes para este subagente, indicalo " + "explicitamente y usa el scope YAML como fallback.\n\n" + f"Devuelve la respuesta en {agent.language}. " + f"Cada candidato debe incluir estos campos: {fields}. " + "Usa fechas exactas, URLs verificables y marca claramente incertidumbre, " + "rumor o evidencia secundaria." + ) + + +def fallback_plan(sources: list[dict[str, str]], sources_cache: SourcesCache) -> dict[str, Any]: + if sources: + reason = "reportar solo si una fuente asignada no responde" + else: + reason = sources_cache.fallback_reason or "sin fuentes activas para este subagente" + + return { + "reportar_si_no_responde": True, + "motivo": reason, + "fallback": "usar fuentes equivalentes dentro del scope YAML del subagente", + } + + +def build_spawn_plan(agent: AgentConfig, args: argparse.Namespace, sources_cache: SourcesCache) -> dict[str, Any]: + sources = sources_cache.groups.get(agent.id, []) + message = build_agent_message(agent, args, sources, sources_cache) + return { + "id": agent.id, + "display_name": agent.display_name, + "config_path": relative_to_repo(agent.path), + "source_type": agent.source_type, + "reasoning_effort": agent.reasoning_effort, + "fuentes_configuradas": sources, + "fallback": fallback_plan(sources, sources_cache), + "spawn_agent": { + "tool": "multi_agent_v1.spawn_agent", + "parameters": { + "agent_type": agent.agent_type, + "reasoning_effort": agent.reasoning_effort, + "fork_context": False, + "message": message, + }, + }, + } + + +def build_plan(agents: list[AgentConfig], args: argparse.Namespace, sources_cache: SourcesCache) -> dict[str, Any]: + now = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + return { + "contrato": "ai-radar.subagents.spawn-plan.v1", + "generado_en": now, + "consulta": args.consulta, + "ventana": { + "desde": args.desde, + "hasta": args.hasta, + "descripcion": search_window(args), + }, + "cantidad_por_subagente": args.cantidad, + "agents_dir": relative_to_repo(args.agents_dir), + "sources_cache": { + "path": relative_to_repo(sources_cache.path), + "status": sources_cache.status, + "generated_at": sources_cache.generated_at, + "notion_database_url": sources_cache.notion_database_url, + "fallback_reason": sources_cache.fallback_reason, + }, + "subagentes": [build_spawn_plan(agent, args, sources_cache) for agent in agents], + "siguiente_paso": ( + "Ejecuta cada objeto subagentes[].spawn_agent como una llamada " + "multi_agent_v1.spawn_agent en paralelo, espera resultados, " + "deduplica y normaliza las senales. Si un subagente no responde " + "o reporta fallback, incluyelo en el resumen final." + ), + } + + +def dump_json(plan: dict[str, Any], indent: int) -> None: + json_indent: int | None = indent if indent > 0 else None + json.dump(plan, sys.stdout, ensure_ascii=False, indent=json_indent) + sys.stdout.write("\n") + + +def dump_markdown(plan: dict[str, Any]) -> None: + print(f"# Plan de subagentes: {plan['consulta']}") + print() + print(f"- Ventana: {plan['ventana']['descripcion']}") + print(f"- Candidatos por subagente: {plan['cantidad_por_subagente']}") + print(f"- Cache de fuentes: `{plan['sources_cache']['path']}` ({plan['sources_cache']['status']})") + print() + + for agent in plan["subagentes"]: + parameters = agent["spawn_agent"]["parameters"] + print(f"## {agent['display_name']}") + print() + print(f"- Config: `{agent['config_path']}`") + print(f"- Reasoning: `{agent['reasoning_effort']}`") + print(f"- Tipo de fuente: `{agent['source_type']}`") + print(f"- Fuentes configuradas: {len(agent['fuentes_configuradas'])}") + print(f"- Fallback: {agent['fallback']['motivo']}") + print() + print("```text") + print(parameters["message"]) + print("```") + print() + + print("Siguiente paso: llamar `multi_agent_v1.spawn_agent` con cada payload.") + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + try: + paths = discover_agent_paths(args.agents_dir) + agents = [load_agent(path) for path in paths] + filtered_agents = [agent for agent in agents if selected(agent, args.solo)] + if not filtered_agents: + filters = ", ".join(args.solo) + raise AgentConfigError(f"ningun subagente coincide con: {filters}") + + sources_cache = load_sources_cache(args.sources_cache) + plan = build_plan(filtered_agents, args, sources_cache) + except AgentConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + if args.formato == "markdown": + dump_markdown(plan) + else: + dump_json(plan, args.indent) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From d32608d9ec65375253d6aa1665388d8867d27333 Mon Sep 17 00:00:00 2001 From: Juan Melo Date: Sun, 21 Jun 2026 18:20:18 +0200 Subject: [PATCH 6/9] feat: agregar API Supabase para AI Radar --- .env.example | 5 + app/api/runs/[id]/route.js | 27 + app/api/runs/route.js | 38 + app/api/signals/route.js | 31 + app/api/sources/sync/route.js | 38 + lib/api/auth.js | 46 + lib/supabase/client.js | 17 + lib/supabase/runs.js | 90 ++ lib/supabase/signals.js | 38 + lib/supabase/sources.js | 98 ++ lib/validation.js | 299 +++++ next.config.mjs | 13 + package-lock.json | 1019 +++++++++++++++++ package.json | 24 + scripts/persist_daily_snapshot.js | 34 + scripts/sync_sources.js | 29 + .../20260621160537_init_ai_radar_core.sql | 82 ++ tests/endpoints.test.js | 42 + tests/validation.test.js | 67 ++ 19 files changed, 2037 insertions(+) create mode 100644 .env.example create mode 100644 app/api/runs/[id]/route.js create mode 100644 app/api/runs/route.js create mode 100644 app/api/signals/route.js create mode 100644 app/api/sources/sync/route.js create mode 100644 lib/api/auth.js create mode 100644 lib/supabase/client.js create mode 100644 lib/supabase/runs.js create mode 100644 lib/supabase/signals.js create mode 100644 lib/supabase/sources.js create mode 100644 lib/validation.js create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/persist_daily_snapshot.js create mode 100644 scripts/sync_sources.js create mode 100644 supabase/migrations/20260621160537_init_ai_radar_core.sql create mode 100644 tests/endpoints.test.js create mode 100644 tests/validation.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ed8104 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +AI_RADAR_API_TOKEN= +AI_RADAR_API_BASE_URL=http://localhost:3000 +NEXT_PUBLIC_APP_ENV=development diff --git a/app/api/runs/[id]/route.js b/app/api/runs/[id]/route.js new file mode 100644 index 0000000..9fb794c --- /dev/null +++ b/app/api/runs/[id]/route.js @@ -0,0 +1,27 @@ +import { jsonError, requireBearerToken } from "../../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../../lib/supabase/client.js"; +import { getRunWithSignals } from "../../../../lib/supabase/runs.js"; +import { validationIssues, uuidSchema } from "../../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function GET(request, context) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + const params = await context.params; + const result = uuidSchema.safeParse(params.id); + if (!result.success) { + return jsonError(400, "invalid_run_id", "id de run invalido", validationIssues(result.error)); + } + + try { + const supabase = getSupabaseAdmin(); + const payload = await getRunWithSignals(supabase, result.data); + return Response.json(payload); + } catch (error) { + return jsonError(500, "get_run_failed", error.message); + } +} diff --git a/app/api/runs/route.js b/app/api/runs/route.js new file mode 100644 index 0000000..7f77f30 --- /dev/null +++ b/app/api/runs/route.js @@ -0,0 +1,38 @@ +import { jsonError, requireBearerToken } from "../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../lib/supabase/client.js"; +import { saveRun } from "../../../lib/supabase/runs.js"; +import { PayloadValidationError, normalizeRunPayload } from "../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function POST(request) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + let body; + try { + body = await request.json(); + } catch { + return jsonError(400, "invalid_json", "el body debe ser JSON valido"); + } + + let run; + try { + run = normalizeRunPayload(body); + } catch (error) { + if (error instanceof PayloadValidationError) { + return jsonError(400, "invalid_payload", error.message, error.issues); + } + throw error; + } + + try { + const supabase = getSupabaseAdmin(); + const result = await saveRun(supabase, run); + return Response.json(result, { status: 201 }); + } catch (error) { + return jsonError(500, "save_run_failed", error.message); + } +} diff --git a/app/api/signals/route.js b/app/api/signals/route.js new file mode 100644 index 0000000..3b4f153 --- /dev/null +++ b/app/api/signals/route.js @@ -0,0 +1,31 @@ +import { jsonError, requireBearerToken } from "../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../lib/supabase/client.js"; +import { listSignals } from "../../../lib/supabase/signals.js"; +import { PayloadValidationError, normalizeSignalQuery } from "../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function GET(request) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + let query; + try { + query = normalizeSignalQuery(new URL(request.url).searchParams); + } catch (error) { + if (error instanceof PayloadValidationError) { + return jsonError(400, "invalid_query", error.message, error.issues); + } + throw error; + } + + try { + const supabase = getSupabaseAdmin(); + const signals = await listSignals(supabase, query); + return Response.json({ signals, count: signals.length }); + } catch (error) { + return jsonError(500, "list_signals_failed", error.message); + } +} diff --git a/app/api/sources/sync/route.js b/app/api/sources/sync/route.js new file mode 100644 index 0000000..59ee06c --- /dev/null +++ b/app/api/sources/sync/route.js @@ -0,0 +1,38 @@ +import { jsonError, requireBearerToken } from "../../../../lib/api/auth.js"; +import { getSupabaseAdmin } from "../../../../lib/supabase/client.js"; +import { syncSources } from "../../../../lib/supabase/sources.js"; +import { PayloadValidationError, normalizeSourcesPayload } from "../../../../lib/validation.js"; + +export const runtime = "nodejs"; + +export async function POST(request) { + const auth = requireBearerToken(request); + if (!auth.ok) { + return auth.response; + } + + let body; + try { + body = await request.json(); + } catch { + return jsonError(400, "invalid_json", "el body debe ser JSON valido"); + } + + let sources; + try { + sources = normalizeSourcesPayload(body); + } catch (error) { + if (error instanceof PayloadValidationError) { + return jsonError(400, "invalid_payload", error.message, error.issues); + } + throw error; + } + + try { + const supabase = getSupabaseAdmin(); + const synced = await syncSources(supabase, sources); + return Response.json({ synced_count: synced.length }); + } catch (error) { + return jsonError(500, "sync_sources_failed", error.message); + } +} diff --git a/lib/api/auth.js b/lib/api/auth.js new file mode 100644 index 0000000..4dd44e4 --- /dev/null +++ b/lib/api/auth.js @@ -0,0 +1,46 @@ +import { timingSafeEqual } from "node:crypto"; + +export function jsonError(status, code, message, details) { + return Response.json( + { + error: { + code, + message, + ...(details ? { details } : {}), + }, + }, + { status }, + ); +} + +export function requireBearerToken(request) { + const expectedToken = process.env.AI_RADAR_API_TOKEN; + if (!expectedToken) { + return { + ok: false, + response: jsonError(500, "server_misconfigured", "AI_RADAR_API_TOKEN no esta configurado"), + }; + } + + const authorization = request.headers.get("authorization") ?? ""; + const match = authorization.match(/^Bearer\s+(.+)$/i); + const receivedToken = match?.[1] ?? ""; + + if (!receivedToken || !safeTokenEquals(receivedToken, expectedToken)) { + return { + ok: false, + response: jsonError(401, "unauthorized", "token invalido"), + }; + } + + return { ok: true }; +} + +function safeTokenEquals(receivedToken, expectedToken) { + const received = Buffer.from(receivedToken); + const expected = Buffer.from(expectedToken); + if (received.length !== expected.length) { + return false; + } + return timingSafeEqual(received, expected); +} diff --git a/lib/supabase/client.js b/lib/supabase/client.js new file mode 100644 index 0000000..1fea395 --- /dev/null +++ b/lib/supabase/client.js @@ -0,0 +1,17 @@ +import { createClient } from "@supabase/supabase-js"; + +export function getSupabaseAdmin() { + const supabaseUrl = process.env.SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !serviceRoleKey) { + throw new Error("SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY son requeridos"); + } + + return createClient(supabaseUrl, serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); +} diff --git a/lib/supabase/runs.js b/lib/supabase/runs.js new file mode 100644 index 0000000..c6e5ec7 --- /dev/null +++ b/lib/supabase/runs.js @@ -0,0 +1,90 @@ +import { findOrCreateSourceForSignal } from "./sources.js"; + +export async function saveRun(supabase, run) { + const runRow = { + query: run.query, + window_start: run.window_start, + window_end: run.window_end, + status: run.status, + sources_cache_status: run.sources_cache_status, + fallback_report: run.fallback_report, + generated_at: run.generated_at, + }; + + const { data: createdRun, error: runError } = await supabase + .from("runs") + .insert(runRow) + .select("id") + .single(); + + if (runError) { + throw new Error(`no se pudo guardar run: ${runError.message}`); + } + + try { + const signalRows = []; + for (const signal of run.signals) { + const sourceId = await findOrCreateSourceForSignal(supabase, signal); + signalRows.push(signalToRow(createdRun.id, sourceId, signal)); + } + + const { error: signalsError } = await supabase.from("signals").insert(signalRows); + if (signalsError) { + throw new Error(`no se pudieron guardar signals: ${signalsError.message}`); + } + + return { + run_id: createdRun.id, + signals_count: signalRows.length, + }; + } catch (error) { + await supabase.from("runs").delete().eq("id", createdRun.id); + throw error; + } +} + +export async function getRunWithSignals(supabase, runId) { + const { data: run, error: runError } = await supabase + .from("runs") + .select("id, query, window_start, window_end, status, sources_cache_status, fallback_report, generated_at") + .eq("id", runId) + .single(); + + if (runError) { + throw new Error(`no se pudo consultar run: ${runError.message}`); + } + + const { data: signals, error: signalsError } = await supabase + .from("signals") + .select( + "id, run_id, source_id, slug, title, topic, source_name, source_url, published_on, consulted_on, evidence, impact, action, status, raw", + ) + .eq("run_id", runId) + .order("published_on", { ascending: false }) + .order("consulted_on", { ascending: false }); + + if (signalsError) { + throw new Error(`no se pudieron consultar signals: ${signalsError.message}`); + } + + return { run, signals }; +} + +function signalToRow(runId, sourceId, signal) { + return { + run_id: runId, + source_id: sourceId, + slug: signal.slug, + title: signal.title, + topic: signal.topic, + source_name: signal.source_name, + source_url: signal.source_url, + published_on: signal.published_on, + consulted_on: signal.consulted_on, + evidence: signal.evidence, + impact: signal.impact, + action: signal.action, + status: signal.status, + raw: signal.raw, + }; +} diff --git a/lib/supabase/signals.js b/lib/supabase/signals.js new file mode 100644 index 0000000..b7a8be7 --- /dev/null +++ b/lib/supabase/signals.js @@ -0,0 +1,38 @@ +export async function listSignals(supabase, query) { + let builder = supabase + .from("signals") + .select( + "id, run_id, source_id, slug, title, topic, source_name, source_url, published_on, consulted_on, evidence, impact, action, status", + ); + + if (query.fecha) { + builder = builder.eq("consulted_on", query.fecha); + } + + if (query.source_type) { + const sourceIds = await sourceIdsByType(supabase, query.source_type); + if (sourceIds.length === 0) { + return []; + } + builder = builder.in("source_id", sourceIds); + } + + const { data, error } = await builder + .order("published_on", { ascending: false }) + .order("consulted_on", { ascending: false }) + .limit(query.limit); + + if (error) { + throw new Error(`no se pudieron consultar signals: ${error.message}`); + } + + return data; +} + +async function sourceIdsByType(supabase, sourceType) { + const { data, error } = await supabase.from("sources").select("id").eq("type", sourceType); + if (error) { + throw new Error(`no se pudieron consultar sources por tipo: ${error.message}`); + } + return data.map((source) => source.id); +} diff --git a/lib/supabase/sources.js b/lib/supabase/sources.js new file mode 100644 index 0000000..eee9775 --- /dev/null +++ b/lib/supabase/sources.js @@ -0,0 +1,98 @@ +export async function syncSources(supabase, sources) { + const synced = []; + + for (const source of sources) { + synced.push(await upsertSource(supabase, source)); + } + + return synced; +} + +export async function findOrCreateSourceForSignal(supabase, signal) { + const existing = await findSource(supabase, signal.source); + if (existing) { + return existing.id; + } + + const row = sourceToRow({ + ...signal.source, + name: signal.source_name, + url: signal.source_url, + source_of_truth: signal.source?.source_of_truth ?? "signal", + }); + const { data, error } = await supabase.from("sources").insert(row).select("id").single(); + if (error) { + throw new Error(`no se pudo crear source: ${error.message}`); + } + return data.id; +} + +async function upsertSource(supabase, source) { + const existing = await findSource(supabase, source); + const row = sourceToRow(source); + + if (existing) { + const { data, error } = await supabase + .from("sources") + .update(row) + .eq("id", existing.id) + .select("id") + .single(); + if (error) { + throw new Error(`no se pudo actualizar source: ${error.message}`); + } + return data; + } + + const { data, error } = await supabase.from("sources").insert(row).select("id").single(); + if (error) { + throw new Error(`no se pudo insertar source: ${error.message}`); + } + return data; +} + +async function findSource(supabase, source) { + if (source?.notion_page_url) { + const { data, error } = await supabase + .from("sources") + .select("id") + .eq("notion_page_url", source.notion_page_url) + .maybeSingle(); + if (error) { + throw new Error(`no se pudo buscar source por Notion: ${error.message}`); + } + if (data) { + return data; + } + } + + if (!source?.url) { + return null; + } + + const { data, error } = await supabase + .from("sources") + .select("id") + .eq("url", source.url) + .limit(1) + .maybeSingle(); + if (error) { + throw new Error(`no se pudo buscar source por URL: ${error.message}`); + } + return data; +} + +function sourceToRow(source) { + return { + notion_page_url: source.notion_page_url ?? null, + name: source.name, + type: source.type ?? null, + url: source.url, + status: source.status ?? null, + priority: source.priority ?? null, + cadence: source.cadence ?? null, + notes: source.notes ?? null, + source_of_truth: source.source_of_truth ?? "notion", + synced_at: new Date().toISOString(), + }; +} diff --git a/lib/validation.js b/lib/validation.js new file mode 100644 index 0000000..4c3b03a --- /dev/null +++ b/lib/validation.js @@ -0,0 +1,299 @@ +import { z } from "zod"; + +export const SOURCE_TYPES = [ + "fuente_oficial", + "repo_tecnico", + "comunidad", + "medios_secundario", +]; + +export const SOURCE_STATUSES = ["activa", "pausada", "descartada"]; + +const SIGNAL_STATUSES = [ + "alta_prioridad_activo", + "riesgo_regulatorio_en_desarrollo", + "senal_tecnica_accionable", + "infraestructura_critica_activo", + "estrategica_emergente", + "observacion", +]; + +const slugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); +const nonEmptyString = z.string().trim().min(1); +const optionalText = z + .string() + .trim() + .optional() + .nullable() + .transform((value) => (value ? value : null)); + +export const dateStringSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => { + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; + }, "usa YYYY-MM-DD"); + +const dateTimeStringSchema = z.string().refine((value) => !Number.isNaN(Date.parse(value)), { + message: "usa una fecha ISO valida", +}); + +const urlSchema = z.string().url(); + +export class PayloadValidationError extends Error { + constructor(message, issues) { + super(message); + this.name = "PayloadValidationError"; + this.issues = issues; + } +} + +export function validationIssues(error) { + return error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); +} + +const sourceSchema = z + .object({ + notion_page_url: urlSchema.optional().nullable(), + name: nonEmptyString, + type: z.enum(SOURCE_TYPES).optional().nullable(), + url: urlSchema, + status: z.enum(SOURCE_STATUSES).optional().nullable(), + priority: optionalText, + cadence: optionalText, + notes: optionalText, + }) + .passthrough(); + +const localSignalSchema = z + .object({ + id: slugSchema, + titulo: nonEmptyString, + tema: nonEmptyString, + fuente: z + .object({ + nombre: nonEmptyString, + url: urlSchema, + publicado: dateStringSchema, + consultado: dateStringSchema, + }) + .strict(), + evidencia: nonEmptyString, + impacto: nonEmptyString, + accion: nonEmptyString, + estado: z.enum(SIGNAL_STATUSES), + }) + .strict(); + +const dailySnapshotSchema = z + .object({ + $schema: z.string(), + contrato: z.literal("ai-radar.daily-signals.v1"), + fecha: dateStringSchema, + generado_en: dateTimeStringSchema, + busqueda: z + .object({ + consulta: nonEmptyString, + idioma: z.string().trim().min(2), + criterio: nonEmptyString, + fuentes_consultadas: z.array(urlSchema).min(1), + }) + .strict(), + senales: z.array(localSignalSchema).min(1), + sources_cache_status: optionalText, + fallback_report: z.array(z.unknown()).optional().default([]), + }) + .strict(); + +const nativeSignalSchema = z + .object({ + slug: slugSchema, + title: nonEmptyString, + topic: optionalText, + source_name: nonEmptyString, + source_url: urlSchema, + source_type: z.enum(SOURCE_TYPES).optional().nullable(), + published_on: dateStringSchema.optional().nullable(), + consulted_on: dateStringSchema, + evidence: nonEmptyString, + impact: nonEmptyString, + action: nonEmptyString, + status: nonEmptyString, + raw: z.unknown().optional(), + source: sourceSchema.optional(), + }) + .strict(); + +const nativeRunSchema = z + .object({ + query: nonEmptyString, + window_start: dateStringSchema.optional().nullable(), + window_end: dateStringSchema.optional().nullable(), + status: z.enum(["completed", "failed"]).optional().default("completed"), + sources_cache_status: optionalText, + fallback_report: z.array(z.unknown()).optional().default([]), + generated_at: dateTimeStringSchema.optional(), + signals: z.array(nativeSignalSchema).min(1), + }) + .strict(); + +const sourcesCacheSchema = z + .object({ + fuentes_por_subagente: z.record(z.string(), z.array(sourceSchema)), + }) + .passthrough(); + +const signalQuerySchema = z.object({ + fecha: z.preprocess( + (value) => (value === null || value === "" ? undefined : value), + dateStringSchema.optional(), + ), + source_type: z.preprocess( + (value) => (value === null || value === "" ? undefined : value), + z.enum(SOURCE_TYPES).optional(), + ), + limit: z.preprocess( + (value) => (value === null || value === "" ? undefined : value), + z.coerce.number().int().min(1).max(100).default(20), + ), +}); + +export const uuidSchema = z.string().uuid(); + +export function normalizeRunPayload(rawPayload) { + const localResult = dailySnapshotSchema.safeParse(rawPayload); + if (localResult.success) { + return normalizeDailySnapshot(localResult.data); + } + + const nativeResult = nativeRunSchema.safeParse(rawPayload); + if (nativeResult.success) { + return normalizeNativeRun(nativeResult.data); + } + + throw new PayloadValidationError("payload de run invalido", validationIssues(localResult.error)); +} + +export function normalizeDailySnapshot(snapshot) { + return { + query: snapshot.busqueda.consulta, + window_start: snapshot.fecha, + window_end: snapshot.fecha, + status: "completed", + sources_cache_status: snapshot.sources_cache_status, + fallback_report: snapshot.fallback_report, + generated_at: snapshot.generado_en, + signals: snapshot.senales.map((signal) => ({ + slug: signal.id, + title: signal.titulo, + topic: signal.tema, + source_name: signal.fuente.nombre, + source_url: signal.fuente.url, + published_on: signal.fuente.publicado, + consulted_on: signal.fuente.consultado, + evidence: signal.evidencia, + impact: signal.impacto, + action: signal.accion, + status: signal.estado, + raw: signal, + source: { + name: signal.fuente.nombre, + url: signal.fuente.url, + source_of_truth: "signal", + }, + })), + }; +} + +function normalizeNativeRun(run) { + return { + query: run.query, + window_start: run.window_start ?? null, + window_end: run.window_end ?? null, + status: run.status, + sources_cache_status: run.sources_cache_status, + fallback_report: run.fallback_report, + generated_at: run.generated_at, + signals: run.signals.map((signal) => ({ + slug: signal.slug, + title: signal.title, + topic: signal.topic ?? null, + source_name: signal.source_name, + source_url: signal.source_url, + published_on: signal.published_on ?? null, + consulted_on: signal.consulted_on, + evidence: signal.evidence, + impact: signal.impact, + action: signal.action, + status: signal.status, + raw: signal.raw ?? signal, + source: signal.source + ? normalizeSource(signal.source) + : { + name: signal.source_name, + url: signal.source_url, + type: signal.source_type ?? null, + source_of_truth: "signal", + }, + })), + }; +} + +export function normalizeSourcesPayload(rawPayload) { + const listResult = z.array(sourceSchema).safeParse(rawPayload); + if (listResult.success) { + return dedupeSources(listResult.data.map(normalizeSource)); + } + + const cacheResult = sourcesCacheSchema.safeParse(rawPayload); + if (!cacheResult.success) { + throw new PayloadValidationError("payload de sources invalido", validationIssues(cacheResult.error)); + } + + const sources = Object.values(cacheResult.data.fuentes_por_subagente) + .flat() + .filter((source) => source.status === "activa") + .map(normalizeSource); + + return dedupeSources(sources); +} + +export function normalizeSignalQuery(searchParams) { + const result = signalQuerySchema.safeParse({ + fecha: searchParams.get("fecha"), + source_type: searchParams.get("source_type"), + limit: searchParams.get("limit"), + }); + + if (!result.success) { + throw new PayloadValidationError("query de signals invalido", validationIssues(result.error)); + } + + return result.data; +} + +function normalizeSource(source) { + return { + notion_page_url: source.notion_page_url ?? null, + name: source.name.trim(), + type: source.type ?? null, + url: source.url, + status: source.status ?? null, + priority: source.priority ?? null, + cadence: source.cadence ?? null, + notes: source.notes ?? null, + source_of_truth: source.source_of_truth ?? "notion", + }; +} + +function dedupeSources(sources) { + const deduped = new Map(); + for (const source of sources) { + deduped.set(source.notion_page_url ?? source.url, source); + } + return [...deduped.values()]; +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..91d3b32 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,13 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(fileURLToPath(import.meta.url)); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + turbopack: { + root, + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..de25e96 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1019 @@ +{ + "name": "ai-radar", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-radar", + "version": "0.1.0", + "dependencies": { + "@supabase/supabase-js": "2.108.2", + "next": "16.2.9", + "react": "19.2.7", + "react-dom": "19.2.7", + "zod": "4.4.3" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", + "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", + "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz", + "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz", + "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz", + "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz", + "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz", + "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.108.2", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz", + "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.108.2", + "@supabase/functions-js": "2.108.2", + "@supabase/postgrest-js": "2.108.2", + "@supabase/realtime-js": "2.108.2", + "@supabase/storage-js": "2.108.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", + "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.9", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.9", + "@next/swc-darwin-x64": "16.2.9", + "@next/swc-linux-arm64-gnu": "16.2.9", + "@next/swc-linux-arm64-musl": "16.2.9", + "@next/swc-linux-x64-gnu": "16.2.9", + "@next/swc-linux-x64-musl": "16.2.9", + "@next/swc-win32-arm64-msvc": "16.2.9", + "@next/swc-win32-x64-msvc": "16.2.9", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..186e5b5 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "ai-radar", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "test": "node --test" + }, + "dependencies": { + "@supabase/supabase-js": "2.108.2", + "next": "16.2.9", + "react": "19.2.7", + "react-dom": "19.2.7", + "zod": "4.4.3" + }, + "overrides": { + "postcss": "8.5.10" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/scripts/persist_daily_snapshot.js b/scripts/persist_daily_snapshot.js new file mode 100644 index 0000000..071ff01 --- /dev/null +++ b/scripts/persist_daily_snapshot.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const snapshotPath = resolve(process.argv[2] ?? ""); +const apiBaseUrl = (process.env.AI_RADAR_API_BASE_URL ?? "http://localhost:3000").replace(/\/$/, ""); +const token = process.env.AI_RADAR_API_TOKEN; + +if (!process.argv[2]) { + console.error("uso: node scripts/persist_daily_snapshot.js data/daily/YYYY-MM-DD.json"); + process.exit(1); +} + +if (!token) { + console.error("error: AI_RADAR_API_TOKEN es requerido"); + process.exit(1); +} + +const payload = JSON.parse(await readFile(snapshotPath, "utf8")); +const response = await fetch(`${apiBaseUrl}/api/runs`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(payload), +}); + +const result = await response.json(); +console.log(JSON.stringify(result, null, 2)); + +if (!response.ok) { + process.exit(1); +} diff --git a/scripts/sync_sources.js b/scripts/sync_sources.js new file mode 100644 index 0000000..a1ff748 --- /dev/null +++ b/scripts/sync_sources.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const sourcePath = resolve(process.argv[2] ?? "config/sources.json"); +const apiBaseUrl = (process.env.AI_RADAR_API_BASE_URL ?? "http://localhost:3000").replace(/\/$/, ""); +const token = process.env.AI_RADAR_API_TOKEN; + +if (!token) { + console.error("error: AI_RADAR_API_TOKEN es requerido"); + process.exit(1); +} + +const payload = JSON.parse(await readFile(sourcePath, "utf8")); +const response = await fetch(`${apiBaseUrl}/api/sources/sync`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(payload), +}); + +const result = await response.json(); +console.log(JSON.stringify(result, null, 2)); + +if (!response.ok) { + process.exit(1); +} diff --git a/supabase/migrations/20260621160537_init_ai_radar_core.sql b/supabase/migrations/20260621160537_init_ai_radar_core.sql new file mode 100644 index 0000000..8449c17 --- /dev/null +++ b/supabase/migrations/20260621160537_init_ai_radar_core.sql @@ -0,0 +1,82 @@ +create extension if not exists pgcrypto; + +create table if not exists public.sources ( + id uuid primary key default gen_random_uuid(), + notion_page_url text unique, + name text not null, + type text, + url text not null, + status text, + priority text, + cadence text, + notes text, + source_of_truth text not null default 'notion', + synced_at timestamptz not null default now(), + constraint sources_type_check + check ( + type is null + or type in ( + 'fuente_oficial', + 'repo_tecnico', + 'comunidad', + 'medios_secundario' + ) + ), + constraint sources_status_check + check ( + status is null + or status in ('activa', 'pausada', 'descartada') + ) +); + +create table if not exists public.runs ( + id uuid primary key default gen_random_uuid(), + query text not null, + window_start date, + window_end date, + status text not null, + sources_cache_status text, + fallback_report jsonb not null default '[]'::jsonb, + generated_at timestamptz not null default now(), + constraint runs_status_check + check (status in ('completed', 'failed')) +); + +create table if not exists public.signals ( + id uuid primary key default gen_random_uuid(), + run_id uuid not null references public.runs(id) on delete cascade, + source_id uuid references public.sources(id), + slug text not null, + title text not null, + topic text, + source_name text not null, + source_url text not null, + published_on date, + consulted_on date not null, + evidence text not null, + impact text not null, + action text not null, + status text not null, + raw jsonb not null default '{}'::jsonb, + unique (run_id, slug) +); + +create index if not exists sources_type_idx on public.sources(type); +create index if not exists sources_url_idx on public.sources(url); +create index if not exists sources_status_idx on public.sources(status); +create index if not exists runs_generated_at_idx on public.runs(generated_at desc); +create index if not exists signals_run_id_idx on public.signals(run_id); +create index if not exists signals_published_on_idx on public.signals(published_on desc); +create index if not exists signals_consulted_on_idx on public.signals(consulted_on desc); + +alter table public.sources enable row level security; +alter table public.runs enable row level security; +alter table public.signals enable row level security; + +revoke all on table public.sources from anon, authenticated; +revoke all on table public.runs from anon, authenticated; +revoke all on table public.signals from anon, authenticated; + +grant select, insert, update, delete on table public.sources to service_role; +grant select, insert, update, delete on table public.runs to service_role; +grant select, insert, update, delete on table public.signals to service_role; diff --git a/tests/endpoints.test.js b/tests/endpoints.test.js new file mode 100644 index 0000000..fa3b4b6 --- /dev/null +++ b/tests/endpoints.test.js @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { POST as postRun } from "../app/api/runs/route.js"; + +test("POST /api/runs rechaza token invalido", async () => { + process.env.AI_RADAR_API_TOKEN = "valid-token"; + + const response = await postRun( + new Request("http://localhost/api/runs", { + method: "POST", + headers: { + authorization: "Bearer invalid-token", + "content-type": "application/json", + }, + body: JSON.stringify({}), + }), + ); + + assert.equal(response.status, 401); + const payload = await response.json(); + assert.equal(payload.error.code, "unauthorized"); +}); + +test("POST /api/runs rechaza payload invalido con token valido", async () => { + process.env.AI_RADAR_API_TOKEN = "valid-token"; + + const response = await postRun( + new Request("http://localhost/api/runs", { + method: "POST", + headers: { + authorization: "Bearer valid-token", + "content-type": "application/json", + }, + body: JSON.stringify({ senales: [] }), + }), + ); + + assert.equal(response.status, 400); + const payload = await response.json(); + assert.equal(payload.error.code, "invalid_payload"); +}); diff --git a/tests/validation.test.js b/tests/validation.test.js new file mode 100644 index 0000000..ade867d --- /dev/null +++ b/tests/validation.test.js @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + PayloadValidationError, + normalizeRunPayload, + normalizeSignalQuery, + normalizeSourcesPayload, +} from "../lib/validation.js"; + +test("normaliza un snapshot diario al payload de runs", async () => { + const snapshot = JSON.parse( + await readFile(new URL("../data/daily/2026-06-21.json", import.meta.url), "utf8"), + ); + + const run = normalizeRunPayload(snapshot); + + assert.equal(run.query, "5 noticias recientes de IA como senales de AI Radar"); + assert.equal(run.window_start, "2026-06-21"); + assert.equal(run.signals.length, 5); + assert.equal(run.signals[0].slug, "g7-ceos-ia-geopolitica"); + assert.equal(run.signals[0].source.source_of_truth, "signal"); +}); + +test("normaliza sources activas desde cache de Notion", () => { + const sources = normalizeSourcesPayload({ + fuentes_por_subagente: { + "ai-radar-fuentes-oficiales": [ + { + name: "OpenAI News", + type: "fuente_oficial", + status: "activa", + url: "https://openai.com/news/", + notion_page_url: "https://app.notion.com/p/example", + }, + { + name: "Paused", + type: "fuente_oficial", + status: "pausada", + url: "https://example.com/paused", + }, + ], + }, + }); + + assert.equal(sources.length, 1); + assert.equal(sources[0].name, "OpenAI News"); +}); + +test("rechaza payloads de run invalidos", () => { + assert.throws(() => normalizeRunPayload({ senales: [] }), PayloadValidationError); +}); + +test("normaliza query params de signals", () => { + const params = new URLSearchParams({ + fecha: "2026-06-21", + source_type: "fuente_oficial", + limit: "3", + }); + + assert.deepEqual(normalizeSignalQuery(params), { + fecha: "2026-06-21", + source_type: "fuente_oficial", + limit: 3, + }); +}); From 25d740a17ecc14780310d8fd5e56552c82743bfe Mon Sep 17 00:00:00 2001 From: Juan Melo Date: Sun, 21 Jun 2026 18:20:25 +0200 Subject: [PATCH 7/9] docs: documentar API Supabase de AI Radar --- .gitignore | 3 +++ AGENTS.md | 28 +++++++++++++++++++++++----- README.md | 39 +++++++++++++++++++++++++++++++++++---- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index e8fd391..aec75c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,18 @@ .DS_Store node_modules/ .pnpm-store/ +.next/ dist/ coverage/ .env .env.* +!.env.example .airadar/ data/searches/ data/reports/ snapshots/weekly/ config/sources.json +supabase/.temp/ frameio/ recordings/ *.log diff --git a/AGENTS.md b/AGENTS.md index 1d74c11..bfc5763 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,27 +2,29 @@ ## Estructura del Proyecto y Organizacion -Este workspace contiene el starter clonado de AI Radar en `platzi-codex-clase-02-agents-md/`. Ese proyecto es intencionalmente pequeno: `README.md` define la direccion del producto, `AGENTS.md` define reglas para agentes y `.gitignore` excluye caches locales, secretos, datos generados y salidas de build. Todavia no existen `src/`, `tests/`, `assets/`, manifiesto de paquete ni runtime de aplicacion. Agrega nuevos directorios solo cuando el objetivo de la clase lo requiera. +Este workspace contiene AI Radar en `platzi-codex-clase-02-agents-md/`. El proyecto sigue siendo pequeno: `README.md` define la direccion del producto, `AGENTS.md` define reglas para agentes y `.gitignore` excluye caches locales, secretos, datos generados y salidas de build. La implementacion actual agrega un runtime minimo Next.js solo para API routes, helpers server-side en `lib/`, migraciones en `supabase/migrations/`, pruebas en `tests/` y scripts locales en `scripts/`. No existe dashboard visual todavia. ## Comandos de Build, Prueba y Desarrollo -Todavia no existen comandos de build o pruebas porque no hay implementacion de aplicacion ni `package.json`. Usa comandos de inspeccion mientras trabajas: +Usa estos comandos mientras trabajas: ```powershell cd platzi-codex-clase-02-agents-md git status --short git log --oneline -5 +npm test +npm run build ``` -No documentes comandos como `npm test` o `npm run build` hasta que realmente existan. +Para desarrollo local de la API usa `npm run dev`. No ejecutes ni documentes comandos nuevos hasta que existan en `package.json`. ## Estilo de Codigo y Convenciones de Nombres -El contenido actual es solo Markdown. Manten encabezados claros, parrafos breves y nombres descriptivos para futuros archivos, por ejemplo `fixtures/signals.json` o `scripts/normalize-sources.js`. No incluyas en control de versiones salidas generadas, snapshots, grabaciones, credenciales ni bases de datos locales. +Manten encabezados claros, parrafos breves y nombres descriptivos para archivos nuevos, por ejemplo `fixtures/signals.json` o `scripts/normalize-sources.js`. La API actual usa JavaScript ESM, validacion con esquemas y funciones pequenas en `lib/`. No incluyas en control de versiones salidas generadas, snapshots temporales, grabaciones, credenciales ni bases de datos locales. ## Guia de Pruebas -No hay un framework de pruebas configurado todavia. Cuando se introduzcan modulos JavaScript, prefiere `node:test` para la logica de dominio. Cuando exista una interfaz visual, agrega verificaciones con Playwright para flujos de usuario. Los archivos de prueba deben vivir junto al comportamiento que verifican o dentro de un directorio `tests/` claro cuando la estructura este definida. +Las pruebas usan `node:test` y viven en `tests/`. Mantén pruebas enfocadas en validacion de contratos, normalizacion y endpoints. Cuando exista una interfaz visual, agrega verificaciones con Playwright para flujos de usuario. ## Guia de Commits y Pull Requests @@ -31,3 +33,19 @@ El historial existente usa prefijos convencionales cortos como `docs:` y `chore: ## Instrucciones Especificas para Agentes Inspecciona el repositorio antes de editar. Trata el README como direccion de producto, no como prueba de funcionalidades implementadas. Manten los cambios acotados a la leccion actual y evita inventar servicios, scripts, bases de datos o automatizaciones que no esten presentes. + +La integracion Supabase actual es server-side: los endpoints requieren `Authorization: Bearer $AI_RADAR_API_TOKEN` y usan `SUPABASE_SERVICE_ROLE_KEY` solo en el servidor. No uses secretos con prefijo `NEXT_PUBLIC_`. No apliques migraciones DDL ni crees proyectos Supabase remotos sin aprobacion explicita. + +Cuando una tarea requiera buscar senales recientes de IA con subagentes, usa las configuraciones en `.agents/` y genera el plan de llamadas con: + +```powershell +python scripts\llamar_subagentes.py "senales recientes de IA" +``` + +Antes de generar el plan, consulta Notion primero usando la tabla `AI radar Sources`. Refresca `config/sources.json` como cache local con las fuentes activas agrupadas por subagente. Ese archivo esta ignorado por git y no debe tratarse como artefacto versionado salvo instruccion explicita. + +Ese script valida los YAML, lee `config/sources.json` si existe y produce payloads para `multi_agent_v1.spawn_agent`; ejecuta esos payloads en paralelo desde Codex, deduplica resultados y normaliza las senales antes de responder o guardar snapshots. + +Si Notion no responde, la tabla no esta indexada, una fuente falla o un subagente no devuelve resultado, continua con el fallback indicado por el script y reporta el motivo en la respuesta final. + +Trata frases naturales como "busca las noticias de esta semana", "busca noticias recientes de IA" o "dame las senales de IA de la semana" como solicitudes para activar ese flujo. Para "esta semana", calcula la ventana de los ultimos 7 dias con fechas exactas y pasala al script con `--desde` y `--hasta`. diff --git a/README.md b/README.md index 9478e4b..a4c4c9e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ AI Radar es el proyecto del curso avanzado de Codex. El objetivo del producto es organizar noticias, herramientas, papers, repos y lanzamientos de IA para convertirlos en senales accionables para builders: que paso, por que importa, que tan confiable es y que vale la pena probar. -Estado inicial: definicion de producto, stack objetivo y reglas iniciales. La implementacion se construye por capas durante el curso con Codex. +Estado actual: definicion de producto, contrato local de senales, scripts de subagentes y una API minima para persistir runs y senales en Supabase. La implementacion se construye por capas durante el curso con Codex. ## Problema @@ -32,14 +32,45 @@ Al final del curso, AI Radar debe poder: - guardar trazas de decisiones y validaciones, - desplegarse con infraestructura controlada. -## Estado Inicial +## Estado Actual -El starter contiene: +El repo contiene: - `README.md` - `.gitignore` +- `AGENTS.md` +- `.agents/` con configuraciones de subagentes +- `contracts/ai-radar-daily-signals.schema.json` +- `data/daily/` con snapshots diarios +- `scripts/` con utilidades locales +- `app/api/` con endpoints Next.js API-only +- `lib/` con validacion y acceso server-side a Supabase +- `supabase/migrations/` con el esquema core +- `tests/` con pruebas `node:test` -La primera clase usa este estado para mostrar como `AGENTS.md` cambia la forma en que Codex entiende un proyecto antes de escribir codigo. +No existe dashboard visual todavia. + +## Desarrollo Local + +```powershell +npm install +npm test +npm run build +npm run dev +``` + +Configura `.env.local` a partir de `.env.example`. No guardes claves reales en git. + +## API Supabase + +Los endpoints requieren `Authorization: Bearer $AI_RADAR_API_TOKEN`: + +- `POST /api/runs`: guarda un run completo con senales normalizadas. +- `GET /api/runs/:id`: consulta un run y sus senales. +- `GET /api/signals?fecha=&source_type=&limit=`: lista senales persistidas. +- `POST /api/sources/sync`: sincroniza fuentes activas desde el cache de Notion. + +Supabase se usa solo server-side con `SUPABASE_SERVICE_ROLE_KEY`. La migracion local habilita RLS y no crea politicas publicas. ## Stack Objetivo From 595605da67f34959c6bbc154fea14c32558cb60e Mon Sep 17 00:00:00 2001 From: Juan Melo Date: Sun, 21 Jun 2026 19:31:01 +0200 Subject: [PATCH 8/9] feat: agregar frontend de ranking AI Radar --- app/globals.css | 1461 +++++++++++++++++++++++++++++++ app/layout.js | 17 + app/page.js | 404 +++++++++ fixtures/dashboard.json | 249 ++++++ public/dashboard.js | 171 ++++ public/favicon.svg | 5 + tests/dashboard-fixture.test.js | 29 + 7 files changed, 2336 insertions(+) create mode 100644 app/globals.css create mode 100644 app/layout.js create mode 100644 app/page.js create mode 100644 fixtures/dashboard.json create mode 100644 public/dashboard.js create mode 100644 public/favicon.svg create mode 100644 tests/dashboard-fixture.test.js diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..fc1f61a --- /dev/null +++ b/app/globals.css @@ -0,0 +1,1461 @@ +:root { + --bg: #f6f8fb; + --surface: #ffffff; + --surface-muted: #f8fafc; + --line: #dce3ee; + --line-strong: #c7d1df; + --text: #0d172b; + --muted: #50617c; + --muted-strong: #33415c; + --accent: #1d6ff2; + --accent-strong: #0f54c8; + --ink: #070d1d; + --green: #049669; + --teal: #12a8a1; + --orange: #f58b17; + --red: #df2e38; + --shadow: 0 12px 28px rgb(30 45 70 / 8%); + --button-bg: #ffffff; + --button-hover: #f2f6fb; + --button-active: #e9f1ff; + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--bg); +} + +body { + margin: 0; + min-width: 320px; + color: var(--text); + background: var(--bg); +} + +button, +input, +select { + font: inherit; +} + +button, +select { + cursor: pointer; +} + +button:disabled, +input:disabled, +select:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 70%, white); + outline-offset: 2px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-header { + position: sticky; + top: 0; + z-index: 10; + display: grid; + grid-template-columns: 180px minmax(220px, 1fr) auto minmax(320px, auto); + align-items: center; + gap: 28px; + min-height: 92px; + padding: 0 28px 0 0; + background: color-mix(in srgb, var(--surface) 94%, transparent); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(16px); +} + +.brand { + display: flex; + align-items: center; + align-self: stretch; + padding: 0 28px; + color: var(--text); + font-size: 28px; + font-weight: 800; + white-space: nowrap; + text-decoration: none; + border-right: 1px solid var(--line); +} + +.title-block h1, +.title-block p, +.updated-at, +.sources-heading h2, +.sources-heading p, +.source-card h3, +.source-card p { + margin: 0; +} + +.title-block h1 { + font-size: clamp(22px, 2vw, 30px); + line-height: 1.1; + letter-spacing: 0; +} + +.title-block p, +.updated-at, +.sources-heading p { + margin-top: 6px; + color: var(--muted); + font-size: 14px; +} + +.mode-switch { + display: inline-grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + min-width: 360px; + padding: 4px; + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + background: var(--surface); + box-shadow: 0 10px 24px rgb(20 32 54 / 7%); +} + +.mode-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 42px; + padding: 0 22px; + color: var(--text); + font-weight: 750; + white-space: nowrap; + background: transparent; + border: 0; + border-radius: 7px; + transition: + background-color 140ms ease, + color 140ms ease, + box-shadow 140ms ease; +} + +.mode-button + .mode-button { + border-left: 0; +} + +.mode-button.is-active { + color: #ffffff; + background: var(--ink); + box-shadow: 0 8px 18px rgb(7 13 29 / 22%); +} + +.mode-button:not(.is-active):hover { + background: var(--button-hover); +} + +.header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; +} + +.updated-at::before { + content: ""; + display: inline-block; + width: 12px; + height: 12px; + margin-right: 8px; + vertical-align: -1px; + border: 2px solid var(--muted); + border-radius: 999px; +} + +.secondary-button, +.icon-button, +.profile-button, +.page-button, +.page-arrow { + border: 1px solid var(--line); + background: var(--button-bg); +} + +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 34px; + padding: 0 16px; + color: var(--text); + font-weight: 700; + border-radius: 6px; + white-space: nowrap; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.secondary-button:hover:not(:disabled), +.icon-button:hover:not(:disabled), +.page-button:hover:not(:disabled), +.page-arrow:hover:not(:disabled) { + background: var(--button-hover); + border-color: var(--line-strong); +} + +.secondary-button:active:not(:disabled), +.icon-button:active:not(:disabled), +.page-button:active:not(:disabled), +.page-arrow:active:not(:disabled) { + transform: translateY(1px); +} + +.snapshot-button { + min-height: 46px; + padding: 0 20px; +} + +.icon-button, +.profile-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 10px; + color: var(--text); + font-weight: 800; +} + +.button-icon { + position: relative; + display: inline-block; + flex: 0 0 auto; + width: 16px; + height: 16px; + color: currentColor; +} + +.reader-icon { + border: 2px solid currentColor; + border-radius: 3px; +} + +.reader-icon::before { + content: ""; + position: absolute; + inset: 3px auto 3px 6px; + border-left: 2px solid currentColor; +} + +.operator-icon::before, +.operator-icon::after { + content: ""; + position: absolute; + left: 1px; + width: 14px; + height: 2px; + background: currentColor; + border-radius: 999px; +} + +.operator-icon::before { + top: 4px; + box-shadow: 4px 5px 0 currentColor; +} + +.operator-icon::after { + top: 2px; + width: 2px; + height: 6px; + transform: translateX(8px); + box-shadow: -5px 7px 0 currentColor; +} + +.snapshot-icon { + border: 2px solid currentColor; + border-radius: 4px; +} + +.snapshot-icon::before { + content: ""; + position: absolute; + inset: 3px; + border: 2px solid currentColor; + border-radius: 999px; +} + +.snapshot-icon::after { + content: ""; + position: absolute; + top: -4px; + left: 4px; + width: 6px; + height: 3px; + background: currentColor; + border-radius: 2px 2px 0 0; +} + +.alert-icon::before { + content: ""; + position: absolute; + left: 7px; + top: 2px; + width: 2px; + height: 9px; + background: currentColor; + border-radius: 999px; +} + +.alert-icon::after { + content: ""; + position: absolute; + left: 7px; + bottom: 2px; + width: 2px; + height: 2px; + background: currentColor; + border-radius: 999px; +} + +.evidence-icon { + width: 17px; + border: 2px solid currentColor; + border-radius: 999px 999px 70% 70%; + transform: rotate(-8deg); +} + +.evidence-icon::before { + content: ""; + position: absolute; + left: 5px; + top: 4px; + width: 4px; + height: 4px; + background: currentColor; + border-radius: 999px; +} + +.filter-icon { + background: currentColor; + clip-path: polygon(1px 2px, 15px 2px, 10px 8px, 10px 14px, 6px 14px, 6px 8px); +} + +.profile-button { + color: #ffffff; + background: var(--ink); + border-color: var(--ink); + border-radius: 999px; +} + +.alert-button { + position: relative; +} + +.alert-count { + position: absolute; + top: -9px; + right: -7px; + display: inline-grid; + min-width: 22px; + height: 22px; + place-items: center; + padding: 0 5px; + color: #ffffff; + font-size: 12px; + font-weight: 800; + background: #ff524d; + border-radius: 999px; +} + +.dashboard-shell { + padding: 28px; +} + +.filters-panel { + display: grid; + grid-template-columns: minmax(260px, 1.25fr) minmax(190px, 0.9fr) minmax(250px, 1fr) minmax(210px, 0.9fr) minmax(170px, 0.75fr) minmax(170px, 0.75fr) minmax(150px, 0.65fr) auto auto; + gap: 14px; + align-items: center; + margin-bottom: 24px; +} + +.search-field, +.select-field { + display: grid; + align-items: center; + min-height: 46px; + padding: 0 14px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.search-field { + position: relative; + display: flex; +} + +.search-field::after { + content: ""; + position: absolute; + right: 16px; + width: 14px; + height: 14px; + border: 2px solid var(--muted-strong); + border-radius: 999px; + box-shadow: 7px 7px 0 -5px var(--muted-strong); +} + +.search-field input { + width: 100%; + min-width: 0; + padding: 0 32px 0 0; + color: var(--text); + border: 0; + outline: 0; +} + +.select-field { + position: relative; + grid-template-columns: auto auto minmax(0, 1fr); + gap: 9px; + padding: 0 34px 0 12px; +} + +.search-field:hover, +.select-field:hover, +.toggle-field:hover { + border-color: var(--line-strong); + background: #fbfdff; +} + +.search-field:focus-within, +.select-field:focus-within, +.toggle-field:focus-within { + border-color: color-mix(in srgb, var(--accent) 70%, var(--line)); + box-shadow: 0 0 0 3px rgb(29 111 242 / 12%); +} + +.select-field::after, +.page-size::after { + content: ""; + position: absolute; + top: 50%; + right: 14px; + width: 7px; + height: 7px; + border-right: 2px solid var(--muted-strong); + border-bottom: 2px solid var(--muted-strong); + pointer-events: none; + transform: translateY(-65%) rotate(45deg); +} + +.control-label { + flex: 0 0 auto; + color: var(--muted-strong); + font-weight: 750; + white-space: nowrap; +} + +.control-icon { + position: relative; + display: inline-block; + width: 16px; + height: 16px; + color: var(--muted-strong); +} + +.select-field select, +.page-size select { + width: 100%; + min-width: 0; + height: 44px; + padding: 0; + color: var(--text); + font-weight: 700; + text-overflow: ellipsis; + background: transparent; + border: 0; + outline: 0; + appearance: none; +} + +.compact-filter .control-label { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.calendar-icon { + border: 2px solid currentColor; + border-radius: 3px; +} + +.calendar-icon::before { + content: ""; + position: absolute; + left: 2px; + right: 2px; + top: 4px; + border-top: 2px solid currentColor; +} + +.source-icon { + border: 2px solid currentColor; + border-radius: 999px; +} + +.source-icon::before, +.source-icon::after { + content: ""; + position: absolute; + left: 2px; + right: 2px; + border-top: 2px solid currentColor; +} + +.source-icon::before { + top: 4px; +} + +.source-icon::after { + top: 9px; +} + +.topic-icon { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-radius: 3px; + transform: rotate(45deg); +} + +.topic-icon::before { + content: ""; + position: absolute; + inset: 4px; + background: currentColor; + border-radius: 999px; +} + +.type-icon::before, +.type-icon::after { + content: ""; + position: absolute; + left: 1px; + width: 14px; + height: 7px; + border: 2px solid currentColor; + border-radius: 3px; +} + +.type-icon::before { + top: 1px; +} + +.type-icon::after { + bottom: 1px; +} + +.confidence-icon::before { + content: ""; + position: absolute; + left: 1px; + right: 1px; + bottom: 3px; + height: 8px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: skewX(-24deg); +} + +.confidence-icon::after { + content: ""; + position: absolute; + right: 1px; + top: 2px; + width: 5px; + height: 5px; + background: currentColor; + border-radius: 999px; +} + +.impact-icon::before { + content: ""; + position: absolute; + left: 1px; + right: 1px; + bottom: 3px; + height: 9px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; +} + +.impact-icon::after { + content: ""; + position: absolute; + left: 4px; + bottom: 5px; + width: 10px; + height: 8px; + border-top: 2px solid currentColor; + border-right: 2px solid currentColor; + transform: rotate(-38deg); +} + +.toggle-field { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 46px; + padding: 0 12px; + color: var(--text); + font-weight: 750; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.toggle-field input { + appearance: none; + position: relative; + width: 46px; + height: 24px; + margin: 0; + background: #c8d0dc; + border-radius: 999px; + transition: background-color 140ms ease; +} + +.toggle-field input::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #ffffff; + border-radius: 999px; + transition: transform 160ms ease; +} + +.toggle-field input:checked { + background: var(--accent); +} + +.toggle-field input:checked::after { + transform: translateX(22px); +} + +.ranking-section, +.sources-section { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); +} + +.section-heading { + min-height: 0; +} + +.section-heading p { + margin: 0; + padding: 0 16px; + color: var(--muted); + font-size: 0; +} + +.table-shell { + width: 100%; + overflow-x: auto; +} + +table { + width: 100%; + min-width: 1040px; + border-collapse: collapse; +} + +th, +td { + padding: 12px 14px; + text-align: left; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +th { + color: var(--text); + font-size: 13px; + font-weight: 800; + background: var(--surface-muted); +} + +td { + font-size: 14px; +} + +.rank-cell { + width: 56px; + color: var(--text); + font-weight: 750; + text-align: center; +} + +.signal-cell { + min-width: 340px; +} + +.signal-cell strong { + display: block; + max-width: 720px; + line-height: 1.25; +} + +.signal-cell span, +.impact-cell span, +.recency-cell span { + display: block; + margin-top: 5px; + color: var(--muted); + font-size: 13px; +} + +.impact-cell { + min-width: 92px; + text-align: center; +} + +.impact-score { + display: block; + font-size: 27px; + line-height: 1; +} + +.impact-strong, +.impact-medium { + color: var(--green); +} + +.impact-low { + color: #157a68; +} + +.confidence-cell { + min-width: 150px; +} + +.confidence-value { + display: block; + margin-bottom: 8px; + font-weight: 800; +} + +.confidence-high { + color: var(--text); +} + +.confidence-medium { + color: #8f5a05; +} + +.confidence-low { + color: var(--red); +} + +.meter { + display: block; + width: 132px; + height: 8px; + overflow: hidden; + background: #eef2f7; + border-radius: 999px; +} + +.meter span { + display: block; + height: 100%; + background: var(--teal); + border-radius: inherit; +} + +.confidence-medium + .meter span { + background: var(--orange); +} + +.confidence-low + .meter span { + background: var(--red); +} + +.recency-cell { + min-width: 100px; + text-align: center; +} + +.sources-cell { + min-width: 150px; +} + +.source-stack { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.source-badge, +.source-more { + display: inline-grid; + min-width: 24px; + height: 24px; + place-items: center; + padding: 0 5px; + color: var(--text); + font-size: 12px; + font-weight: 900; + line-height: 1; + background: #ffffff; + border: 1px solid var(--line); + border-radius: 5px; +} + +.source-red { + color: #ff362f; +} + +.source-dark { + color: #ffffff; + background: #05070d; + border-color: #05070d; +} + +.source-blue { + color: #ffffff; + background: #2587e8; + border-color: #2587e8; +} + +.source-blue-muted { + color: #274268; + background: #e8eef9; +} + +.source-orange { + color: #ffffff; + background: #fb9b2c; + border-color: #fb9b2c; +} + +.source-green, +.source-green-dark { + color: #137a1d; + background: #ebf9ed; +} + +.source-green-dark { + color: #ffffff; + background: #5b9d18; + border-color: #5b9d18; +} + +.source-purple { + color: #ffffff; + background: #635bff; + border-color: #635bff; +} + +.source-navy { + color: #ffffff; + background: #164174; + border-color: #164174; +} + +.source-light { + color: var(--text); + background: #ffffff; +} + +.source-more { + color: var(--muted-strong); + background: #f1f5f9; +} + +.duplicate-cell { + min-width: 115px; +} + +.duplicate { + font-weight: 850; +} + +.duplicate-unique { + color: var(--green); +} + +.duplicate-possible { + color: #d36b00; +} + +.duplicate-possible::before { + content: ""; + display: inline-block; + width: 7px; + height: 12px; + margin-right: 6px; + vertical-align: -1px; + background: linear-gradient(#ffd6a0 0 45%, #ff8f1f 45% 100%); + border-radius: 999px; +} + +.actions-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 160px; +} + +.dots-button { + width: 28px; + height: 32px; + border-color: transparent; + background: transparent; +} + +.filters-button { + min-height: 46px; +} + +.evidence-button { + gap: 6px; + min-width: 128px; + padding: 0 10px; +} + +.dots-icon, +.dots-icon::before, +.dots-icon::after { + display: block; + width: 4px; + height: 4px; + background: currentColor; + border-radius: 999px; +} + +.dots-icon { + position: relative; +} + +.dots-icon::before, +.dots-icon::after { + content: ""; + position: absolute; + left: 0; +} + +.dots-icon::before { + top: -7px; +} + +.dots-icon::after { + top: 7px; +} + +.table-footer { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + padding: 13px 18px; +} + +.table-footer p { + margin: 0; + color: var(--muted-strong); + font-weight: 650; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.page-button, +.page-arrow { + min-width: 34px; + height: 34px; + color: var(--text); + font-weight: 750; + border-radius: 6px; +} + +.page-button { + border-color: transparent; + background: transparent; +} + +.page-button.is-active { + color: var(--accent-strong); + background: #eef5ff; + border-color: var(--accent); +} + +.page-gap { + color: var(--muted); +} + +.page-size { + position: relative; + justify-self: end; + min-width: 150px; + padding: 9px 34px 9px 12px; + border: 1px solid var(--line); + border-radius: 6px; +} + +.page-size select { + height: auto; +} + +.sources-section { + margin-top: 20px; + padding: 16px; +} + +.sources-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.sources-heading h2 { + font-size: 18px; +} + +.sources-heading a { + color: var(--accent-strong); + font-size: 14px; + font-weight: 700; + text-decoration: none; +} + +.sources-grid { + display: grid; + grid-template-columns: repeat(9, minmax(130px, 1fr)); + gap: 10px; +} + +.source-card { + position: relative; + min-height: 102px; + padding: 12px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; +} + +.source-card-header { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.source-card h3 { + min-width: 0; + overflow: hidden; + font-size: 13px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-metric { + display: flex; + align-items: baseline; + gap: 4px; + margin-top: 12px; +} + +.source-metric strong, +.source-card-summary strong { + font-size: 16px; +} + +.source-metric span, +.source-card-summary p { + color: var(--muted); + font-size: 13px; +} + +.sparkline { + position: absolute; + right: 10px; + bottom: 8px; + width: 118px; + height: 28px; +} + +.sparkline polyline { + fill: none; + stroke: #31b896; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.source-lag { + position: absolute; + right: 12px; + top: 58px; + color: var(--muted); + font-size: 12px; +} + +.source-card-summary { + display: grid; + align-content: start; + gap: 8px; +} + +.state-cell { + height: 280px; + text-align: center; +} + +.state-cell strong, +.state-cell span { + display: block; +} + +.state-cell span { + margin-top: 8px; + color: var(--muted); +} + +.state-cell-error strong { + color: var(--red); +} + +.skeleton-row td { + padding: 20px; +} + +.skeleton-line { + display: block; + width: 100%; + height: 42px; + background: linear-gradient(90deg, #eef2f6, #f8fafc, #eef2f6); + background-size: 240% 100%; + border-radius: 6px; + animation: shimmer 1.2s linear infinite; +} + +.no-results-row { + display: none; +} + +.no-results-row.is-visible { + display: table-row; +} + +.evidence-dialog { + width: min(640px, calc(100vw - 32px)); + padding: 0; + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: 0 28px 70px rgb(10 20 40 / 26%); +} + +.evidence-dialog::backdrop { + background: rgb(9 14 28 / 45%); +} + +.evidence-dialog form { + padding: 22px; +} + +.dialog-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.dialog-heading h2 { + margin: 0; + font-size: 20px; +} + +.evidence-dialog dl { + display: grid; + gap: 8px; + margin: 0; +} + +.evidence-dialog dt { + color: var(--muted); + font-size: 13px; + font-weight: 800; + text-transform: uppercase; +} + +.evidence-dialog dd { + margin: 0 0 12px; + line-height: 1.5; +} + +@keyframes shimmer { + from { + background-position: 120% 0; + } + to { + background-position: -120% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +@media (max-width: 1560px) { + .filters-panel { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 1320px) { + .app-header { + grid-template-columns: 180px 1fr; + gap: 18px; + padding: 0 18px 16px 0; + } + + .brand { + padding: 0 22px; + font-size: 27px; + } + + .mode-switch, + .header-actions { + grid-column: 2; + justify-self: start; + } + + .sources-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 820px) { + .app-header { + position: static; + grid-template-columns: 1fr; + gap: 14px; + min-height: auto; + padding: 18px; + } + + .brand { + align-self: auto; + padding: 0; + border-right: 0; + } + + .mode-switch, + .header-actions { + grid-column: auto; + width: 100%; + } + + .mode-switch { + min-width: 0; + } + + .mode-button { + padding: 0 12px; + } + + .header-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .dashboard-shell { + padding: 16px; + } + + .filters-panel { + grid-template-columns: 1fr; + } + + .ranking-section { + border: 0; + background: transparent; + box-shadow: none; + } + + .table-shell { + overflow: visible; + } + + table { + min-width: 0; + } + + thead { + display: none; + } + + tbody, + tr, + td { + display: block; + width: 100%; + } + + tr { + margin-bottom: 12px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + box-shadow: 0 1px 0 rgb(20 40 70 / 4%); + } + + td { + display: grid; + grid-template-columns: minmax(88px, 35%) 1fr; + gap: 12px; + padding: 11px 14px; + border-bottom: 1px solid #edf1f6; + } + + td::before { + content: attr(data-label); + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + } + + td:last-child { + border-bottom: 0; + } + + .rank-cell, + .impact-cell, + .recency-cell { + text-align: left; + } + + .signal-cell, + .confidence-cell, + .sources-cell, + .duplicate-cell, + .actions-cell { + min-width: 0; + } + + .actions-cell { + align-items: center; + } + + .meter { + width: 100%; + } + + .table-footer { + grid-template-columns: 1fr; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 7px; + } + + .pagination { + justify-content: flex-start; + overflow-x: auto; + padding-bottom: 4px; + } + + .page-size { + justify-self: stretch; + } + + .sources-heading { + align-items: flex-start; + flex-direction: column; + } + + .sources-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .title-block h1 { + font-size: 24px; + } + + .updated-at { + width: 100%; + } + + .secondary-button { + padding: 0 12px; + } + + td { + grid-template-columns: 1fr; + gap: 6px; + } + + .source-stack { + flex-wrap: wrap; + } +} diff --git a/app/layout.js b/app/layout.js new file mode 100644 index 0000000..9dc2fad --- /dev/null +++ b/app/layout.js @@ -0,0 +1,17 @@ +import "./globals.css"; + +export const metadata = { + title: "AI Radar", + description: "Ranking operativo de senales de AI Radar", + icons: { + icon: "/favicon.svg", + }, +}; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} diff --git a/app/page.js b/app/page.js new file mode 100644 index 0000000..45d9836 --- /dev/null +++ b/app/page.js @@ -0,0 +1,404 @@ +import dashboard from "../fixtures/dashboard.json"; +import Script from "next/script"; + +const stateLabels = { + loading: "Cargando senales", + empty: "No hay senales para la ventana seleccionada", + error: "No se pudo cargar el ranking de senales", + success: "Ranking de senales cargado", +}; + +function scoreTone(score) { + if (score >= 80) { + return "strong"; + } + if (score >= 55) { + return "medium"; + } + return "low"; +} + +function duplicateLabel(status) { + return status === "possible" ? "Posible" : "Unico"; +} + +function sourceTone(tone) { + return `source-badge source-${tone}`; +} + +function sparklinePoints(values) { + const width = 118; + const height = 28; + const min = Math.min(...values); + const max = Math.max(...values); + const spread = max - min || 1; + + return values + .map((value, index) => { + const x = (index / (values.length - 1)) * width; + const y = height - ((value - min) / spread) * 18 - 5; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); +} + +function renderTableBody(viewState) { + if (viewState === "loading") { + return Array.from({ length: 5 }, (_, index) => ( + + + + + + )); + } + + if (viewState === "empty") { + return ( + + + No hay senales todavia + Conecta una API real o cambia a un fixture con senales declaradas. + + + ); + } + + if (viewState === "error") { + return ( + + + Error al cargar el ranking + El estado de error esta declarado para QA visual. Reintenta o revisa la fuente de datos. + + + ); + } + + return dashboard.signals.map((signal) => ( + source.name.toLowerCase()).join(" ")} + > + + {signal.rank} + + + {signal.title} + {signal.category} + + + + {signal.impact_score} + + {signal.impact_label} + + + + {signal.confidence_score}% + +