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"
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4052bbc
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+SUPABASE_URL=
+SUPABASE_SERVICE_ROLE_KEY=
+AI_RADAR_API_TOKEN=
+AI_RADAR_API_BASE_URL=http://localhost:3000
+NOTION_API_KEY=
+NOTION_DATA_SOURCE_ID=
+NOTION_DATABASE_ID=
+NOTION_DATABASE_URL=
+NOTION_DATA_SOURCE_URL=
+NEXT_PUBLIC_APP_ENV=development
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
new file mode 100644
index 0000000..155745d
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,53 @@
+# Guia del Repositorio
+
+## Estructura del Proyecto y Organizacion
+
+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 con API routes protegidas, dashboard visual en `app/page.js`, helpers server-side en `lib/`, migraciones en `supabase/migrations/`, pruebas en `tests/`, fixtures en `fixtures/` y scripts locales en `scripts/`.
+
+## Comandos de Build, Prueba y Desarrollo
+
+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
+npm run sources:refresh
+npm run supabase:check
+```
+
+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
+
+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
+
+Las pruebas usan `node:test` y viven en `tests/`. Mantén pruebas enfocadas en validacion de contratos, normalizacion, endpoints y adaptadores server-side. Para cambios de interfaz visual, agrega o ejecuta verificaciones con Playwright para flujos de usuario cuando el alcance lo justifique.
+
+## Guia de Commits y Pull Requests
+
+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.
+
+## 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. El dashboard puede leer Supabase desde server components mediante helpers de `lib/`; si faltan variables o tablas, debe caer a fixture declarado sin exponer secretos al navegador. 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. Usa `npm run sources:refresh` cuando existan `NOTION_API_KEY` y `NOTION_DATA_SOURCE_ID`; si el conector Notion no permite consultas SQL, usa `search` + `fetch` como fallback de lectura y reporta el motivo. 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..f468234 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, una API minima para persistir runs y senales en Supabase y un dashboard visual inicial. El dashboard puede leer datos reales server-side desde Supabase cuando el entorno esta configurado; si faltan credenciales, cae al fixture declarado en `fixtures/dashboard.json`.
## Problema
@@ -32,14 +32,66 @@ 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 protegidos
+- `app/page.js` con dashboard visual basado en API server-side o fixture declarado
+- `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.
+El dashboard visual existe en modo operacional inicial. Aun no hay ranking persistido con scores propios; cuando usa Supabase, la capa visual adapta `signals` y `sources` al contrato de UI y declara ese mapeo en los datos entregados a la pagina.
+
+## Desarrollo Local
+
+```powershell
+npm install
+npm test
+npm run build
+npm run dev
+npm run sources:refresh
+npm run supabase:check
+```
+
+Configura `.env.local` a partir de `.env.example`. No guardes claves reales en git.
+
+## Fuentes Notion
+
+La tabla de fuentes vive en Notion como `AI radar Sources`. Para refrescar el cache local ignorado por git:
+
+```powershell
+npm run sources:refresh
+```
+
+El script usa la API publica de Notion y evita depender de consultas SQL del conector Notion. Configura `NOTION_API_KEY` y preferentemente `NOTION_DATA_SOURCE_ID`; `NOTION_DATABASE_ID` queda como fallback legacy. El cache resultante se escribe en `config/sources.json`, agrupado por subagente y solo con filas `Status = activa`.
+
+## 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.
+
+Para validar que el entorno apunta al proyecto correcto y que existen las tablas esperadas:
+
+```powershell
+npm run supabase:check
+```
+
+El diagnostico no imprime secretos. Si faltan variables o el proyecto no tiene `public.sources`, `public.runs` y `public.signals`, reporta el problema antes de intentar sincronizar fuentes o persistir snapshots.
## Stack Objetivo
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/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..2d7a068
--- /dev/null
+++ b/app/page.js
@@ -0,0 +1,431 @@
+import Script from "next/script";
+import { loadDashboardData } from "../lib/dashboard.js";
+
+export const dynamic = "force-dynamic";
+
+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 renderNoResultsRow() {
+ return (
+
+ |
+ No hay resultados para esos filtros
+ Ajusta busqueda, impacto minimo, confianza o duplicados.
+ |
+
+ );
+}
+
+function renderTableBody(viewState, dashboard) {
+ 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}%
+
+
+
+
+ |
+
+ {signal.recency_label}
+ {signal.observed_at}
+ |
+
+ source.name).join(", ")}`}
+ >
+ {signal.sources.map((source) => (
+
+ {source.label}
+
+ ))}
+ +{signal.additional_sources}
+
+ |
+
+
+ {duplicateLabel(signal.duplicate_status)}
+
+ |
+
+
+
+ |
+
+ ))}
+ {renderNoResultsRow()}
+ >
+ );
+}
+
+export default async function DashboardPage({ searchParams }) {
+ const params = await searchParams;
+ const dashboard = await loadDashboardData();
+ const requestedState = typeof params?.state === "string" ? params.state : "success";
+ const initialState = Object.hasOwn(stateLabels, requestedState) ? requestedState : "success";
+ const viewState = initialState === "success" && dashboard.signals.length === 0 ? "empty" : initialState;
+ const isInteractive = viewState === "success";
+ const serializedDashboard = JSON.stringify(dashboard).replaceAll("<", "\\u003c");
+ const visibleStart = dashboard.total_signals === 0 ? 0 : 1;
+ const visibleEnd = Math.min(dashboard.page_size, dashboard.total_signals);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ Ranking de senales
+
+
+ {stateLabels[viewState]}
+
+
+
+
+
+
+
+ | # |
+ Senal |
+ Impacto |
+ Confianza |
+ Recencia |
+ Fuentes |
+ Duplicados |
+ Acciones |
+
+
+ {renderTableBody(viewState, dashboard)}
+
+
+
+
+
+ {visibleStart}-{visibleEnd} de {dashboard.total_signals} senales
+
+
+
+
+
+
+
+
+
+ {dashboard.source_health.map((source) => (
+
+
+ {source.label}
+
{source.name}
+
+
+ {source.score}
+ / 100
+
+
+ {source.lag_label}
+
+ ))}
+
+ +{dashboard.summary.extra_sources} fuentes
+ Ver todas
+
+ {dashboard.summary.extra_sources_score}/100
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
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..e262570
--- /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-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.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://openai.com/index/ai-chemist-improves-reaction/"
+ ]
+ },
+ "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": "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-fable-mythos-control-exportacion",
+ "titulo": "Anthropic suspende Fable 5 y Mythos 5 por una directiva del gobierno de EE. UU.",
+ "tema": "regulacion",
+ "fuente": {
+ "nombre": "Anthropic",
+ "url": "https://www.anthropic.com/news/fable-mythos-access",
+ "publicado": "2026-06-12",
+ "consultado": "2026-06-21"
+ },
+ "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-ia",
+ "titulo": "Google DeepMind publica una hoja de ruta para asegurar agentes de IA",
+ "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 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-red-electrica-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": "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": "openai-quimico-autonomo-medicinal",
+ "titulo": "OpenAI muestra un agente casi autonomo que mejora una reaccion de quimica medicinal",
+ "tema": "investigacion aplicada",
+ "fuente": {
+ "nombre": "OpenAI",
+ "url": "https://openai.com/index/ai-chemist-improves-reaction/",
+ "publicado": "2026-06-17",
+ "consultado": "2026-06-21"
+ },
+ "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/fixtures/dashboard.json b/fixtures/dashboard.json
new file mode 100644
index 0000000..989235d
--- /dev/null
+++ b/fixtures/dashboard.json
@@ -0,0 +1,249 @@
+{
+ "contract": "ai-radar.dashboard-fixture.v1",
+ "source": {
+ "type": "fixture",
+ "description": "Datos declarados para construir y validar el dashboard visual inspirado en la referencia. Sustituir por una API server-side cuando existan scores de ranking, confianza, recencia, fuentes y duplicados.",
+ "related_api": "GET /api/signals?fecha=&source_type=&limit="
+ },
+ "generated_at": "2026-06-21T09:47:32.000Z",
+ "updated_label": "09:47:32",
+ "window_label": "Ultimos 7 dias",
+ "total_signals": 128,
+ "page_size": 10,
+ "signals": [
+ {
+ "rank": 1,
+ "slug": "openai-gpt-4o-voz-vision",
+ "title": "OpenAI presenta GPT-4o con capacidades nativas de voz y vision en tiempo real",
+ "category": "Modelo",
+ "topic": "modelos multimodales",
+ "impact_score": 92,
+ "impact_label": "Muy alto",
+ "confidence_score": 92,
+ "confidence_level": "high",
+ "recency_label": "12m",
+ "observed_at": "09:35",
+ "sources": [
+ { "label": "R", "name": "Reuters", "tone": "red" },
+ { "label": "TC", "name": "TechCrunch", "tone": "dark" },
+ { "label": "AI", "name": "OpenAI", "tone": "blue" }
+ ],
+ "additional_sources": 6,
+ "duplicate_status": "unique",
+ "evidence": "La senal combina reportes de prensa tecnica y fuente oficial sobre capacidades nativas de voz, vision y respuesta en tiempo real.",
+ "action": "Evaluar flujos donde la latencia conversacional y entrada multimodal reduzcan pasos manuales."
+ },
+ {
+ "rank": 2,
+ "slug": "deepmind-gemini-contexto-2m",
+ "title": "Google DeepMind lanza Gemini 1.5 Pro con ventana de contexto de 2M tokens",
+ "category": "Modelo",
+ "topic": "contexto largo",
+ "impact_score": 89,
+ "impact_label": "Muy alto",
+ "confidence_score": 88,
+ "confidence_level": "high",
+ "recency_label": "28m",
+ "observed_at": "09:19",
+ "sources": [
+ { "label": "B", "name": "Bloomberg", "tone": "red" },
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "G", "name": "Google", "tone": "orange" }
+ ],
+ "additional_sources": 8,
+ "duplicate_status": "unique",
+ "evidence": "La senal aparece en fuente primaria y cobertura secundaria con foco en contexto largo para analisis de documentos y codigo.",
+ "action": "Probar recuperacion de informacion y analisis de repos grandes contra el limite actual."
+ },
+ {
+ "rank": 3,
+ "slug": "anthropic-claude-3-opus-codigo",
+ "title": "Anthropic introduce Claude 3 Opus con mejoras en razonamiento y codigo",
+ "category": "Modelo",
+ "topic": "razonamiento",
+ "impact_score": 85,
+ "impact_label": "Muy alto",
+ "confidence_score": 86,
+ "confidence_level": "high",
+ "recency_label": "1h 02m",
+ "observed_at": "08:45",
+ "sources": [
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "TC", "name": "TechCrunch", "tone": "dark" },
+ { "label": "A", "name": "Anthropic", "tone": "blue-muted" }
+ ],
+ "additional_sources": 7,
+ "duplicate_status": "possible",
+ "evidence": "La cobertura coincide en avances de razonamiento, tareas de codigo y comparativas con modelos frontier.",
+ "action": "Actualizar benchmarks internos de copilotos y revisar costos por tarea completada."
+ },
+ {
+ "rank": 4,
+ "slug": "microsoft-copilot-gpt-4o-office",
+ "title": "Microsoft integra Copilot con GPT-4o en Office 365 para todos los usuarios",
+ "category": "Producto",
+ "topic": "productividad",
+ "impact_score": 78,
+ "impact_label": "Alto",
+ "confidence_score": 74,
+ "confidence_level": "medium",
+ "recency_label": "1h 18m",
+ "observed_at": "08:29",
+ "sources": [
+ { "label": "MS", "name": "Microsoft", "tone": "green" },
+ { "label": "B", "name": "Bloomberg", "tone": "dark" },
+ { "label": "R", "name": "Reuters", "tone": "light" }
+ ],
+ "additional_sources": 5,
+ "duplicate_status": "unique",
+ "evidence": "La senal conecta anuncio de producto con distribucion masiva en suite de trabajo existente.",
+ "action": "Revisar oportunidades de integracion con documentos, correo y flujos internos de conocimiento."
+ },
+ {
+ "rank": 5,
+ "slug": "meta-llama-3-70b-inferencia",
+ "title": "Meta presenta Llama 3 70B con mejor rendimiento y menor coste de inferencia",
+ "category": "Modelo",
+ "topic": "open models",
+ "impact_score": 74,
+ "impact_label": "Alto",
+ "confidence_score": 71,
+ "confidence_level": "medium",
+ "recency_label": "2h 03m",
+ "observed_at": "07:44",
+ "sources": [
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "TC", "name": "TechCrunch", "tone": "dark" },
+ { "label": "M", "name": "Meta", "tone": "purple" }
+ ],
+ "additional_sources": 6,
+ "duplicate_status": "unique",
+ "evidence": "La senal agrupa fuente de proveedor, prensa tecnica y comparaciones de inferencia.",
+ "action": "Probar casos de uso donde self-hosting y costo por token cambien la decision de proveedor."
+ },
+ {
+ "rank": 6,
+ "slug": "nvidia-blackwell-gb200-entrenamiento",
+ "title": "NVIDIA anuncia chips Blackwell GB200 enfocados en entrenamiento de IA",
+ "category": "Hardware",
+ "topic": "infraestructura",
+ "impact_score": 68,
+ "impact_label": "Alto",
+ "confidence_score": 66,
+ "confidence_level": "medium",
+ "recency_label": "3h 11m",
+ "observed_at": "06:36",
+ "sources": [
+ { "label": "N", "name": "NVIDIA", "tone": "green-dark" },
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "EE", "name": "Enterprise AI", "tone": "blue" }
+ ],
+ "additional_sources": 4,
+ "duplicate_status": "possible",
+ "evidence": "La senal cruza anuncio de hardware con cobertura de disponibilidad para entrenamiento a gran escala.",
+ "action": "Monitorear disponibilidad regional, consumo energetico y cambios en precio de compute."
+ },
+ {
+ "rank": 7,
+ "slug": "perplexity-deep-research-pdf",
+ "title": "Perplexity lanza Deep Research con citas verificadas y exportacion a PDF",
+ "category": "Producto",
+ "topic": "research workflow",
+ "impact_score": 62,
+ "impact_label": "Medio",
+ "confidence_score": 60,
+ "confidence_level": "medium",
+ "recency_label": "4h 02m",
+ "observed_at": "05:45",
+ "sources": [
+ { "label": "P", "name": "Perplexity", "tone": "dark" },
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "X", "name": "X", "tone": "blue" }
+ ],
+ "additional_sources": 3,
+ "duplicate_status": "unique",
+ "evidence": "La senal se apoya en lanzamiento de producto y capturas de usuarios sobre flujo de investigacion.",
+ "action": "Comparar calidad de citas y exportacion frente a flujos internos de analisis."
+ },
+ {
+ "rank": 8,
+ "slug": "apple-modelos-generativos-ios-18",
+ "title": "Apple explora integrar modelos generativos en iOS 18, segun Bloomberg",
+ "category": "Estrategia",
+ "topic": "distribucion movil",
+ "impact_score": 58,
+ "impact_label": "Medio",
+ "confidence_score": 55,
+ "confidence_level": "medium",
+ "recency_label": "5h 27m",
+ "observed_at": "04:20",
+ "sources": [
+ { "label": "BBG", "name": "Bloomberg", "tone": "dark" },
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "M", "name": "MacRumors", "tone": "blue-muted" }
+ ],
+ "additional_sources": 6,
+ "duplicate_status": "possible",
+ "evidence": "La senal es secundaria y depende de reportes sobre integracion futura en sistema operativo.",
+ "action": "Seguir APIs de desarrollador antes de comprometer roadmap de producto movil."
+ },
+ {
+ "rank": 9,
+ "slug": "aws-bedrock-agents-orquestacion",
+ "title": "AWS lanza Bedrock Agents para orquestacion segura de agentes de IA",
+ "category": "Producto",
+ "topic": "agentes",
+ "impact_score": 54,
+ "impact_label": "Medio",
+ "confidence_score": 53,
+ "confidence_level": "medium",
+ "recency_label": "6h 10m",
+ "observed_at": "03:37",
+ "sources": [
+ { "label": "A", "name": "AWS", "tone": "blue" },
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "TC", "name": "TechCrunch", "tone": "dark" }
+ ],
+ "additional_sources": 4,
+ "duplicate_status": "unique",
+ "evidence": "La senal combina anuncio cloud con foco en permisos, herramientas y despliegue empresarial.",
+ "action": "Evaluar si la orquestacion gestionada reduce riesgo operativo frente a agentes propios."
+ },
+ {
+ "rank": 10,
+ "slug": "eu-ai-act-guias-alto-riesgo",
+ "title": "EU AI Act entra en vigor con primeras guias para sistemas de alto riesgo",
+ "category": "Politica",
+ "topic": "regulacion",
+ "impact_score": 49,
+ "impact_label": "Medio",
+ "confidence_score": 48,
+ "confidence_level": "low",
+ "recency_label": "7h 42m",
+ "observed_at": "02:05",
+ "sources": [
+ { "label": "EU", "name": "European Union", "tone": "navy" },
+ { "label": "R", "name": "Reuters", "tone": "light" },
+ { "label": "FT", "name": "Financial Times", "tone": "orange" }
+ ],
+ "additional_sources": 5,
+ "duplicate_status": "unique",
+ "evidence": "La senal resume cobertura legal y primeras guias para sistemas clasificados como alto riesgo.",
+ "action": "Revisar inventario de funciones con decision automatizada y documentar controles de cumplimiento."
+ }
+ ],
+ "source_health": [
+ { "name": "Reuters", "label": "R", "score": 98, "lag_label": "12m", "tone": "red", "trend": [88, 89, 89, 88, 90, 89, 91, 90, 92, 91, 92, 93] },
+ { "name": "TechCrunch", "label": "TC", "score": 96, "lag_label": "8m", "tone": "green", "trend": [84, 86, 85, 87, 87, 86, 85, 85, 84, 83, 83, 82] },
+ { "name": "The Verge", "label": "V", "score": 92, "lag_label": "15m", "tone": "purple", "trend": [75, 76, 77, 76, 78, 77, 77, 76, 75, 76, 78, 78] },
+ { "name": "Bloomberg", "label": "B", "score": 90, "lag_label": "22m", "tone": "dark", "trend": [72, 73, 73, 74, 75, 74, 73, 72, 70, 69, 68, 67] },
+ { "name": "Wired", "label": "W", "score": 88, "lag_label": "18m", "tone": "dark", "trend": [68, 69, 70, 70, 71, 70, 69, 70, 68, 67, 66, 66] },
+ { "name": "arXiv", "label": "X", "score": 85, "lag_label": "30m", "tone": "light", "trend": [63, 64, 65, 64, 66, 67, 66, 65, 66, 67, 68, 68] },
+ { "name": "X (Twitter)", "label": "X", "score": 78, "lag_label": "5m", "tone": "light", "trend": [58, 59, 61, 60, 60, 59, 57, 56, 55, 54, 53, 53] },
+ { "name": "YouTube", "label": "YT", "score": 74, "lag_label": "6m", "tone": "red", "trend": [52, 53, 54, 55, 55, 54, 53, 52, 51, 50, 50, 49] }
+ ],
+ "summary": {
+ "extra_sources": 12,
+ "extra_sources_score": 87
+ }
+}
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/dashboard.js b/lib/dashboard.js
new file mode 100644
index 0000000..5109fa6
--- /dev/null
+++ b/lib/dashboard.js
@@ -0,0 +1,197 @@
+import { createRequire } from "node:module";
+
+import { getSupabaseAdmin } from "./supabase/client.js";
+import { getSupabaseEnvStatus } from "./supabase/diagnostics.js";
+import { listSignals } from "./supabase/signals.js";
+import { listSources } from "./supabase/sources.js";
+
+const require = createRequire(import.meta.url);
+const fixtureDashboard = require("../fixtures/dashboard.json");
+
+const STATUS_TO_CATEGORY = {
+ alta_prioridad_activo: "Prioridad",
+ riesgo_regulatorio_en_desarrollo: "Politica",
+ senal_tecnica_accionable: "Tecnica",
+ infraestructura_critica_activo: "Infraestructura",
+ estrategica_emergente: "Estrategia",
+ observacion: "Observacion",
+};
+
+const STATUS_TO_IMPACT = {
+ alta_prioridad_activo: 92,
+ riesgo_regulatorio_en_desarrollo: 82,
+ senal_tecnica_accionable: 78,
+ infraestructura_critica_activo: 74,
+ estrategica_emergente: 68,
+ observacion: 52,
+};
+
+const SOURCE_TONES = ["red", "green", "purple", "dark", "blue", "orange", "green-dark", "light", "navy"];
+
+export async function loadDashboardData(options = {}) {
+ const envStatus = getSupabaseEnvStatus(options.env);
+ if (!envStatus.variables.SUPABASE_URL || !envStatus.variables.SUPABASE_SERVICE_ROLE_KEY) {
+ return fallbackDashboard(`faltan variables server-side: ${envStatus.missing.join(", ")}`);
+ }
+
+ try {
+ const supabase = getSupabaseAdmin();
+ const [signals, sources] = await Promise.all([
+ listSignals(supabase, { limit: 10 }),
+ listSources(supabase, { status: "activa", limit: 20 }),
+ ]);
+ return buildDashboardFromSupabaseRows({ signals, sources });
+ } catch (error) {
+ return fallbackDashboard(`Supabase no disponible: ${error.message}`);
+ }
+}
+
+export function buildDashboardFromSupabaseRows({ signals = [], sources = [] }, options = {}) {
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
+ const dashboardSignals = signals.map((signal, index) => signalToDashboardSignal(signal, index));
+ const sourceHealth = sourcesToHealth(sources, signals);
+
+ return {
+ contract: "ai-radar.dashboard-api.v1",
+ source: {
+ type: "api",
+ description:
+ "Datos leidos server-side desde Supabase. Los scores visuales se derivan de status y completitud hasta que exista ranking persistido.",
+ related_api: "GET /api/signals?fecha=&source_type=&limit=",
+ },
+ generated_at: generatedAt,
+ updated_label: timeLabel(generatedAt),
+ window_label: "Ultimos registros persistidos",
+ total_signals: dashboardSignals.length,
+ page_size: dashboardSignals.length,
+ signals: dashboardSignals,
+ source_health: sourceHealth,
+ summary: {
+ extra_sources: Math.max(0, sources.length - sourceHealth.length),
+ extra_sources_score: sourceHealth.length ? roundedAverage(sourceHealth.map((source) => source.score)) : 0,
+ },
+ };
+}
+
+function fallbackDashboard(reason) {
+ const dashboard = structuredClone(fixtureDashboard);
+ dashboard.source = {
+ ...dashboard.source,
+ fallback_reason: reason,
+ };
+ return dashboard;
+}
+
+function signalToDashboardSignal(signal, index) {
+ const impactScore = STATUS_TO_IMPACT[signal.status] ?? 50;
+ const sourceName = signal.source_name;
+ const confidenceScore = confidenceForSignal(signal);
+
+ return {
+ rank: index + 1,
+ slug: signal.slug,
+ title: signal.title,
+ category: STATUS_TO_CATEGORY[signal.status] ?? "Senal",
+ topic: signal.topic ?? "sin tema",
+ impact_score: impactScore,
+ impact_label: impactScore >= 80 ? "Muy alto" : impactScore >= 60 ? "Alto" : "Medio",
+ confidence_score: confidenceScore,
+ confidence_level: confidenceScore >= 80 ? "high" : confidenceScore >= 55 ? "medium" : "low",
+ recency_label: recencyLabel(signal.published_on ?? signal.consulted_on),
+ observed_at: signal.consulted_on,
+ sources: [
+ {
+ label: sourceLabel(sourceName),
+ name: sourceName,
+ tone: SOURCE_TONES[index % SOURCE_TONES.length],
+ },
+ ],
+ additional_sources: 0,
+ duplicate_status: "unique",
+ evidence: signal.evidence,
+ action: signal.action,
+ };
+}
+
+function sourcesToHealth(sources, signals) {
+ const byName = new Map();
+ for (const source of sources) {
+ byName.set(source.name, source);
+ }
+ for (const signal of signals) {
+ if (!byName.has(signal.source_name)) {
+ byName.set(signal.source_name, {
+ name: signal.source_name,
+ url: signal.source_url,
+ type: signal.source_type,
+ status: "activa",
+ });
+ }
+ }
+
+ return [...byName.values()].slice(0, 8).map((source, index) => {
+ const score = source.status === "activa" ? 86 : 62;
+ return {
+ name: source.name,
+ label: sourceLabel(source.name),
+ score,
+ lag_label: source.synced_at ? recencyLabel(source.synced_at) : "sin sync",
+ tone: SOURCE_TONES[index % SOURCE_TONES.length],
+ trend: trendForScore(score),
+ };
+ });
+}
+
+function confidenceForSignal(signal) {
+ let score = 48;
+ if (signal.source_url) score += 18;
+ if (signal.published_on) score += 14;
+ if (signal.evidence?.length > 80) score += 10;
+ if (signal.action?.length > 30) score += 10;
+ return Math.min(96, score);
+}
+
+function sourceLabel(name) {
+ return String(name)
+ .split(/[\s/-]+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? "")
+ .join("");
+}
+
+function trendForScore(score) {
+ return Array.from({ length: 12 }, (_, index) => Math.max(20, Math.min(99, score - 5 + (index % 4) * 2)));
+}
+
+function recencyLabel(value) {
+ if (!value) {
+ return "sin fecha";
+ }
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return "sin fecha";
+ }
+
+ const days = Math.max(0, Math.round((Date.now() - parsed.getTime()) / 86_400_000));
+ if (days === 0) {
+ return "hoy";
+ }
+ if (days === 1) {
+ return "1d";
+ }
+ return `${days}d`;
+}
+
+function timeLabel(value) {
+ return new Intl.DateTimeFormat("es", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ }).format(new Date(value));
+}
+
+function roundedAverage(values) {
+ return Math.round(values.reduce((total, value) => total + value, 0) / values.length);
+}
diff --git a/lib/env.js b/lib/env.js
new file mode 100644
index 0000000..e4959f4
--- /dev/null
+++ b/lib/env.js
@@ -0,0 +1,45 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+export function loadLocalEnv(path = ".env.local") {
+ const envPath = resolve(path);
+ let content;
+ try {
+ content = readFileSync(envPath, "utf8");
+ } catch (error) {
+ if (error.code === "ENOENT") {
+ return { loaded: false, path: envPath };
+ }
+ throw error;
+ }
+
+ for (const line of content.split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) {
+ continue;
+ }
+
+ const separator = trimmed.indexOf("=");
+ if (separator === -1) {
+ continue;
+ }
+
+ const key = trimmed.slice(0, separator).trim();
+ const value = unquote(trimmed.slice(separator + 1).trim());
+ if (key && process.env[key] === undefined) {
+ process.env[key] = value;
+ }
+ }
+
+ return { loaded: true, path: envPath };
+}
+
+function unquote(value) {
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ return value.slice(1, -1);
+ }
+ return value;
+}
diff --git a/lib/sources-cache.js b/lib/sources-cache.js
new file mode 100644
index 0000000..84cf249
--- /dev/null
+++ b/lib/sources-cache.js
@@ -0,0 +1,145 @@
+export const TYPE_TO_SUBAGENT = {
+ fuente_oficial: "ai-radar-fuentes-oficiales",
+ repo_tecnico: "ai-radar-repo-tecnico",
+ comunidad: "ai-radar-comunidad",
+ medios_secundario: "ai-radar-medios-secundarios",
+};
+
+const SOURCE_FIELDS = [
+ "name",
+ "type",
+ "status",
+ "url",
+ "priority",
+ "cadence",
+ "notes",
+ "notion_page_url",
+];
+
+export function buildSourcesCache(sources, options = {}) {
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
+ const normalized = dedupeSources(
+ sources
+ .map(normalizeSourceRecord)
+ .filter(Boolean)
+ .filter((source) => source.status === "activa" && TYPE_TO_SUBAGENT[source.type]),
+ );
+ const groups = Object.fromEntries(Object.values(TYPE_TO_SUBAGENT).map((id) => [id, []]));
+
+ for (const source of normalized) {
+ groups[TYPE_TO_SUBAGENT[source.type]].push(source);
+ }
+
+ for (const group of Object.values(groups)) {
+ group.sort((left, right) => left.name.localeCompare(right.name));
+ }
+
+ return stripEmpty({
+ contrato: "ai-radar.sources-cache.v1",
+ generado_en: generatedAt,
+ origen: options.origin ?? "notion",
+ notion_database: options.notionDatabase ?? "AI radar Sources",
+ notion_database_url: options.notionDatabaseUrl,
+ notion_data_source_url: options.notionDataSourceUrl,
+ notion_consultado_en: options.notionConsultedAt ?? generatedAt,
+ type_to_subagent: TYPE_TO_SUBAGENT,
+ fuentes_por_subagente: groups,
+ });
+}
+
+export function sourceFromNotionApiPage(page) {
+ const properties = page?.properties;
+ if (!properties || typeof properties !== "object") {
+ return null;
+ }
+
+ return normalizeSourceRecord({
+ name: titleValue(properties.Name),
+ type: optionValue(properties.Type),
+ status: optionValue(properties.Status),
+ url: urlValue(properties.URL ?? properties["userDefined:URL"]),
+ priority: optionValue(properties.Priority),
+ cadence: optionValue(properties.Cadence),
+ notes: richTextValue(properties.Notes),
+ notion_page_url: page.url,
+ });
+}
+
+export function sourceFromNotionMcpFetchText(text) {
+ const match = String(text).match(/\s*([\s\S]*?)\s*<\/properties>/);
+ if (!match) {
+ return null;
+ }
+
+ const properties = JSON.parse(match[1]);
+ return sourceFromFlatNotionProperties(properties);
+}
+
+export function sourceFromFlatNotionProperties(properties) {
+ if (!properties || typeof properties !== "object") {
+ return null;
+ }
+
+ return normalizeSourceRecord({
+ name: properties.Name,
+ type: properties.Type,
+ status: properties.Status,
+ url: properties["userDefined:URL"] ?? properties.URL,
+ priority: properties.Priority,
+ cadence: properties.Cadence,
+ notes: properties.Notes,
+ notion_page_url: properties.url,
+ });
+}
+
+export function normalizeSourceRecord(source) {
+ if (!source || typeof source !== "object") {
+ return null;
+ }
+
+ const normalized = {};
+ for (const field of SOURCE_FIELDS) {
+ const value = source[field];
+ if (typeof value === "string" && value.trim()) {
+ normalized[field] = value.trim();
+ }
+ }
+
+ if (!normalized.name || !normalized.url) {
+ return null;
+ }
+
+ return normalized;
+}
+
+function dedupeSources(sources) {
+ const deduped = new Map();
+ for (const source of sources) {
+ deduped.set(source.notion_page_url ?? source.url, source);
+ }
+ return [...deduped.values()];
+}
+
+function stripEmpty(record) {
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null));
+}
+
+function titleValue(property) {
+ return richTextArrayValue(property?.title);
+}
+
+function richTextValue(property) {
+ return richTextArrayValue(property?.rich_text);
+}
+
+function richTextArrayValue(items) {
+ return Array.isArray(items) ? items.map((item) => item.plain_text ?? "").join("").trim() : "";
+}
+
+function optionValue(property) {
+ return property?.select?.name ?? property?.status?.name ?? "";
+}
+
+function urlValue(property) {
+ return property?.url ?? "";
+}
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/diagnostics.js b/lib/supabase/diagnostics.js
new file mode 100644
index 0000000..4362e26
--- /dev/null
+++ b/lib/supabase/diagnostics.js
@@ -0,0 +1,53 @@
+export const REQUIRED_SUPABASE_ENV = ["SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "AI_RADAR_API_TOKEN"];
+
+export const REQUIRED_SUPABASE_TABLES = [
+ {
+ name: "sources",
+ columns: "id, notion_page_url, name, type, url, status, priority, cadence, notes, source_of_truth, synced_at",
+ },
+ {
+ name: "runs",
+ columns: "id, query, window_start, window_end, status, sources_cache_status, fallback_report, generated_at",
+ },
+ {
+ name: "signals",
+ columns:
+ "id, run_id, source_id, slug, title, topic, source_name, source_url, published_on, consulted_on, evidence, impact, action, status, raw",
+ },
+];
+
+export function getSupabaseEnvStatus(env = process.env) {
+ const variables = Object.fromEntries(
+ REQUIRED_SUPABASE_ENV.map((name) => [name, Boolean(env[name] && String(env[name]).trim())]),
+ );
+ const missing = Object.entries(variables)
+ .filter(([, isSet]) => !isSet)
+ .map(([name]) => name);
+
+ return {
+ ok: missing.length === 0,
+ variables,
+ missing,
+ };
+}
+
+export async function checkSupabaseTables(supabase) {
+ const tables = {};
+ for (const table of REQUIRED_SUPABASE_TABLES) {
+ const { error } = await supabase.from(table.name).select(table.columns).limit(1);
+ tables[table.name] = error
+ ? {
+ ok: false,
+ code: error.code,
+ message: error.message,
+ }
+ : {
+ ok: true,
+ };
+ }
+
+ return {
+ ok: Object.values(tables).every((table) => table.ok),
+ tables,
+ };
+}
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..e4384f0
--- /dev/null
+++ b/lib/supabase/sources.js
@@ -0,0 +1,117 @@
+export async function syncSources(supabase, sources) {
+ const synced = [];
+
+ for (const source of sources) {
+ synced.push(await upsertSource(supabase, source));
+ }
+
+ return synced;
+}
+
+export async function listSources(supabase, query = {}) {
+ let builder = supabase
+ .from("sources")
+ .select("id, notion_page_url, name, type, url, status, priority, cadence, notes, source_of_truth, synced_at")
+ .order("synced_at", { ascending: false })
+ .limit(query.limit ?? 20);
+
+ if (query.status) {
+ builder = builder.eq("status", query.status);
+ }
+
+ const { data, error } = await builder;
+ if (error) {
+ throw new Error(`no se pudieron consultar sources: ${error.message}`);
+ }
+
+ return data;
+}
+
+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..c122ec8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "ai-radar",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "test": "node --test",
+ "sources:refresh": "node scripts/refresh_sources_cache.js",
+ "supabase:check": "node scripts/check_supabase_config.js"
+ },
+ "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/public/dashboard.js b/public/dashboard.js
new file mode 100644
index 0000000..8a05262
--- /dev/null
+++ b/public/dashboard.js
@@ -0,0 +1,165 @@
+(() => {
+ const dataScript = document.querySelector("#dashboard-data");
+ const dashboard = dataScript ? JSON.parse(dataScript.value || dataScript.textContent) : null;
+ const liveStatus = document.querySelector("[data-live-status]");
+ const rows = [...document.querySelectorAll("[data-row]")];
+ const countLabel = document.querySelector("[data-count-label]");
+ const searchInput = document.querySelector("[data-search-input]");
+ const sourceFilter = document.querySelector("[data-source-filter]");
+ const topicFilter = document.querySelector("[data-topic-filter]");
+ const categoryFilter = document.querySelector("[data-category-filter]");
+ const confidenceFilter = document.querySelector("[data-confidence-filter]");
+ const impactFilter = document.querySelector("[data-impact-filter]");
+ const duplicatesFilter = document.querySelector("[data-duplicates-filter]");
+ const resetButton = document.querySelector("[data-reset-filters]");
+ const snapshotButton = document.querySelector("[data-snapshot-button]");
+ const alertButton = document.querySelector("[data-alert-button]");
+ const dialog = document.querySelector("[data-evidence-dialog]");
+
+ if (!dashboard || rows.length === 0) {
+ return;
+ }
+
+ const tableBody = document.querySelector("[data-table-body]");
+ const noResultsRow = document.querySelector("[data-no-results-row]");
+
+ function normalize(value) {
+ return value.trim().toLowerCase();
+ }
+
+ function updateStatus(message) {
+ if (liveStatus) {
+ liveStatus.textContent = message;
+ }
+ }
+
+ function applyFilters() {
+ const query = normalize(searchInput.value);
+ const source = sourceFilter.value;
+ const topic = topicFilter.value;
+ const category = categoryFilter.value;
+ const confidence = Number(confidenceFilter.value);
+ const impact = Number(impactFilter.value);
+ const onlyDuplicates = duplicatesFilter.checked;
+ let visible = 0;
+
+ for (const row of rows) {
+ const matchesQuery =
+ !query ||
+ row.dataset.title.includes(query) ||
+ row.dataset.topic.includes(query) ||
+ row.dataset.sources.includes(query);
+ const matchesSource = source === "all" || row.dataset.sources.includes(source);
+ const matchesTopic = topic === "all" || row.dataset.topic === topic;
+ const matchesCategory = category === "all" || row.dataset.category === category;
+ const matchesConfidence = Number(row.dataset.confidence) >= confidence;
+ const matchesImpact = Number(row.dataset.impact) >= impact;
+ const matchesDuplicate = !onlyDuplicates || row.dataset.duplicate === "possible";
+ const isVisible =
+ matchesQuery &&
+ matchesSource &&
+ matchesTopic &&
+ matchesCategory &&
+ matchesConfidence &&
+ matchesImpact &&
+ matchesDuplicate;
+
+ row.hidden = !isVisible;
+ if (isVisible) {
+ visible += 1;
+ }
+ }
+
+ noResultsRow.classList.toggle("is-visible", visible === 0);
+ countLabel.textContent =
+ visible === 0
+ ? `0 de ${dashboard.total_signals} senales`
+ : `1-${visible} de ${dashboard.total_signals} senales`;
+ updateStatus(
+ visible === 0
+ ? "No hay resultados para los filtros activos"
+ : `${visible} senales visibles con los filtros activos`,
+ );
+ }
+
+ function resetFilters() {
+ searchInput.value = "";
+ sourceFilter.value = "all";
+ topicFilter.value = "all";
+ categoryFilter.value = "all";
+ confidenceFilter.value = "0";
+ impactFilter.value = "0";
+ duplicatesFilter.checked = false;
+ applyFilters();
+ searchInput.focus();
+ }
+
+ function signalBySlug(slug) {
+ return dashboard.signals.find((signal) => signal.slug === slug);
+ }
+
+ function openEvidence(slug) {
+ const signal = signalBySlug(slug);
+ if (!signal || !dialog) {
+ return;
+ }
+
+ dialog.querySelector("[data-dialog-title]").textContent = signal.title;
+ dialog.querySelector("[data-dialog-evidence]").textContent = signal.evidence;
+ dialog.querySelector("[data-dialog-action]").textContent = signal.action;
+ dialog.showModal();
+ }
+
+ function downloadSnapshot() {
+ const payload = {
+ contract: dashboard.contract,
+ generated_at: new Date().toISOString(),
+ source: dashboard.source,
+ visible_signals: rows
+ .filter((row) => !row.hidden)
+ .map((row) => signalBySlug(row.querySelector("[data-evidence-button]").dataset.slug)),
+ };
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = "ai-radar-dashboard-snapshot.json";
+ link.click();
+ URL.revokeObjectURL(link.href);
+ updateStatus("Snapshot descargado con las senales visibles");
+ }
+
+ for (const control of [
+ searchInput,
+ sourceFilter,
+ topicFilter,
+ categoryFilter,
+ confidenceFilter,
+ impactFilter,
+ duplicatesFilter,
+ ]) {
+ control.addEventListener("input", applyFilters);
+ control.addEventListener("change", applyFilters);
+ }
+
+ resetButton.addEventListener("click", resetFilters);
+ snapshotButton.addEventListener("click", downloadSnapshot);
+ alertButton.addEventListener("click", () => {
+ updateStatus("Hay 12 alertas pendientes de revision operativa");
+ });
+
+ for (const button of document.querySelectorAll("[data-evidence-button]")) {
+ button.addEventListener("click", () => openEvidence(button.dataset.slug));
+ }
+
+ for (const button of document.querySelectorAll("[data-mode-button]")) {
+ button.addEventListener("click", () => {
+ for (const peer of document.querySelectorAll("[data-mode-button]")) {
+ peer.classList.toggle("is-active", peer === button);
+ peer.setAttribute("aria-pressed", String(peer === button));
+ }
+ updateStatus(`Vista cambiada a ${button.textContent.trim()}`);
+ });
+ }
+
+ document.documentElement.dataset.dashboardReady = "true";
+})();
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..f058158
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,5 @@
+
diff --git a/scripts/check_supabase_config.js b/scripts/check_supabase_config.js
new file mode 100644
index 0000000..4b22a07
--- /dev/null
+++ b/scripts/check_supabase_config.js
@@ -0,0 +1,42 @@
+#!/usr/bin/env node
+import { createClient } from "@supabase/supabase-js";
+
+import { loadLocalEnv } from "../lib/env.js";
+import { checkSupabaseTables, getSupabaseEnvStatus } from "../lib/supabase/diagnostics.js";
+
+loadLocalEnv();
+
+const args = new Set(process.argv.slice(2));
+
+if (args.has("--help") || args.has("-h")) {
+ console.log(`uso: node scripts/check_supabase_config.js [--no-network]
+
+Valida que el entorno server-side de Supabase este listo para AI Radar.
+No imprime secretos.
+`);
+ process.exit(0);
+}
+
+const env = getSupabaseEnvStatus();
+const result = {
+ env,
+ network_checked: false,
+ tables: null,
+};
+
+if (env.ok && !args.has("--no-network")) {
+ const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY, {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ },
+ });
+ result.network_checked = true;
+ result.tables = await checkSupabaseTables(supabase);
+}
+
+console.log(JSON.stringify(result, null, 2));
+
+if (!env.ok || (result.tables && !result.tables.ok)) {
+ process.exit(1);
+}
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())
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())
diff --git a/scripts/persist_daily_snapshot.js b/scripts/persist_daily_snapshot.js
new file mode 100644
index 0000000..634149e
--- /dev/null
+++ b/scripts/persist_daily_snapshot.js
@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+import { loadLocalEnv } from "../lib/env.js";
+
+loadLocalEnv();
+
+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/refresh_sources_cache.js b/scripts/refresh_sources_cache.js
new file mode 100644
index 0000000..7f800f2
--- /dev/null
+++ b/scripts/refresh_sources_cache.js
@@ -0,0 +1,148 @@
+#!/usr/bin/env node
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+
+import { loadLocalEnv } from "../lib/env.js";
+import { buildSourcesCache, sourceFromNotionApiPage } from "../lib/sources-cache.js";
+
+loadLocalEnv();
+
+const args = parseArgs(process.argv.slice(2));
+
+if (args.help) {
+ printHelp();
+ process.exit(0);
+}
+
+const notionToken = process.env.NOTION_API_KEY ?? process.env.NOTION_TOKEN;
+const notionDataSourceId = args.dataSourceId ?? process.env.NOTION_DATA_SOURCE_ID;
+const notionDatabaseId = args.databaseId ?? process.env.NOTION_DATABASE_ID;
+const outputPath = resolve(args.output ?? "config/sources.json");
+
+if (!notionToken) {
+ console.error("error: NOTION_API_KEY es requerido");
+ process.exit(1);
+}
+
+if (!notionDataSourceId && !notionDatabaseId) {
+ console.error("error: NOTION_DATA_SOURCE_ID o NOTION_DATABASE_ID es requerido");
+ process.exit(1);
+}
+
+const mode = notionDataSourceId ? "data_source" : "database";
+const notionVersion = process.env.NOTION_VERSION ?? (mode === "data_source" ? "2025-09-03" : "2022-06-28");
+const endpoint =
+ mode === "data_source"
+ ? `https://api.notion.com/v1/data_sources/${notionDataSourceId}/query`
+ : `https://api.notion.com/v1/databases/${notionDatabaseId}/query`;
+
+const pages = await queryAllPages(endpoint, notionToken, notionVersion);
+const generatedAt = new Date().toISOString();
+const notionDataSourceUrl =
+ args.dataSourceUrl ??
+ process.env.NOTION_DATA_SOURCE_URL ??
+ (notionDataSourceId ? `collection://${notionDataSourceId}` : undefined);
+const cache = buildSourcesCache(pages.map(sourceFromNotionApiPage), {
+ generatedAt,
+ notionDatabaseUrl: args.databaseUrl ?? process.env.NOTION_DATABASE_URL,
+ notionDataSourceUrl,
+});
+
+await mkdir(dirname(outputPath), { recursive: true });
+await writeFile(outputPath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
+
+console.log(
+ JSON.stringify(
+ {
+ output: outputPath,
+ mode,
+ notion_version: notionVersion,
+ pages_count: pages.length,
+ active_sources_count: Object.values(cache.fuentes_por_subagente).flat().length,
+ },
+ null,
+ 2,
+ ),
+);
+
+async function queryAllPages(endpointUrl, token, notionVersionHeader) {
+ const pages = [];
+ let startCursor;
+
+ do {
+ const response = await fetch(endpointUrl, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${token}`,
+ "content-type": "application/json",
+ "notion-version": notionVersionHeader,
+ },
+ body: JSON.stringify({
+ page_size: 100,
+ ...(startCursor ? { start_cursor: startCursor } : {}),
+ filter: {
+ property: "Status",
+ select: {
+ equals: "activa",
+ },
+ },
+ sorts: [
+ {
+ property: "Name",
+ direction: "ascending",
+ },
+ ],
+ }),
+ });
+
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(`Notion respondio ${response.status}: ${payload.message ?? JSON.stringify(payload)}`);
+ }
+
+ pages.push(...(payload.results ?? []));
+ startCursor = payload.has_more ? payload.next_cursor : null;
+ } while (startCursor);
+
+ return pages;
+}
+
+function parseArgs(rawArgs) {
+ const parsed = {};
+ for (let index = 0; index < rawArgs.length; index += 1) {
+ const arg = rawArgs[index];
+ if (arg === "--help" || arg === "-h") {
+ parsed.help = true;
+ } else if (arg === "--output") {
+ parsed.output = rawArgs[++index];
+ } else if (arg === "--data-source-id") {
+ parsed.dataSourceId = rawArgs[++index];
+ } else if (arg === "--database-id") {
+ parsed.databaseId = rawArgs[++index];
+ } else if (arg === "--database-url") {
+ parsed.databaseUrl = rawArgs[++index];
+ } else if (arg === "--data-source-url") {
+ parsed.dataSourceUrl = rawArgs[++index];
+ } else {
+ throw new Error(`argumento no soportado: ${arg}`);
+ }
+ }
+ return parsed;
+}
+
+function printHelp() {
+ console.log(`uso: node scripts/refresh_sources_cache.js [opciones]
+
+Opciones:
+ --output Ruta de salida. Default: config/sources.json
+ --data-source-id ID de data source de Notion. Default: NOTION_DATA_SOURCE_ID
+ --database-id ID legacy de database de Notion. Default: NOTION_DATABASE_ID
+ --database-url URL de la database para metadata del cache.
+ --data-source-url URL collection://... para metadata del cache.
+
+Variables:
+ NOTION_API_KEY Token de integracion de Notion.
+ NOTION_DATA_SOURCE_ID Preferido para Notion API 2025-09-03.
+ NOTION_DATABASE_ID Fallback legacy para Notion API 2022-06-28.
+`);
+}
diff --git a/scripts/sync_sources.js b/scripts/sync_sources.js
new file mode 100644
index 0000000..05c08dc
--- /dev/null
+++ b/scripts/sync_sources.js
@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+import { loadLocalEnv } from "../lib/env.js";
+
+loadLocalEnv();
+
+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/dashboard-data.test.js b/tests/dashboard-data.test.js
new file mode 100644
index 0000000..a1ff9c6
--- /dev/null
+++ b/tests/dashboard-data.test.js
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { buildDashboardFromSupabaseRows, loadDashboardData } from "../lib/dashboard.js";
+
+test("dashboard usa fixture declarado cuando falta Supabase server-side", async () => {
+ const dashboard = await loadDashboardData({
+ env: {
+ AI_RADAR_API_TOKEN: "token",
+ },
+ });
+
+ assert.equal(dashboard.contract, "ai-radar.dashboard-fixture.v1");
+ assert.equal(dashboard.source.type, "fixture");
+ assert.match(dashboard.source.fallback_reason, /SUPABASE_URL/);
+});
+
+test("dashboard adapta signals y sources persistidos desde Supabase", () => {
+ const dashboard = buildDashboardFromSupabaseRows(
+ {
+ signals: [
+ {
+ slug: "openai-example",
+ title: "OpenAI publica una novedad verificable",
+ topic: "modelos",
+ source_name: "OpenAI News",
+ source_url: "https://openai.com/news/",
+ published_on: "2026-06-21",
+ consulted_on: "2026-06-21",
+ evidence: "La fuente primaria describe el cambio con detalles suficientes para builders.",
+ impact: "Afecta decisiones de producto y evaluacion tecnica.",
+ action: "Probar el cambio en un flujo interno acotado.",
+ status: "alta_prioridad_activo",
+ },
+ ],
+ sources: [
+ {
+ name: "OpenAI News",
+ url: "https://openai.com/news/",
+ status: "activa",
+ synced_at: "2026-06-21T12:00:00.000Z",
+ },
+ ],
+ },
+ { generatedAt: "2026-06-21T12:00:00.000Z" },
+ );
+
+ assert.equal(dashboard.contract, "ai-radar.dashboard-api.v1");
+ assert.equal(dashboard.source.type, "api");
+ assert.equal(dashboard.signals[0].title, "OpenAI publica una novedad verificable");
+ assert.equal(dashboard.source_health[0].name, "OpenAI News");
+});
diff --git a/tests/dashboard-fixture.test.js b/tests/dashboard-fixture.test.js
new file mode 100644
index 0000000..d209913
--- /dev/null
+++ b/tests/dashboard-fixture.test.js
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+test("fixture de dashboard declara datos suficientes para la UI", async () => {
+ const dashboard = JSON.parse(
+ await readFile(new URL("../fixtures/dashboard.json", import.meta.url), "utf8"),
+ );
+
+ assert.equal(dashboard.contract, "ai-radar.dashboard-fixture.v1");
+ assert.equal(dashboard.source.type, "fixture");
+ assert.equal(dashboard.signals.length, dashboard.page_size);
+ assert.ok(dashboard.total_signals >= dashboard.signals.length);
+ assert.ok(dashboard.source_health.length >= 1);
+
+ for (const signal of dashboard.signals) {
+ assert.equal(typeof signal.slug, "string");
+ assert.equal(typeof signal.title, "string");
+ assert.equal(typeof signal.category, "string");
+ assert.ok(Number.isInteger(signal.impact_score));
+ assert.ok(signal.impact_score >= 0 && signal.impact_score <= 100);
+ assert.ok(Number.isInteger(signal.confidence_score));
+ assert.ok(signal.confidence_score >= 0 && signal.confidence_score <= 100);
+ assert.ok(["high", "medium", "low"].includes(signal.confidence_level));
+ assert.ok(["unique", "possible"].includes(signal.duplicate_status));
+ assert.ok(Array.isArray(signal.sources));
+ assert.ok(signal.sources.length >= 1);
+ }
+});
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/env.test.js b/tests/env.test.js
new file mode 100644
index 0000000..b7ef96b
--- /dev/null
+++ b/tests/env.test.js
@@ -0,0 +1,43 @@
+import assert from "node:assert/strict";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import test from "node:test";
+
+import { loadLocalEnv } from "../lib/env.js";
+
+test("carga .env.local sin sobrescribir variables existentes", async () => {
+ const previous = process.env.AI_RADAR_TEST_ENV;
+ const dir = await mkdtemp(join(tmpdir(), "airadar-env-"));
+ const envPath = join(dir, ".env.local");
+ process.env.AI_RADAR_TEST_ENV = "existing";
+
+ try {
+ await writeFile(
+ envPath,
+ [
+ "# comentario",
+ "AI_RADAR_TEST_ENV=from-file",
+ "AI_RADAR_TEST_NEW=value",
+ "AI_RADAR_TEST_QUOTED=\"quoted value\"",
+ ].join("\n"),
+ "utf8",
+ );
+
+ const result = loadLocalEnv(envPath);
+
+ assert.equal(result.loaded, true);
+ assert.equal(process.env.AI_RADAR_TEST_ENV, "existing");
+ assert.equal(process.env.AI_RADAR_TEST_NEW, "value");
+ assert.equal(process.env.AI_RADAR_TEST_QUOTED, "quoted value");
+ } finally {
+ if (previous === undefined) {
+ delete process.env.AI_RADAR_TEST_ENV;
+ } else {
+ process.env.AI_RADAR_TEST_ENV = previous;
+ }
+ delete process.env.AI_RADAR_TEST_NEW;
+ delete process.env.AI_RADAR_TEST_QUOTED;
+ await rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/tests/sources-cache.test.js b/tests/sources-cache.test.js
new file mode 100644
index 0000000..32442a7
--- /dev/null
+++ b/tests/sources-cache.test.js
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buildSourcesCache,
+ sourceFromNotionApiPage,
+ sourceFromNotionMcpFetchText,
+} from "../lib/sources-cache.js";
+
+test("construye cache de fuentes activas agrupadas por subagente", () => {
+ const cache = buildSourcesCache(
+ [
+ {
+ name: "OpenAI News",
+ type: "fuente_oficial",
+ status: "activa",
+ url: "https://openai.com/news/",
+ priority: "alta",
+ },
+ {
+ name: "Fuente pausada",
+ type: "fuente_oficial",
+ status: "pausada",
+ url: "https://example.com/paused",
+ },
+ ],
+ {
+ generatedAt: "2026-06-21T12:00:00.000Z",
+ notionDatabaseUrl: "https://app.notion.com/p/db",
+ notionDataSourceUrl: "collection://sources",
+ },
+ );
+
+ assert.equal(cache.contrato, "ai-radar.sources-cache.v1");
+ assert.equal(cache.fuentes_por_subagente["ai-radar-fuentes-oficiales"].length, 1);
+ assert.equal(cache.fuentes_por_subagente["ai-radar-fuentes-oficiales"][0].name, "OpenAI News");
+ assert.equal(cache.fuentes_por_subagente["ai-radar-comunidad"].length, 0);
+});
+
+test("extrae una fuente desde fetch de Notion MCP", () => {
+ const source = sourceFromNotionMcpFetchText(`
+
+{"Name":"The Decoder","Type":"medios_secundario","Status":"activa","Priority":"media","Cadence":"diaria","Notes":"Medio especializado","url":"https://app.notion.com/p/source","userDefined:URL":"https://www.thedecoder.com/"}
+
+`);
+
+ assert.deepEqual(source, {
+ name: "The Decoder",
+ type: "medios_secundario",
+ status: "activa",
+ url: "https://www.thedecoder.com/",
+ priority: "media",
+ cadence: "diaria",
+ notes: "Medio especializado",
+ notion_page_url: "https://app.notion.com/p/source",
+ });
+});
+
+test("extrae una fuente desde respuesta de Notion API", () => {
+ const source = sourceFromNotionApiPage({
+ url: "https://app.notion.com/p/source",
+ properties: {
+ Name: { title: [{ plain_text: "r/LocalLLaMA" }] },
+ Type: { select: { name: "comunidad" } },
+ Status: { select: { name: "activa" } },
+ Priority: { select: { name: "media" } },
+ Cadence: { select: { name: "diaria" } },
+ Notes: { rich_text: [{ plain_text: "Comunidad tecnica" }] },
+ URL: { url: "https://www.reddit.com/r/LocalLLaMA/" },
+ },
+ });
+
+ assert.equal(source.name, "r/LocalLLaMA");
+ assert.equal(source.type, "comunidad");
+ assert.equal(source.url, "https://www.reddit.com/r/LocalLLaMA/");
+});
diff --git a/tests/supabase-diagnostics.test.js b/tests/supabase-diagnostics.test.js
new file mode 100644
index 0000000..43aa8bb
--- /dev/null
+++ b/tests/supabase-diagnostics.test.js
@@ -0,0 +1,53 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { checkSupabaseTables, getSupabaseEnvStatus } from "../lib/supabase/diagnostics.js";
+
+test("diagnostico Supabase reporta variables faltantes sin exponer secretos", () => {
+ const status = getSupabaseEnvStatus({
+ SUPABASE_URL: "https://example.supabase.co",
+ });
+
+ assert.equal(status.ok, false);
+ assert.equal(status.variables.SUPABASE_URL, true);
+ assert.equal(status.variables.SUPABASE_SERVICE_ROLE_KEY, false);
+ assert.equal(status.variables.AI_RADAR_API_TOKEN, false);
+ assert.deepEqual(status.missing, ["SUPABASE_SERVICE_ROLE_KEY", "AI_RADAR_API_TOKEN"]);
+});
+
+test("diagnostico Supabase valida tablas con lecturas reales", async () => {
+ const calls = [];
+ const supabase = {
+ from(table) {
+ return {
+ select(columns, options) {
+ calls.push({ table, columns, options });
+ return {
+ async limit() {
+ if (table === "signals") {
+ return {
+ error: {
+ code: "PGRST205",
+ message: "Could not find the table 'public.signals' in the schema cache",
+ },
+ };
+ }
+ return { error: null };
+ },
+ };
+ },
+ };
+ },
+ };
+
+ const status = await checkSupabaseTables(supabase);
+
+ assert.equal(status.ok, false);
+ assert.equal(status.tables.sources.ok, true);
+ assert.equal(status.tables.runs.ok, true);
+ assert.equal(status.tables.signals.ok, false);
+ assert.equal(status.tables.signals.code, "PGRST205");
+ assert.equal(calls.length, 3);
+ assert.ok(calls.every((call) => call.options === undefined));
+ assert.ok(calls.every((call) => call.columns.includes("id")));
+});
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,
+ });
+});