Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
81 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "carlosgalera-a11y/arditimed", "files": {"functions/src/goldStandardEval.ts": {"7893b214009f491a7491098a946bc2e3ff1515cf": {"t": 1785745582.2888439, "n": 6239, "h": "cd636672fc47bb10", "c": "import { onSchedule } from 'firebase-functions/v2/scheduler';\nimport { defineSecret } from 'firebase-functions/params';\nimport { getFirestore, FieldValue } from 'firebase-admin/firestore';\nimport { getApp } from 'firebase-admin/app';\nimport { logger } from 'firebase-functions/v2';\n\nimport { buildProviderChain, tryProviderChain } from './routing';\nimport type { AiType } from './types';\n\nconst REGION = 'europe-west1';\nconst TZ = 'Europe/Madrid';\nconst EVAL_TIMEOUT_MS = 60000;\n\nconst DEEPSEEK_API_KEY = defineSecret('DEEPSEEK_API_KEY');\nconst OPENROUTER_API_KEY = defineSecret('OPENROUTER_API_KEY');\n\n/**\n * Evaluación continua con casos gold-standard · EU AI Act art. 15\n * (accuracy & robustness).\n *\n * Cron mensual (día 1, 04:00 Madrid) que ejecuta la cadena de IA\n * contra un conjunto de casos con respuesta conocida y mide:\n * - acierto textual (substring / regex)\n * - latencia\n * - tasa de error\n *\n * Los casos gold-standard viven en Firestore:\n * eval_goldstandard/{caseId}\n * {\n * type: 'clinical_case' | 'educational' | 'vision',\n * systemPrompt: '…',\n * userPrompt: '…',\n * imageBase64?: '…', // solo para vision\n * expectedContains: ['string1', 'string2'], // todos deben aparecer\n * expectedForbidden: ['string a evitar'], // NINGUNO debe aparecer\n * specialty: 'cardio' | 'respiratorio' | …,\n * severityIfFail: 'high'|'medium'|'low'\n * }\n *\n * Resultado en `eval_results/{YYYY-MM-DD}`:\n * { month, total, passed, failed, avgLatencyMs, results: [...], status: 'ok'|'degraded'|'fail' }\n *\n * Alerta: si <80% pasa, logger.error + status='fail' → pipeline de\n * alertas externo (Sentry/email) debe leer y notificar.\n *\n * NOTA: el dataset inicial se rellena manualmente por el autor en\n * Firestore Console (10 casos mínimo). Ver docs/legal/gold-standard-dataset.md\n */\nexport const goldStandardEval = onSchedule(\n {\n region: REGION,\n schedule: '0 4 1 * *', // día 1 de cada mes 04:00\n timeZone: TZ,\n secrets: [DEEPSEEK_API_KEY, OPENROUTER_API_KEY],\n memory: '1GiB',\n timeoutSeconds: 540,\n },\n async () => {\n const db = getFirestore(getApp());\n const monthKey = new Date().toISOString().substring(0, 7); // YYYY-MM\n\n const casesSnap = await db.collection('eval_goldstandard').get();\n if (casesSnap.empty) {\n logger.warn('goldStandardEval.empty', { month: monthKey, hint: 'populate eval_goldstandard collection' });\n await db.collection('eval_results').doc(monthKey).set({\n month: monthKey,\n total: 0,\n passed: 0,\n failed: 0,\n status: 'empty',\n generatedAt: FieldValue.serverTimestamp(),\n note: 'Dataset vacío. Rellenar eval_goldstandard manualmente.',\n });\n return;\n }\n\n const results: Array<{\n caseId: string;\n type: string;\n specialty?: string;\n passed: boolean;\n latencyMs: number;\n provider?: string;\n model?: string;\n missing?: string[];\n forbidden?: string[];\n error?: string;\n }> = [];\n let totalLat = 0;\n\n for (const doc of casesSnap.docs) {\n const c = doc.data() as {\n type?: string;\n systemPrompt?: string;\n userPrompt?: string;\n imageBase64?: string;\n expectedContains?: string[];\n expectedForbidden?: string[];\n specialty?: string;\n severityIfFail?: string;\n };\n const start = Date.now();\n if (!c.type || !c.userPrompt) {\n results.push({ caseId: doc.id, type: c.type || 'unknown', passed: false, latencyMs: 0, error: 'missing type/userPrompt' });\n continue;\n }\n try {\n const chain = buildProviderChain({\n type: c.type as AiType,\n userPrompt: c.userPrompt,\n systemPrompt: c.systemPrompt || '',\n imageBase64: c.imageBase64,\n secrets: {\n deepseekKey: DEEPSEEK_API_KEY.value(),\n openrouterKey: OPENROUTER_API_KEY.value(),\n },\n });\n const r = (await Promise.race([\n tryProviderChain(chain),\n new Promise<never>((_, rej) => setTimeout(() => rej(new Error('eval timeout')), EVAL_TIMEOUT_MS)),\n ])) as { provider: string; result: { model: string; text: string } };\n const latency = Date.now() - start;\n totalLat += latency;\n const text = (r.result.text || '').toLowerCase();\n\n const missing: string[] = [];\n (c.expectedContains || []).forEach((exp) => {\n if (!text.includes(exp.toLowerCase())) missing.push(exp);\n });\n const forbidden: string[] = [];\n (c.expectedForbidden || []).forEach((forb) => {\n if (text.includes(forb.toLowerCase())) forbidden.push(forb);\n });\n const passed = missing.length === 0 && forbidden.length === 0;\n results.push({\n caseId: doc.id,\n type: c.type,\n specialty: c.specialty,\n passed,\n latencyMs: latency,\n provider: r.provider,\n model: r.result.model,\n missing: missing.length ? missing : undefined,\n forbidden: forbidden.length ? forbidden : undefined,\n });\n } catch (e) {\n results.push({\n caseId: doc.id,\n type: c.type,\n specialty: c.specialty,\n passed: false,\n latencyMs: Date.now() - start,\n error: (e as Error).message.slice(0, 200),\n });\n }\n }\n\n const total = results.length;\n const passed = results.filter((r) => r.passed).length;\n const failed = total - passed;\n const passRate = total ? passed / total : 0;\n const status = passRate >= 0.8 ? 'ok' : passRate >= 0.6 ? 'degraded' : 'fail';\n\n await db.collection('eval_results').doc(monthKey).set({\n month: monthKey,\n total,\n passed,\n failed,\n passRate: +passRate.toFixed(4),\n avgLatencyMs: total ? Math.round(totalLat / total) : 0,\n status,\n results,\n generatedAt: FieldValue.serverTimestamp(),\n });\n\n if (status !== 'ok') {\n logger.error('goldStandardEval.DEGRADED', { month: monthKey, status, passRate, passed, failed });\n } else {\n logger.info('goldStandardEval.ok', { month: monthKey, passRate, total });\n }\n },\n);\n"}, "022d7c6fe0f0ec819e872b6f06a58a37a7abe270": {"t": 1785754495.0413437, "n": 6239, "h": "cd636672fc47bb10", "c": "import { onSchedule } from 'firebase-functions/v2/scheduler';\nimport { defineSecret } from 'firebase-functions/params';\nimport { getFirestore, FieldValue } from 'firebase-admin/firestore';\nimport { getApp } from 'firebase-admin/app';\nimport { logger } from 'firebase-functions/v2';\n\nimport { buildProviderChain, tryProviderChain } from './routing';\nimport type { AiType } from './types';\n\nconst REGION = 'europe-west1';\nconst TZ = 'Europe/Madrid';\nconst EVAL_TIMEOUT_MS = 60000;\n\nconst DEEPSEEK_API_KEY = defineSecret('DEEPSEEK_API_KEY');\nconst OPENROUTER_API_KEY = defineSecret('OPENROUTER_API_KEY');\n\n/**\n * Evaluación continua con casos gold-standard · EU AI Act art. 15\n * (accuracy & robustness).\n *\n * Cron mensual (día 1, 04:00 Madrid) que ejecuta la cadena de IA\n * contra un conjunto de casos con respuesta conocida y mide:\n * - acierto textual (substring / regex)\n * - latencia\n * - tasa de error\n *\n * Los casos gold-standard viven en Firestore:\n * eval_goldstandard/{caseId}\n * {\n * type: 'clinical_case' | 'educational' | 'vision',\n * systemPrompt: '…',\n * userPrompt: '…',\n * imageBase64?: '…', // solo para vision\n * expectedContains: ['string1', 'string2'], // todos deben aparecer\n * expectedForbidden: ['string a evitar'], // NINGUNO debe aparecer\n * specialty: 'cardio' | 'respiratorio' | …,\n * severityIfFail: 'high'|'medium'|'low'\n * }\n *\n * Resultado en `eval_results/{YYYY-MM-DD}`:\n * { month, total, passed, failed, avgLatencyMs, results: [...], status: 'ok'|'degraded'|'fail' }\n *\n * Alerta: si <80% pasa, logger.error + status='fail' → pipeline de\n * alertas externo (Sentry/email) debe leer y notificar.\n *\n * NOTA: el dataset inicial se rellena manualmente por el autor en\n * Firestore Console (10 casos mínimo). Ver docs/legal/gold-standard-dataset.md\n */\nexport const goldStandardEval = onSchedule(\n {\n region: REGION,\n schedule: '0 4 1 * *', // día 1 de cada mes 04:00\n timeZone: TZ,\n secrets: [DEEPSEEK_API_KEY, OPENROUTER_API_KEY],\n memory: '1GiB',\n timeoutSeconds: 540,\n },\n async () => {\n const db = getFirestore(getApp());\n const monthKey = new Date().toISOString().substring(0, 7); // YYYY-MM\n\n const casesSnap = await db.collection('eval_goldstandard').get();\n if (casesSnap.empty) {\n logger.warn('goldStandardEval.empty', { month: monthKey, hint: 'populate eval_goldstandard collection' });\n await db.collection('eval_results').doc(monthKey).set({\n month: monthKey,\n total: 0,\n passed: 0,\n failed: 0,\n status: 'empty',\n generatedAt: FieldValue.serverTimestamp(),\n note: 'Dataset vacío. Rellenar eval_goldstandard manualmente.',\n });\n return;\n }\n\n const results: Array<{\n caseId: string;\n type: string;\n specialty?: string;\n passed: boolean;\n latencyMs: number;\n provider?: string;\n model?: string;\n missing?: string[];\n forbidden?: string[];\n error?: string;\n }> = [];\n let totalLat = 0;\n\n for (const doc of casesSnap.docs) {\n const c = doc.data() as {\n type?: string;\n systemPrompt?: string;\n userPrompt?: string;\n imageBase64?: string;\n expectedContains?: string[];\n expectedForbidden?: string[];\n specialty?: string;\n severityIfFail?: string;\n };\n const start = Date.now();\n if (!c.type || !c.userPrompt) {\n results.push({ caseId: doc.id, type: c.type || 'unknown', passed: false, latencyMs: 0, error: 'missing type/userPrompt' });\n continue;\n }\n try {\n const chain = buildProviderChain({\n type: c.type as AiType,\n userPrompt: c.userPrompt,\n systemPrompt: c.systemPrompt || '',\n imageBase64: c.imageBase64,\n secrets: {\n deepseekKey: DEEPSEEK_API_KEY.value(),\n openrouterKey: OPENROUTER_API_KEY.value(),\n },\n });\n const r = (await Promise.race([\n tryProviderChain(chain),\n new Promise<never>((_, rej) => setTimeout(() => rej(new Error('eval timeout')), EVAL_TIMEOUT_MS)),\n ])) as { provider: string; result: { model: string; text: string } };\n const latency = Date.now() - start;\n totalLat += latency;\n const text = (r.result.text || '').toLowerCase();\n\n const missing: string[] = [];\n (c.expectedContains || []).forEach((exp) => {\n if (!text.includes(exp.toLowerCase())) missing.push(exp);\n });\n const forbidden: string[] = [];\n (c.expectedForbidden || []).forEach((forb) => {\n if (text.includes(forb.toLowerCase())) forbidden.push(forb);\n });\n const passed = missing.length === 0 && forbidden.length === 0;\n results.push({\n caseId: doc.id,\n type: c.type,\n specialty: c.specialty,\n passed,\n latencyMs: latency,\n provider: r.provider,\n model: r.result.model,\n missing: missing.length ? missing : undefined,\n forbidden: forbidden.length ? forbidden : undefined,\n });\n } catch (e) {\n results.push({\n caseId: doc.id,\n type: c.type,\n specialty: c.specialty,\n passed: false,\n latencyMs: Date.now() - start,\n error: (e as Error).message.slice(0, 200),\n });\n }\n }\n\n const total = results.length;\n const passed = results.filter((r) => r.passed).length;\n const failed = total - passed;\n const passRate = total ? passed / total : 0;\n const status = passRate >= 0.8 ? 'ok' : passRate >= 0.6 ? 'degraded' : 'fail';\n\n await db.collection('eval_results').doc(monthKey).set({\n month: monthKey,\n total,\n passed,\n failed,\n passRate: +passRate.toFixed(4),\n avgLatencyMs: total ? Math.round(totalLat / total) : 0,\n status,\n results,\n generatedAt: FieldValue.serverTimestamp(),\n });\n\n if (status !== 'ok') {\n logger.error('goldStandardEval.DEGRADED', { month: monthKey, status, passRate, passed, failed });\n } else {\n logger.info('goldStandardEval.ok', { month: monthKey, passRate, total });\n }\n },\n);\n"}}, "functions-setup/README.md": {"7893b214009f491a7491098a946bc2e3ff1515cf": {"t": 1785746420.4594903, "n": 7800, "h": "0feaa3c29de9dbe6", "c": "# Despliegue Cloud Function — Cartagenaeste\n\nGuía paso a paso para mover las API keys al backend de Firebase.\n\n**Objetivo:** que las keys `DEEPSEEK_KEY` y `OPENROUTER_KEY` dejen de estar en el frontend (ofuscadas con XOR-42) y pasen a variables de entorno de Firebase, inaccesibles desde DevTools del navegador.\n\n**Tiempo estimado:** 15–25 minutos si nunca has usado Firebase CLI.\n\n---\n\n## Paso 0 — Requisitos previos\n\nNecesitas:\n- Node.js 20+ instalado (`node --version` debe devolver v20.x o superior).\n- Cuenta Google con acceso al proyecto `docenciacartagenaeste` en Firebase Console.\n- Las API keys actuales:\n - DeepSeek: `sk-a89dd17f...` (la que ya tienes en `app-main.js` XOR-obfuscada, hay que rotarla después de este despliegue).\n - OpenRouter: la que está en `_dk()` — también rotar.\n\n---\n\n## Paso 1 — Instalar Firebase CLI\n\n```bash\nnpm install -g firebase-tools\nfirebase --version # debe devolver 13.x o superior\n```\n\nLuego autentícate:\n\n```bash\nfirebase login\n# Se abre el navegador, te pide login con la cuenta Google que tiene acceso a docenciacartagenaeste\n```\n\n---\n\n## Paso 2 — Copiar los archivos de `functions-setup/` a tu máquina\n\nEn tu ordenador, crea una carpeta fuera del repo (no mezclar con el sitio):\n\n```bash\nmkdir ~/cartagenaeste-backend\ncd ~/cartagenaeste-backend\n```\n\nCopia el contenido de `functions-setup/` del repo:\n\n```bash\ncp -r /ruta/al/repo/functions-setup/. .\n# Ahora deberías ver: firebase.json, .firebaserc, functions/\n```\n\nVerifica que estás apuntando al proyecto correcto:\n\n```bash\nfirebase use\n# Debería mostrar: Active Project: default (docenciacartagenaeste)\n```\n\n---\n\n## Paso 3 — Actualizar el plan de Firebase a Blaze (pago por uso)\n\n**Importante:** Cloud Functions requiere plan Blaze. Pero para tu volumen:\n- Primeras 2 millones de invocaciones al mes: **gratis**.\n- Primeros 400.000 GB-segundos de cómputo: **gratis**.\n\nCon tu uso real (residentes consultando DeepSeek, ~100-500 req/día), **pagarás 0€** casi seguro. Pero sin Blaze no se pueden desplegar.\n\nVe a https://console.firebase.google.com/project/docenciacartagenaeste/usage/details y activa Blaze. Pon un alerta de facturación en 5€ por si acaso.\n\n---\n\n## Paso 4 — Instalar dependencias y guardar los secrets\n\n```bash\ncd functions\nnpm install\ncd ..\n```\n\nGuarda las keys como Secrets (encrypted at rest, no en el código):\n\n```bash\nfirebase functions:secrets:set DEEPSEEK_KEY\n# Te pide pegar la clave. Pega sk-a89dd17f... y pulsa Enter.\n\nfirebase functions:secrets:set OPENROUTER_KEY\n# Pega la clave de OpenRouter.\n```\n\nVerifica:\n\n```bash\nfirebase functions:secrets:access DEEPSEEK_KEY\n# Debe devolver la clave que acabas de introducir.\n```\n\n---\n\n## Paso 5 — Activar App Check en Firebase Console\n\n1. Ve a https://console.firebase.google.com/project/docenciacartagenaeste/appcheck\n2. Pestaña **Apps** → selecciona la app web de Cartagenaeste.\n3. En proveedor elige **reCAPTCHA v3**.\n4. Ve a https://www.google.com/recaptcha/admin/create\n - Tipo: **reCAPTCHA v3**\n - Dominios: `carlosgalera-a11y.github.io`, `cartagenaeste.es`, `localhost`\n - Aceptar condiciones, Enviar.\n5. Copia la **Site Key** (pública) — la usarás en el frontend.\n6. Copia la **Secret Key** (privada) — pégala en la configuración de App Check en Firebase.\n7. Vuelve a Firebase Console → App Check → pestaña **APIs** → activa enforcement para **Cloud Functions**.\n\n---\n\n## Paso 6 — Desplegar las Cloud Functions\n\n```bash\nfirebase deploy --only functions\n```\n\nPrimera vez tarda ~3-5 minutos. Output final debe contener:\n\n```\n✔ functions[llamarIA(europe-west1)] Successful create operation.\n✔ functions[scanIA(europe-west1)] Successful create operation.\n```\n\nCopia las URLs que aparecen (las necesitas en Paso 7).\n\n---\n\n## Paso 7 — Conectar el frontend a las Cloud Functions\n\nEdita `index.html` del sitio. Busca el bloque donde está cargado el SDK Firebase y añade los módulos `functions` y `app-check`.\n\n**Añade después de los SDKs existentes de Firebase:**\n\n```html\n<script src=\"https://www.gstatic.com/firebasejs/10.12.0/firebase-functions-compat.js\"></script>\n<script src=\"https://www.gstatic.com/firebasejs/10.12.0/firebase-app-check-compat.js\"></script>\n```\n\n**Y en el bloque de inicialización de Firebase, añade:**\n\n```javascript\n// Activar App Check — PÉGA AQUÍ TU SITE KEY DE reCAPTCHA v3\nconst appCheck = firebase.appCheck();\nappCheck.activate('TU_RECAPTCHA_V3_SITE_KEY', true);\n\n// Conectar al backend europe-west1\nconst functions = firebase.app().functions('europe-west1');\n```\n\n**Reemplaza la función `llamarIA` en `app-main.js` (línea 971) por esto:**\n\n```javascript\nasync function llamarIA(up, sp) {\n up = sanitizeAI(up || '');\n if (!up) return '⚠️ Consulta vacía o inválida.';\n try { aiRateLimiter.check(); } catch(e) { return e.message; }\n\n if (!firebase.auth().currentUser) {\n return '⚠️ Debes iniciar sesión para usar la IA.';\n }\n\n try {\n const call = firebase.app().functions('europe-west1').httpsCallable('llamarIA');\n const result = await call({ user: up, system: sp });\n return result.data.text || '⚠️ Respuesta vacía del servidor.';\n } catch (e) {\n console.error('llamarIA error:', e);\n if (e.code === 'functions/resource-exhausted') return '⏱️ ' + (e.message || 'Límite alcanzado.');\n if (e.code === 'functions/unauthenticated') return '⚠️ Sesión expirada. Vuelve a iniciar sesión.';\n return '⚠️ Error del servicio de IA: ' + (e.message || 'intenta de nuevo más tarde.');\n }\n}\n```\n\n**Análogamente, en ScanIA (línea ~2040) reemplaza el fetch directo por:**\n\n```javascript\nconst call = firebase.app().functions('europe-west1').httpsCallable('scanIA');\nconst result = await call({\n imageBase64: scanB64,\n systemPrompt: sys,\n userText: userText,\n modelPref: 'qwen'\n});\nconst txt = result.data.text;\nconst usedModel = result.data.model;\n```\n\n---\n\n## Paso 8 — Borrar las keys del frontend\n\nEn `app-main.js`:\n1. Borra la función `_dk()` (línea 57) — ya no se usa.\n2. Borra la función `_xd()` (línea 55) si no la usa nada más.\n3. Borra las llamadas `var DS_KEY = _xd(...)` y `var OR_KEY = _dk()`.\n4. Borra `SCAN_GROQ_KEY_DEFAULT`, `EMBEDDED_GROQ_KEY`, `SCAN_GROQ_KEY`, `getScanGroqKey`.\n\n---\n\n## Paso 9 — ROTAR las keys antiguas\n\n**Esto es crítico.** Las keys que están en el historial Git siguen expuestas. Ve a:\n\n1. https://platform.deepseek.com/api_keys → revoca `sk-a89dd17f...` → crea una nueva → guárdala con `firebase functions:secrets:set DEEPSEEK_KEY`.\n2. https://openrouter.ai/keys → revoca la actual → crea nueva → `firebase functions:secrets:set OPENROUTER_KEY`.\n3. Redeploy: `firebase deploy --only functions`.\n\n---\n\n## Paso 10 — Verificar\n\nEn el navegador, abre DevTools → Network. Al hacer una consulta, ahora debe aparecer:\n\n```\nPOST https://europe-west1-docenciacartagenaeste.cloudfunctions.net/llamarIA\n```\n\nY NO debe aparecer ninguna llamada directa a `api.deepseek.com` ni a `openrouter.ai`. Si aparece, algo quedó sin migrar.\n\nEn la cabecera Authorization del request, ya NO ves `Bearer sk-...` de DeepSeek — solo ves un token Firebase ID (que es temporal y del usuario, no una API key compartida).\n\n---\n\n## ¿Qué has ganado?\n\n| Antes | Después |\n|---|---|\n| Key DeepSeek visible con DevTools en 30s | Key vive en Google Cloud, nunca sale |\n| Cualquiera con la URL puede gastar tus créditos | Solo usuarios logueados con App Check |\n| Rate limit bypasseable recargando | Rate limit en servidor por UID |\n| 0€ si key filtrada y te gastan 100€ | 0€ + key rotable en segundos |\n\n---\n\n## Cuándo me necesitas de vuelta\n\nCuando hayas hecho los Pasos 1–6 (que son los que requieren tu cuenta), dime y te paso el **diff exacto** del `index.html` y `app-main.js` para los Pasos 7–10. Prefiero no tocar el frontend antes de saber que tu backend está vivo.\n\nTiempo real tuyo estimado: ~40 minutos si nunca has usado Firebase CLI, ~15 si ya lo conoces.\n"}, "122183e076b7b728b7490b34c001140a79514b90": {"t": 1785754496.9688363, "n": 7800, "h": "0feaa3c29de9dbe6", "c": "# Despliegue Cloud Function — Cartagenaeste\n\nGuía paso a paso para mover las API keys al backend de Firebase.\n\n**Objetivo:** que las keys `DEEPSEEK_KEY` y `OPENROUTER_KEY` dejen de estar en el frontend (ofuscadas con XOR-42) y pasen a variables de entorno de Firebase, inaccesibles desde DevTools del navegador.\n\n**Tiempo estimado:** 15–25 minutos si nunca has usado Firebase CLI.\n\n---\n\n## Paso 0 — Requisitos previos\n\nNecesitas:\n- Node.js 20+ instalado (`node --version` debe devolver v20.x o superior).\n- Cuenta Google con acceso al proyecto `docenciacartagenaeste` en Firebase Console.\n- Las API keys actuales:\n - DeepSeek: `sk-a89dd17f...` (la que ya tienes en `app-main.js` XOR-obfuscada, hay que rotarla después de este despliegue).\n - OpenRouter: la que está en `_dk()` — también rotar.\n\n---\n\n## Paso 1 — Instalar Firebase CLI\n\n```bash\nnpm install -g firebase-tools\nfirebase --version # debe devolver 13.x o superior\n```\n\nLuego autentícate:\n\n```bash\nfirebase login\n# Se abre el navegador, te pide login con la cuenta Google que tiene acceso a docenciacartagenaeste\n```\n\n---\n\n## Paso 2 — Copiar los archivos de `functions-setup/` a tu máquina\n\nEn tu ordenador, crea una carpeta fuera del repo (no mezclar con el sitio):\n\n```bash\nmkdir ~/cartagenaeste-backend\ncd ~/cartagenaeste-backend\n```\n\nCopia el contenido de `functions-setup/` del repo:\n\n```bash\ncp -r /ruta/al/repo/functions-setup/. .\n# Ahora deberías ver: firebase.json, .firebaserc, functions/\n```\n\nVerifica que estás apuntando al proyecto correcto:\n\n```bash\nfirebase use\n# Debería mostrar: Active Project: default (docenciacartagenaeste)\n```\n\n---\n\n## Paso 3 — Actualizar el plan de Firebase a Blaze (pago por uso)\n\n**Importante:** Cloud Functions requiere plan Blaze. Pero para tu volumen:\n- Primeras 2 millones de invocaciones al mes: **gratis**.\n- Primeros 400.000 GB-segundos de cómputo: **gratis**.\n\nCon tu uso real (residentes consultando DeepSeek, ~100-500 req/día), **pagarás 0€** casi seguro. Pero sin Blaze no se pueden desplegar.\n\nVe a https://console.firebase.google.com/project/docenciacartagenaeste/usage/details y activa Blaze. Pon un alerta de facturación en 5€ por si acaso.\n\n---\n\n## Paso 4 — Instalar dependencias y guardar los secrets\n\n```bash\ncd functions\nnpm install\ncd ..\n```\n\nGuarda las keys como Secrets (encrypted at rest, no en el código):\n\n```bash\nfirebase functions:secrets:set DEEPSEEK_KEY\n# Te pide pegar la clave. Pega sk-a89dd17f... y pulsa Enter.\n\nfirebase functions:secrets:set OPENROUTER_KEY\n# Pega la clave de OpenRouter.\n```\n\nVerifica:\n\n```bash\nfirebase functions:secrets:access DEEPSEEK_KEY\n# Debe devolver la clave que acabas de introducir.\n```\n\n---\n\n## Paso 5 — Activar App Check en Firebase Console\n\n1. Ve a https://console.firebase.google.com/project/docenciacartagenaeste/appcheck\n2. Pestaña **Apps** → selecciona la app web de Cartagenaeste.\n3. En proveedor elige **reCAPTCHA v3**.\n4. Ve a https://www.google.com/recaptcha/admin/create\n - Tipo: **reCAPTCHA v3**\n - Dominios: `carlosgalera-a11y.github.io`, `cartagenaeste.es`, `localhost`\n - Aceptar condiciones, Enviar.\n5. Copia la **Site Key** (pública) — la usarás en el frontend.\n6. Copia la **Secret Key** (privada) — pégala en la configuración de App Check en Firebase.\n7. Vuelve a Firebase Console → App Check → pestaña **APIs** → activa enforcement para **Cloud Functions**.\n\n---\n\n## Paso 6 — Desplegar las Cloud Functions\n\n```bash\nfirebase deploy --only functions\n```\n\nPrimera vez tarda ~3-5 minutos. Output final debe contener:\n\n```\n✔ functions[llamarIA(europe-west1)] Successful create operation.\n✔ functions[scanIA(europe-west1)] Successful create operation.\n```\n\nCopia las URLs que aparecen (las necesitas en Paso 7).\n\n---\n\n## Paso 7 — Conectar el frontend a las Cloud Functions\n\nEdita `index.html` del sitio. Busca el bloque donde está cargado el SDK Firebase y añade los módulos `functions` y `app-check`.\n\n**Añade después de los SDKs existentes de Firebase:**\n\n```html\n<script src=\"https://www.gstatic.com/firebasejs/10.12.0/firebase-functions-compat.js\"></script>\n<script src=\"https://www.gstatic.com/firebasejs/10.12.0/firebase-app-check-compat.js\"></script>\n```\n\n**Y en el bloque de inicialización de Firebase, añade:**\n\n```javascript\n// Activar App Check — PÉGA AQUÍ TU SITE KEY DE reCAPTCHA v3\nconst appCheck = firebase.appCheck();\nappCheck.activate('TU_RECAPTCHA_V3_SITE_KEY', true);\n\n// Conectar al backend europe-west1\nconst functions = firebase.app().functions('europe-west1');\n```\n\n**Reemplaza la función `llamarIA` en `app-main.js` (línea 971) por esto:**\n\n```javascript\nasync function llamarIA(up, sp) {\n up = sanitizeAI(up || '');\n if (!up) return '⚠️ Consulta vacía o inválida.';\n try { aiRateLimiter.check(); } catch(e) { return e.message; }\n\n if (!firebase.auth().currentUser) {\n return '⚠️ Debes iniciar sesión para usar la IA.';\n }\n\n try {\n const call = firebase.app().functions('europe-west1').httpsCallable('llamarIA');\n const result = await call({ user: up, system: sp });\n return result.data.text || '⚠️ Respuesta vacía del servidor.';\n } catch (e) {\n console.error('llamarIA error:', e);\n if (e.code === 'functions/resource-exhausted') return '⏱️ ' + (e.message || 'Límite alcanzado.');\n if (e.code === 'functions/unauthenticated') return '⚠️ Sesión expirada. Vuelve a iniciar sesión.';\n return '⚠️ Error del servicio de IA: ' + (e.message || 'intenta de nuevo más tarde.');\n }\n}\n```\n\n**Análogamente, en ScanIA (línea ~2040) reemplaza el fetch directo por:**\n\n```javascript\nconst call = firebase.app().functions('europe-west1').httpsCallable('scanIA');\nconst result = await call({\n imageBase64: scanB64,\n systemPrompt: sys,\n userText: userText,\n modelPref: 'qwen'\n});\nconst txt = result.data.text;\nconst usedModel = result.data.model;\n```\n\n---\n\n## Paso 8 — Borrar las keys del frontend\n\nEn `app-main.js`:\n1. Borra la función `_dk()` (línea 57) — ya no se usa.\n2. Borra la función `_xd()` (línea 55) si no la usa nada más.\n3. Borra las llamadas `var DS_KEY = _xd(...)` y `var OR_KEY = _dk()`.\n4. Borra `SCAN_GROQ_KEY_DEFAULT`, `EMBEDDED_GROQ_KEY`, `SCAN_GROQ_KEY`, `getScanGroqKey`.\n\n---\n\n## Paso 9 — ROTAR las keys antiguas\n\n**Esto es crítico.** Las keys que están en el historial Git siguen expuestas. Ve a:\n\n1. https://platform.deepseek.com/api_keys → revoca `sk-a89dd17f...` → crea una nueva → guárdala con `firebase functions:secrets:set DEEPSEEK_KEY`.\n2. https://openrouter.ai/keys → revoca la actual → crea nueva → `firebase functions:secrets:set OPENROUTER_KEY`.\n3. Redeploy: `firebase deploy --only functions`.\n\n---\n\n## Paso 10 — Verificar\n\nEn el navegador, abre DevTools → Network. Al hacer una consulta, ahora debe aparecer:\n\n```\nPOST https://europe-west1-docenciacartagenaeste.cloudfunctions.net/llamarIA\n```\n\nY NO debe aparecer ninguna llamada directa a `api.deepseek.com` ni a `openrouter.ai`. Si aparece, algo quedó sin migrar.\n\nEn la cabecera Authorization del request, ya NO ves `Bearer sk-...` de DeepSeek — solo ves un token Firebase ID (que es temporal y del usuario, no una API key compartida).\n\n---\n\n## ¿Qué has ganado?\n\n| Antes | Después |\n|---|---|\n| Key DeepSeek visible con DevTools en 30s | Key vive en Google Cloud, nunca sale |\n| Cualquiera con la URL puede gastar tus créditos | Solo usuarios logueados con App Check |\n| Rate limit bypasseable recargando | Rate limit en servidor por UID |\n| 0€ si key filtrada y te gastan 100€ | 0€ + key rotable en segundos |\n\n---\n\n## Cuándo me necesitas de vuelta\n\nCuando hayas hecho los Pasos 1–6 (que son los que requieren tu cuenta), dime y te paso el **diff exacto** del `index.html` y `app-main.js` para los Pasos 7–10. Prefiero no tocar el frontend antes de saber que tu backend está vivo.\n\nTiempo real tuyo estimado: ~40 minutos si nunca has usado Firebase CLI, ~15 si ya lo conoces.\n"}}, ".gitleaks.toml": {"7893b214009f491a7491098a946bc2e3ff1515cf": {"t": 1785747468.7181869, "n": 960, "h": "9fb7d95fa0d50a09", "c": "title = \"Cartagenaeste gitleaks config\"\n\n[extend]\nuseDefault = true\n\n[[rules]]\nid = \"groq-api-key\"\ndescription = \"Groq API key\"\nregex = '''gsk_[0-9A-Za-z]{40,}'''\ntags = [\"key\", \"groq\"]\n\n[[rules]]\nid = \"deepseek-api-key\"\ndescription = \"DeepSeek API key\"\nregex = '''sk-[a-f0-9]{32}'''\ntags = [\"key\", \"deepseek\"]\n\n[[rules]]\nid = \"openrouter-api-key\"\ndescription = \"OpenRouter API key\"\nregex = '''sk-or-v1-[a-f0-9]{64}'''\ntags = [\"key\", \"openrouter\"]\n\n[allowlist]\ndescription = \"Patterns allowlisted because they are placeholders, historical audits, or documented sentinels\"\npaths = [\n '''docs/security-audit.*\\.md''',\n '''docs/diagnostico.*\\.md''',\n '''AUDIT_2026-04-04\\.md''',\n '''CHANGELOG\\.md''',\n '''FIREBASE-(SETUP|INTEGRAR-CLAVE)\\.md''',\n '''\\.gitleaks\\.toml''',\n]\nregexes = [\n '''AIzaSyD9c-EXAMPLE-KEY-CHANGE-ME''',\n '''AIzaSyD9c-TU-CLAVE-AQUI''',\n '''YOUR_API_KEY_HERE''',\n '''REDACTED_.*''',\n '''about:disabled-.*''',\n]\n"}, "c0944bbc7614ec3fe3fdd7148a20d6f30ac879c0": {"t": 1785754493.210956, "n": 960, "h": "9fb7d95fa0d50a09", "c": "title = \"Cartagenaeste gitleaks config\"\n\n[extend]\nuseDefault = true\n\n[[rules]]\nid = \"groq-api-key\"\ndescription = \"Groq API key\"\nregex = '''gsk_[0-9A-Za-z]{40,}'''\ntags = [\"key\", \"groq\"]\n\n[[rules]]\nid = \"deepseek-api-key\"\ndescription = \"DeepSeek API key\"\nregex = '''sk-[a-f0-9]{32}'''\ntags = [\"key\", \"deepseek\"]\n\n[[rules]]\nid = \"openrouter-api-key\"\ndescription = \"OpenRouter API key\"\nregex = '''sk-or-v1-[a-f0-9]{64}'''\ntags = [\"key\", \"openrouter\"]\n\n[allowlist]\ndescription = \"Patterns allowlisted because they are placeholders, historical audits, or documented sentinels\"\npaths = [\n '''docs/security-audit.*\\.md''',\n '''docs/diagnostico.*\\.md''',\n '''AUDIT_2026-04-04\\.md''',\n '''CHANGELOG\\.md''',\n '''FIREBASE-(SETUP|INTEGRAR-CLAVE)\\.md''',\n '''\\.gitleaks\\.toml''',\n]\nregexes = [\n '''AIzaSyD9c-EXAMPLE-KEY-CHANGE-ME''',\n '''AIzaSyD9c-TU-CLAVE-AQUI''',\n '''YOUR_API_KEY_HERE''',\n '''REDACTED_.*''',\n '''about:disabled-.*''',\n]\n"}}, "docs/qa-checklist-v1.0.0.md": {"7893b214009f491a7491098a946bc2e3ff1515cf": {"t": 1785747468.7877064, "n": 4805, "h": "9987ec5dfbaef485", "c": "# QA Checklist · Cartagenaeste v1.0.0\n\nFecha del ejercicio: 2026-04-22.\nEjecutor: Carlos Galera Román.\nURL de referencia: https://area2cartagena.es/\n\n---\n\n## Autenticación\n\n- [ ] Login Google funciona en producción (popup → cuenta → redirige con avatar).\n- [ ] Login email+password funciona (si lo activas en la UI).\n- [ ] Logout funciona y oculta datos personales.\n- [ ] Recuperación de contraseña funciona (email de reset).\n- [ ] Timeout sesión: tras ~30 min idle, token refreshed transparentemente.\n\n## Funcionalidad core\n\n- [ ] Crear caso docente con iniciales ≤4 chars (rechaza >4 por firestore rule).\n- [ ] Editar caso → persiste en `users/{uid}/cases`.\n- [ ] Borrar caso → aparece entrada en `auditLogs` (acción `delete`).\n- [ ] MegaCuaderno IA responde (llamada real a `askAi`, Network 200).\n- [ ] Análisis de imagen IA funciona (tipo `vision`).\n- [ ] Triaje QR completo: paciente → QR → escanear → ficha 24h.\n- [ ] 5 calculadoras al azar (BMI, MELD, CHA2DS2-VASc, Glasgow, Wells) dan resultados correctos.\n- [ ] Buscador vacuna por edad funciona.\n- [ ] Generador turnos crea PDF descargable.\n- [ ] Casos clínicos MIR: 17 especialidades, >100 preguntas.\n- [ ] Fármacos urgencia: 51 entries listados.\n- [ ] Buzón sugerencias → `sugerencias/{autoId}` creado.\n- [ ] Plantillas informes: 3 tabs funcionando.\n- [ ] Enfermería módulo accesible desde menú principal.\n- [ ] Camas y ambulancias accesibles.\n- [ ] Mapa sanitario carga.\n- [ ] Dashboard accesible tras login superadmin.\n\n## Seguridad y compliance\n\n- [ ] DevTools → Network al usar IA: **única** llamada a `*-docenciacartagenaeste.cloudfunctions.net/askAi`. Cero a groq/openrouter/deepseek/pollinations/dashscope/mistral directo.\n- [ ] App Check enforce: tras activar en consola (fase posterior), petición desde origen no autorizado → `failed-precondition`.\n- [ ] Firestore rules: usuario A no lee datos de usuario B. Verificar con dos cuentas Google + DevTools Firestore inspect.\n- [ ] Cookie banner aparece a usuario nuevo.\n- [ ] Disclaimer formativo visible en home y páginas clínicas.\n- [ ] `/privacidad.html` accesible y actualizada.\n- [ ] `/informacion.html` (aviso legal) accesible.\n- [ ] Headers de seguridad presentes: `curl -I https://area2cartagena.es/ | grep -Ei \"strict-transport|content-type-options|permissions-policy\"`.\n- [ ] `gitleaks detect --source .` → 0 findings en el último commit.\n- [ ] Grep control en prod: `curl -s https://area2cartagena.es/index.html | grep -cE \"api.(groq|deepseek|mistral).com|openrouter.ai/api|pollinations|dashscope\"` = 0.\n\n## Datos\n\n- [ ] Exportar mis datos: botón genera JSON con mis casos/notas.\n- [ ] Eliminar mi cuenta: borra docs `users/{uid}` y subcolecciones. `auditLogs` preserva la traza.\n- [ ] Backup diario: verificar en bucket `gs://docenciacartagenaeste-backups/firestore/YYYY-MM-DD/` (tras primera ejecución del cron).\n- [ ] Audit log inmutable: intentar `db.collection('auditLogs').doc('foo').update({x:1})` desde consola de un usuario → permission-denied.\n\n## Monitoring\n\n- [ ] Sentry recibe exception de prueba en <30s (tras setear `window.SENTRY_DSN`).\n- [ ] `firebase functions:log --only askAi --lines 10` muestra entradas con metadatos (sin texto del prompt).\n- [ ] `/status.html` muestra métricas reales.\n- [ ] Alerta de prueba por email (forzar error rate > 5% con 10 llamadas a askAi con prompt inválido).\n\n## PWA y rendimiento\n\n- [ ] PWA installable en Chrome desktop (icono + en barra de direcciones).\n- [ ] PWA installable en Safari iOS (Compartir → Añadir a pantalla de inicio).\n- [ ] Service Worker v74 activo: DevTools → Application → Service Workers → status \"activated\".\n- [ ] Update flow funciona: merge trivial + redeploy → banner \"Nueva versión disponible\".\n- [ ] Lighthouse Performance mobile ≥ 75 en `/`.\n- [ ] Lighthouse Accessibility ≥ 85.\n- [ ] Lighthouse Best Practices ≥ 85.\n- [ ] Lighthouse PWA = 100.\n- [ ] Sin console errors en `/`, `/casos-clinicos.html`, `/calculadoras.html`, `/mapa.html`, `/status.html`.\n\n## Responsive\n\n- [ ] 375px (iPhone SE) — layout no roto.\n- [ ] 412px (Pixel / Android estándar) — layout no roto.\n- [ ] 768px (iPad) — layout adapta a 2 columnas donde aplica.\n- [ ] 1440px (desktop) — layout usa ancho máx 1200-1400px.\n\n## Repositorio\n\n- [ ] README renderiza bien en GitHub.\n- [ ] LICENSE visible y legible.\n- [ ] IP_ATTESTATION.md visible con datos correctos (Reg. 00765-03096622).\n- [ ] CHANGELOG.md refleja v1.0.0.\n- [ ] CI verde en main (última build).\n- [ ] Branch protection activa en `main` (Settings → Branches → add rule).\n- [ ] No secretos en `git log -p | grep -iE 'sk-[a-f0-9]{32}|AIza[0-9A-Za-z_-]{35}|gsk_[a-zA-Z0-9]{20,}'` (tras `git filter-repo`).\n\n## Script automatizable\n\nEjecutar:\n```bash\nbash test-deploy.sh\n```\n\nDebe retornar 0 failures.\n\n---\n\n**Firma**: ________________________ **Fecha**: __________\n"}, "622ad0fc83d0eb07a9514b7b77f5621177d34c5e": {"t": 1785754500.95377, "n": 4805, "h": "9987ec5dfbaef485", "c": "# QA Checklist · Cartagenaeste v1.0.0\n\nFecha del ejercicio: 2026-04-22.\nEjecutor: Carlos Galera Román.\nURL de referencia: https://area2cartagena.es/\n\n---\n\n## Autenticación\n\n- [ ] Login Google funciona en producción (popup → cuenta → redirige con avatar).\n- [ ] Login email+password funciona (si lo activas en la UI).\n- [ ] Logout funciona y oculta datos personales.\n- [ ] Recuperación de contraseña funciona (email de reset).\n- [ ] Timeout sesión: tras ~30 min idle, token refreshed transparentemente.\n\n## Funcionalidad core\n\n- [ ] Crear caso docente con iniciales ≤4 chars (rechaza >4 por firestore rule).\n- [ ] Editar caso → persiste en `users/{uid}/cases`.\n- [ ] Borrar caso → aparece entrada en `auditLogs` (acción `delete`).\n- [ ] MegaCuaderno IA responde (llamada real a `askAi`, Network 200).\n- [ ] Análisis de imagen IA funciona (tipo `vision`).\n- [ ] Triaje QR completo: paciente → QR → escanear → ficha 24h.\n- [ ] 5 calculadoras al azar (BMI, MELD, CHA2DS2-VASc, Glasgow, Wells) dan resultados correctos.\n- [ ] Buscador vacuna por edad funciona.\n- [ ] Generador turnos crea PDF descargable.\n- [ ] Casos clínicos MIR: 17 especialidades, >100 preguntas.\n- [ ] Fármacos urgencia: 51 entries listados.\n- [ ] Buzón sugerencias → `sugerencias/{autoId}` creado.\n- [ ] Plantillas informes: 3 tabs funcionando.\n- [ ] Enfermería módulo accesible desde menú principal.\n- [ ] Camas y ambulancias accesibles.\n- [ ] Mapa sanitario carga.\n- [ ] Dashboard accesible tras login superadmin.\n\n## Seguridad y compliance\n\n- [ ] DevTools → Network al usar IA: **única** llamada a `*-docenciacartagenaeste.cloudfunctions.net/askAi`. Cero a groq/openrouter/deepseek/pollinations/dashscope/mistral directo.\n- [ ] App Check enforce: tras activar en consola (fase posterior), petición desde origen no autorizado → `failed-precondition`.\n- [ ] Firestore rules: usuario A no lee datos de usuario B. Verificar con dos cuentas Google + DevTools Firestore inspect.\n- [ ] Cookie banner aparece a usuario nuevo.\n- [ ] Disclaimer formativo visible en home y páginas clínicas.\n- [ ] `/privacidad.html` accesible y actualizada.\n- [ ] `/informacion.html` (aviso legal) accesible.\n- [ ] Headers de seguridad presentes: `curl -I https://area2cartagena.es/ | grep -Ei \"strict-transport|content-type-options|permissions-policy\"`.\n- [ ] `gitleaks detect --source .` → 0 findings en el último commit.\n- [ ] Grep control en prod: `curl -s https://area2cartagena.es/index.html | grep -cE \"api.(groq|deepseek|mistral).com|openrouter.ai/api|pollinations|dashscope\"` = 0.\n\n## Datos\n\n- [ ] Exportar mis datos: botón genera JSON con mis casos/notas.\n- [ ] Eliminar mi cuenta: borra docs `users/{uid}` y subcolecciones. `auditLogs` preserva la traza.\n- [ ] Backup diario: verificar en bucket `gs://docenciacartagenaeste-backups/firestore/YYYY-MM-DD/` (tras primera ejecución del cron).\n- [ ] Audit log inmutable: intentar `db.collection('auditLogs').doc('foo').update({x:1})` desde consola de un usuario → permission-denied.\n\n## Monitoring\n\n- [ ] Sentry recibe exception de prueba en <30s (tras setear `window.SENTRY_DSN`).\n- [ ] `firebase functions:log --only askAi --lines 10` muestra entradas con metadatos (sin texto del prompt).\n- [ ] `/status.html` muestra métricas reales.\n- [ ] Alerta de prueba por email (forzar error rate > 5% con 10 llamadas a askAi con prompt inválido).\n\n## PWA y rendimiento\n\n- [ ] PWA installable en Chrome desktop (icono + en barra de direcciones).\n- [ ] PWA installable en Safari iOS (Compartir → Añadir a pantalla de inicio).\n- [ ] Service Worker v74 activo: DevTools → Application → Service Workers → status \"activated\".\n- [ ] Update flow funciona: merge trivial + redeploy → banner \"Nueva versión disponible\".\n- [ ] Lighthouse Performance mobile ≥ 75 en `/`.\n- [ ] Lighthouse Accessibility ≥ 85.\n- [ ] Lighthouse Best Practices ≥ 85.\n- [ ] Lighthouse PWA = 100.\n- [ ] Sin console errors en `/`, `/casos-clinicos.html`, `/calculadoras.html`, `/mapa.html`, `/status.html`.\n\n## Responsive\n\n- [ ] 375px (iPhone SE) — layout no roto.\n- [ ] 412px (Pixel / Android estándar) — layout no roto.\n- [ ] 768px (iPad) — layout adapta a 2 columnas donde aplica.\n- [ ] 1440px (desktop) — layout usa ancho máx 1200-1400px.\n\n## Repositorio\n\n- [ ] README renderiza bien en GitHub.\n- [ ] LICENSE visible y legible.\n- [ ] IP_ATTESTATION.md visible con datos correctos (Reg. 00765-03096622).\n- [ ] CHANGELOG.md refleja v1.0.0.\n- [ ] CI verde en main (última build).\n- [ ] Branch protection activa en `main` (Settings → Branches → add rule).\n- [ ] No secretos en `git log -p | grep -iE 'sk-[a-f0-9]{32}|AIza[0-9A-Za-z_-]{35}|gsk_[a-zA-Z0-9]{20,}'` (tras `git filter-repo`).\n\n## Script automatizable\n\nEjecutar:\n```bash\nbash test-deploy.sh\n```\n\nDebe retornar 0 failures.\n\n---\n\n**Firma**: ________________________ **Fecha**: __________\n"}}, "test-deploy.sh": {"7893b214009f491a7491098a946bc2e3ff1515cf": {"t": 1785747490.528467, "n": 4500, "h": "e76794f91e6fb542", "c": "#!/bin/bash\n# ══════════════════════════════════════════════════════════════════════\n# test-deploy.sh · Smoke tests post-deploy para Cartagenaeste\n# Uso: bash test-deploy.sh (BASE_URL override opcional)\n# ══════════════════════════════════════════════════════════════════════\n\nset -u\nBASE_URL=\"${BASE_URL:-https://area2cartagena.es}\"\nPASS=0\nFAIL=0\nWARN=0\n\ngreen() { echo -e \"\\033[32m✓ $1\\033[0m\"; PASS=$((PASS+1)); }\nred() { echo -e \"\\033[31m✗ $1\\033[0m\"; FAIL=$((FAIL+1)); }\nyellow() { echo -e \"\\033[33m⚠ $1\\033[0m\"; WARN=$((WARN+1)); }\n\necho \"══════════════════════════════════════════════════\"\necho \"🏥 Smoke Tests · Cartagenaeste · $BASE_URL\"\necho \" $(date '+%Y-%m-%d %H:%M:%S')\"\necho \"══════════════════════════════════════════════════\"\n\n# ── 1. URLs principales ──\necho \"\"\necho \"── 1. Carga básica ──\"\nfor path in \"/\" \"/notebook-local.html\" \"/chatbot-medicacion.html\" \"/status.html\" \"/privacidad.html\" \"/offline.html\" \"/manifest.json\" \"/sw.js\" \"/ai-client.js\" \"/errorMessages.js\"; do\n code=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$BASE_URL$path\")\n if [ \"$code\" = \"200\" ]; then green \"$path\"; else red \"$path HTTP $code\"; fi\ndone\n\n# ── 2. Manifest PWA ──\necho \"\"\necho \"── 2. Manifest PWA ──\"\nif curl -sf \"$BASE_URL/manifest.json\" | python3 -m json.tool >/dev/null 2>&1; then\n green \"manifest.json JSON válido\"\nelse\n red \"manifest.json inválido\"\nfi\n\n# ── 3. Claves API ──\necho \"\"\necho \"── 3. Claves API en frontend ──\"\nHTML=$(curl -s \"$BASE_URL/index.html\")\nMODULES=$(curl -s \"$BASE_URL/app-modules.js\")\nAPPMAIN=$(curl -s \"$BASE_URL/app-main.js\")\ncombined=\"$HTML$MODULES$APPMAIN\"\n\nfor pattern in 'sk-[a-f0-9]\\{32\\}' 'sk-or-v1-[a-f0-9]\\{20,\\}' 'gsk_[a-zA-Z0-9]\\{20,\\}' 'fh_[a-zA-Z0-9]\\{10,\\}'; do\n count=$(echo \"$combined\" | grep -cE \"$pattern\" || true)\n if [ \"$count\" = \"0\" ]; then green \"Patrón '$pattern' ausente\"; else red \"Patrón '$pattern' presente ($count)\"; fi\ndone\n\n# ── 4. URLs directas a proveedores IA ──\necho \"\"\necho \"── 4. URLs directas IA ──\"\nfor endpoint in \"api.groq.com\" \"api.deepseek.com\" \"api.mistral.ai\" \"openrouter.ai/api\" \"pollinations.ai\" \"dashscope-intl.aliyuncs\"; do\n count=$(echo \"$combined\" | grep -cF \"$endpoint\" || true)\n if [ \"$count\" = \"0\" ]; then green \"Sin $endpoint\"; else red \"$count refs a $endpoint\"; fi\ndone\n\n# ── 5. IP interna ──\necho \"\"\necho \"── 5. IP interna ──\"\ncount=$(echo \"$combined\" | grep -cE \"192\\.168\\.[0-9]+\\.[0-9]+\" || true)\nif [ \"$count\" = \"0\" ]; then green \"Sin IPs internas\"; else red \"$count IPs internas\"; fi\n\n# ── 6. Service Worker ──\necho \"\"\necho \"── 6. Service Worker ──\"\nSW=$(curl -s \"$BASE_URL/sw.js\")\nif echo \"$SW\" | grep -qE \"CACHE_NAME\\s*=\\s*'area2-v7[4-9]|'area2-v[89][0-9]\"; then green \"sw.js v74+\"; else yellow \"sw.js versión anterior a v74\"; fi\nif echo \"$SW\" | grep -q \"skipWaiting\"; then green \"skipWaiting presente\"; else red \"sw.js sin skipWaiting\"; fi\n\n# ── 7. Headers / meta seguridad ──\necho \"\"\necho \"── 7. Security headers ──\"\nHDR=$(curl -sI \"$BASE_URL/\"; echo \"---\"; echo \"$HTML\" | head -100)\necho \"$HDR\" | grep -qi \"strict-transport-security\" && green \"HSTS\" || yellow \"HSTS ausente\"\necho \"$HDR\" | grep -qi \"x-content-type-options.*nosniff\" && green \"nosniff\" || yellow \"nosniff ausente\"\necho \"$HDR\" | grep -qi \"Permissions-Policy\" && green \"Permissions-Policy\" || yellow \"Permissions-Policy ausente\"\necho \"$HDR\" | grep -qi \"Content-Security-Policy\" && green \"CSP\" || red \"CSP ausente\"\n\n# ── 8. publicMetrics endpoint ──\necho \"\"\necho \"── 8. publicMetrics ──\"\nM_URL=\"https://europe-west1-docenciacartagenaeste.cloudfunctions.net/publicMetrics\"\nMS=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$M_URL\")\nif [ \"$MS\" = \"200\" ]; then\n green \"publicMetrics HTTP 200\"\n if curl -sf \"$M_URL\" | python3 -m json.tool >/dev/null 2>&1; then green \"publicMetrics JSON válido\"; fi\nelse\n yellow \"publicMetrics HTTP $MS (¿función no desplegada?)\"\nfi\n\n# ── 9. Metadatos limpios ──\necho \"\"\necho \"── 9. Metadatos ──\"\nif echo \"$HTML\" | grep -qE \"build 17[0-9]{8}\"; then red \"Title con build timestamp\"; else green \"Title limpio\"; fi\nif echo \"$HTML\" | grep -qiE \"IA diagn[oó]stica\"; then red \"Copy 'IA diagnóstica'\"; else green \"Copy 'IA docente'\"; fi\n\n# ── Resumen ──\necho \"\"\necho \"══════════════════════════════════════════════════\"\nTOTAL=$((PASS+FAIL+WARN))\necho \"📊 Resultados: $PASS/$TOTAL ok, $FAIL fail, $WARN warn\"\nif [ \"$FAIL\" -gt 0 ]; then\n echo -e \"\\033[31m❌ HAY FALLOS\\033[0m\"\n exit 1\nelse\n echo -e \"\\033[32m✅ Smoke tests OK\\033[0m\"\n exit 0\nfi\n"}, "622ad0fc83d0eb07a9514b7b77f5621177d34c5e": {"t": 1785754493.3471813, "n": 4500, "h": "e76794f91e6fb542", "c": "#!/bin/bash\n# ══════════════════════════════════════════════════════════════════════\n# test-deploy.sh · Smoke tests post-deploy para Cartagenaeste\n# Uso: bash test-deploy.sh (BASE_URL override opcional)\n# ══════════════════════════════════════════════════════════════════════\n\nset -u\nBASE_URL=\"${BASE_URL:-https://area2cartagena.es}\"\nPASS=0\nFAIL=0\nWARN=0\n\ngreen() { echo -e \"\\033[32m✓ $1\\033[0m\"; PASS=$((PASS+1)); }\nred() { echo -e \"\\033[31m✗ $1\\033[0m\"; FAIL=$((FAIL+1)); }\nyellow() { echo -e \"\\033[33m⚠ $1\\033[0m\"; WARN=$((WARN+1)); }\n\necho \"══════════════════════════════════════════════════\"\necho \"🏥 Smoke Tests · Cartagenaeste · $BASE_URL\"\necho \" $(date '+%Y-%m-%d %H:%M:%S')\"\necho \"══════════════════════════════════════════════════\"\n\n# ── 1. URLs principales ──\necho \"\"\necho \"── 1. Carga básica ──\"\nfor path in \"/\" \"/notebook-local.html\" \"/chatbot-medicacion.html\" \"/status.html\" \"/privacidad.html\" \"/offline.html\" \"/manifest.json\" \"/sw.js\" \"/ai-client.js\" \"/errorMessages.js\"; do\n code=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$BASE_URL$path\")\n if [ \"$code\" = \"200\" ]; then green \"$path\"; else red \"$path HTTP $code\"; fi\ndone\n\n# ── 2. Manifest PWA ──\necho \"\"\necho \"── 2. Manifest PWA ──\"\nif curl -sf \"$BASE_URL/manifest.json\" | python3 -m json.tool >/dev/null 2>&1; then\n green \"manifest.json JSON válido\"\nelse\n red \"manifest.json inválido\"\nfi\n\n# ── 3. Claves API ──\necho \"\"\necho \"── 3. Claves API en frontend ──\"\nHTML=$(curl -s \"$BASE_URL/index.html\")\nMODULES=$(curl -s \"$BASE_URL/app-modules.js\")\nAPPMAIN=$(curl -s \"$BASE_URL/app-main.js\")\ncombined=\"$HTML$MODULES$APPMAIN\"\n\nfor pattern in 'sk-[a-f0-9]\\{32\\}' 'sk-or-v1-[a-f0-9]\\{20,\\}' 'gsk_[a-zA-Z0-9]\\{20,\\}' 'fh_[a-zA-Z0-9]\\{10,\\}'; do\n count=$(echo \"$combined\" | grep -cE \"$pattern\" || true)\n if [ \"$count\" = \"0\" ]; then green \"Patrón '$pattern' ausente\"; else red \"Patrón '$pattern' presente ($count)\"; fi\ndone\n\n# ── 4. URLs directas a proveedores IA ──\necho \"\"\necho \"── 4. URLs directas IA ──\"\nfor endpoint in \"api.groq.com\" \"api.deepseek.com\" \"api.mistral.ai\" \"openrouter.ai/api\" \"pollinations.ai\" \"dashscope-intl.aliyuncs\"; do\n count=$(echo \"$combined\" | grep -cF \"$endpoint\" || true)\n if [ \"$count\" = \"0\" ]; then green \"Sin $endpoint\"; else red \"$count refs a $endpoint\"; fi\ndone\n\n# ── 5. IP interna ──\necho \"\"\necho \"── 5. IP interna ──\"\ncount=$(echo \"$combined\" | grep -cE \"192\\.168\\.[0-9]+\\.[0-9]+\" || true)\nif [ \"$count\" = \"0\" ]; then green \"Sin IPs internas\"; else red \"$count IPs internas\"; fi\n\n# ── 6. Service Worker ──\necho \"\"\necho \"── 6. Service Worker ──\"\nSW=$(curl -s \"$BASE_URL/sw.js\")\nif echo \"$SW\" | grep -qE \"CACHE_NAME\\s*=\\s*'area2-v7[4-9]|'area2-v[89][0-9]\"; then green \"sw.js v74+\"; else yellow \"sw.js versión anterior a v74\"; fi\nif echo \"$SW\" | grep -q \"skipWaiting\"; then green \"skipWaiting presente\"; else red \"sw.js sin skipWaiting\"; fi\n\n# ── 7. Headers / meta seguridad ──\necho \"\"\necho \"── 7. Security headers ──\"\nHDR=$(curl -sI \"$BASE_URL/\"; echo \"---\"; echo \"$HTML\" | head -100)\necho \"$HDR\" | grep -qi \"strict-transport-security\" && green \"HSTS\" || yellow \"HSTS ausente\"\necho \"$HDR\" | grep -qi \"x-content-type-options.*nosniff\" && green \"nosniff\" || yellow \"nosniff ausente\"\necho \"$HDR\" | grep -qi \"Permissions-Policy\" && green \"Permissions-Policy\" || yellow \"Permissions-Policy ausente\"\necho \"$HDR\" | grep -qi \"Content-Security-Policy\" && green \"CSP\" || red \"CSP ausente\"\n\n# ── 8. publicMetrics endpoint ──\necho \"\"\necho \"── 8. publicMetrics ──\"\nM_URL=\"https://europe-west1-docenciacartagenaeste.cloudfunctions.net/publicMetrics\"\nMS=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 \"$M_URL\")\nif [ \"$MS\" = \"200\" ]; then\n green \"publicMetrics HTTP 200\"\n if curl -sf \"$M_URL\" | python3 -m json.tool >/dev/null 2>&1; then green \"publicMetrics JSON válido\"; fi\nelse\n yellow \"publicMetrics HTTP $MS (¿función no desplegada?)\"\nfi\n\n# ── 9. Metadatos limpios ──\necho \"\"\necho \"── 9. Metadatos ──\"\nif echo \"$HTML\" | grep -qE \"build 17[0-9]{8}\"; then red \"Title con build timestamp\"; else green \"Title limpio\"; fi\nif echo \"$HTML\" | grep -qiE \"IA diagn[oó]stica\"; then red \"Copy 'IA diagnóstica'\"; else green \"Copy 'IA docente'\"; fi\n\n# ── Resumen ──\necho \"\"\necho \"══════════════════════════════════════════════════\"\nTOTAL=$((PASS+FAIL+WARN))\necho \"📊 Resultados: $PASS/$TOTAL ok, $FAIL fail, $WARN warn\"\nif [ \"$FAIL\" -gt 0 ]; then\n echo -e \"\\033[31m❌ HAY FALLOS\\033[0m\"\n exit 1\nelse\n echo -e \"\\033[32m✅ Smoke tests OK\\033[0m\"\n exit 0\nfi\n"}, "68258126cbb20aa0dc42c7336d4be11326338ba7": {"t": 1785754495.388154, "n": 5255, "h": "156f2c1a88ffc0ee", "c": "#!/bin/bash\n# ══════════════════════════════════════════════════════════════════════\n# test-deploy.sh — Smoke tests para la PWA Área II Cartagena\n# Ejecutar después de cada deploy: bash test-deploy.sh\n# ══════════════════════════════════════════════════════════════════════\n\nBASE_URL=\"https://carlosgalera-a11y.github.io/Cartagenaeste\"\nPASS=0\nFAIL=0\nWARN=0\n\ngreen() { echo -e \"\\033[32m✓ $1\\033[0m\"; PASS=$((PASS+1)); }\nred() { echo -e \"\\033[31m✗ $1\\033[0m\"; FAIL=$((FAIL+1)); }\nyellow(){ echo -e \"\\033[33m⚠ $1\\033[0m\"; WARN=$((WARN+1)); }\n\necho \"══════════════════════════════════════════════════\"\necho \"🏥 Smoke Tests — Área II Cartagena PWA\"\necho \" $(date '+%Y-%m-%d %H:%M:%S')\"\necho \"══════════════════════════════════════════════════\"\necho \"\"\n\n# ── 1. Página principal carga ──\necho \"── Carga básica ──\"\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/\")\nif [ \"$HTTP\" = \"200\" ]; then green \"index.html carga (HTTP $HTTP)\"; else red \"index.html NO carga (HTTP $HTTP)\"; fi\n\n# ── 2. Archivos JS críticos ──\necho \"\"\necho \"── Archivos JS ──\"\nfor f in app-main.js app-modules.js triaje-ia.js api-config.js escalas-clinicas.js; do\n HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/$f\")\n if [ \"$HTTP\" = \"200\" ]; then green \"$f OK\"; else red \"$f FALTA (HTTP $HTTP)\"; fi\ndone\n\n# ── 3. Service Worker ──\necho \"\"\necho \"── Service Worker ──\"\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/sw.js\")\nif [ \"$HTTP\" = \"200\" ]; then green \"sw.js presente\"; else yellow \"sw.js no accesible (HTTP $HTTP)\"; fi\n\n# ── 4. Manifest PWA ──\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/manifest.json\")\nif [ \"$HTTP\" = \"200\" ]; then green \"manifest.json presente\"; else yellow \"manifest.json no accesible\"; fi\n\n# ── 5. Contenido HTML — secciones principales ──\necho \"\"\necho \"── Secciones HTML ──\"\nHTML=$(curl -s \"$BASE_URL/\")\nfor section in pageLanding pagePatients pageProfessionals pageProtocolosAP pageProtocolosUrgencias pageTriaje pageScanIA pageEnfermeria; do\n if echo \"$HTML\" | grep -q \"id=\\\"$section\\\"\"; then green \"$section presente\"; else red \"$section FALTA en HTML\"; fi\ndone\n\n# ── 6. API Keys NO expuestas en texto claro ──\necho \"\"\necho \"── Seguridad: API Keys ──\"\n# Patrones genéricos de claves expuestas (no hardcodeamos la clave aquí)\nMODULES=$(curl -s \"$BASE_URL/app-modules.js\")\nif echo \"$HTML\" | grep -qE 'sk-[a-f0-9]{32}'; then red \"Posible clave sk- expuesta en index.html\"; else green \"Sin claves sk- en index.html\"; fi\nif echo \"$MODULES\" | grep -qE 'sk-[a-f0-9]{32}'; then red \"Posible clave sk- expuesta en app-modules.js\"; else green \"Sin claves sk- en app-modules.js\"; fi\nif echo \"$HTML\" | grep -qE '192\\.168\\.[0-9]+\\.[0-9]+'; then red \"IP privada expuesta en index.html\"; else green \"Sin IPs privadas en index.html\"; fi\n\n# ── 7. Fármacos de Urgencia ──\necho \"\"\necho \"── Fármacos de Urgencia ──\"\nif echo \"$HTML\" | grep -q \"urg-farmacos-content\"; then green \"Sección Fármacos presente\"; else red \"Sección Fármacos FALTA\"; fi\nif echo \"$HTML\" | grep -q \"URG_FARMACOS\"; then green \"Array fármacos cargado\"; else red \"Array fármacos FALTA\"; fi\n\n# ── 8. Triaje — Fix auth permanente ──\necho \"\"\necho \"── Triaje: Fix auth ──\"\nif echo \"$MODULES\" | grep -q \"firebase.auth().currentUser\"; then green \"Guard auth en triaje presente\"; else red \"Guard auth en triaje FALTA — riesgo de 'insufficient permissions'\"; fi\nif echo \"$MODULES\" | grep -q \"NO ELIMINAR\"; then green \"Comentario protección permanente OK\"; else yellow \"Comentario protección no encontrado\"; fi\n\n# ── 9. Triaje ficha ──\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/triaje-ficha.html\")\nif [ \"$HTTP\" = \"200\" ]; then green \"triaje-ficha.html accesible\"; else yellow \"triaje-ficha.html no accesible (HTTP $HTTP)\"; fi\n\n# ── 10. PDFs de trípticos (muestra) ──\necho \"\"\necho \"── Recursos (muestra) ──\"\nfor pdf in \"tripticos/triptico-fibrilacion-auricular.pdf\" \"recursos-sociales-grado-dependencia.pdf\"; do\n HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/$pdf\")\n if [ \"$HTTP\" = \"200\" ]; then green \"$pdf OK\"; else yellow \"$pdf no accesible (HTTP $HTTP)\"; fi\ndone\n\n# ── 11. API endpoints (solo si LOCAL_AI_PROXY está definido) ──\necho \"\"\necho \"── API proxy local (opcional) ──\"\nif [ -n \"$LOCAL_AI_PROXY\" ]; then\n NAS_HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" --connect-timeout 3 \"$LOCAL_AI_PROXY/health\" 2>/dev/null)\n if [ \"$NAS_HTTP\" = \"200\" ]; then\n green \"Proxy local accesible\"\n AI_HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" --connect-timeout 5 -X POST \"$LOCAL_AI_PROXY/ai/chat\" -H \"Content-Type: application/json\" -d '{\"messages\":[{\"role\":\"user\",\"content\":\"test\"}],\"max_tokens\":5}' 2>/dev/null)\n if [ \"$AI_HTTP\" = \"200\" ]; then green \"Proxy AI endpoint responde\"; else yellow \"Proxy AI endpoint: HTTP $AI_HTTP\"; fi\n else\n yellow \"Proxy local no accesible\"\n fi\nelse\n yellow \"LOCAL_AI_PROXY no definido — test omitido (producción usa Cloud Function)\"\nfi\n\n# ── Resumen ──\necho \"\"\necho \"══════════════════════════════════════════════════\"\nTOTAL=$((PASS+FAIL+WARN))\necho \"📊 Resultados: $PASS/$TOTAL passed, $FAIL failed, $WARN warnings\"\nif [ \"$FAIL\" -gt 0 ]; then\n echo -e \"\\033[31m❌ HAY FALLOS — revisar antes de continuar\\033[0m\"\n exit 1\nelse\n echo -e \"\\033[32m✅ Todo OK — deploy seguro\\033[0m\"\n exit 0\nfi\n"}, "a47e7f173480a31fb1aba641fe618d31186480a4": {"t": 1785754496.7536361, "n": 5152, "h": "a59e763d546a31b7", "c": "#!/bin/bash\n# ══════════════════════════════════════════════════════════════════════\n# test-deploy.sh — Smoke tests para la PWA Área II Cartagena\n# Ejecutar después de cada deploy: bash test-deploy.sh\n# ══════════════════════════════════════════════════════════════════════\n\nBASE_URL=\"https://carlosgalera-a11y.github.io/Cartagenaeste\"\nPASS=0\nFAIL=0\nWARN=0\n\ngreen() { echo -e \"\\033[32m✓ $1\\033[0m\"; PASS=$((PASS+1)); }\nred() { echo -e \"\\033[31m✗ $1\\033[0m\"; FAIL=$((FAIL+1)); }\nyellow(){ echo -e \"\\033[33m⚠ $1\\033[0m\"; WARN=$((WARN+1)); }\n\necho \"══════════════════════════════════════════════════\"\necho \"🏥 Smoke Tests — Área II Cartagena PWA\"\necho \" $(date '+%Y-%m-%d %H:%M:%S')\"\necho \"══════════════════════════════════════════════════\"\necho \"\"\n\n# ── 1. Página principal carga ──\necho \"── Carga básica ──\"\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/\")\nif [ \"$HTTP\" = \"200\" ]; then green \"index.html carga (HTTP $HTTP)\"; else red \"index.html NO carga (HTTP $HTTP)\"; fi\n\n# ── 2. Archivos JS críticos ──\necho \"\"\necho \"── Archivos JS ──\"\nfor f in app-main.js app-modules.js triaje-ia.js api-config.js escalas-clinicas.js; do\n HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/$f\")\n if [ \"$HTTP\" = \"200\" ]; then green \"$f OK\"; else red \"$f FALTA (HTTP $HTTP)\"; fi\ndone\n\n# ── 3. Service Worker ──\necho \"\"\necho \"── Service Worker ──\"\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/sw.js\")\nif [ \"$HTTP\" = \"200\" ]; then green \"sw.js presente\"; else yellow \"sw.js no accesible (HTTP $HTTP)\"; fi\n\n# ── 4. Manifest PWA ──\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/manifest.json\")\nif [ \"$HTTP\" = \"200\" ]; then green \"manifest.json presente\"; else yellow \"manifest.json no accesible\"; fi\n\n# ── 5. Contenido HTML — secciones principales ──\necho \"\"\necho \"── Secciones HTML ──\"\nHTML=$(curl -s \"$BASE_URL/\")\nfor section in pageLanding pagePatients pageProfessionals pageProtocolosAP pageProtocolosUrgencias pageTriaje pageScanIA pageEnfermeria; do\n if echo \"$HTML\" | grep -q \"id=\\\"$section\\\"\"; then green \"$section presente\"; else red \"$section FALTA en HTML\"; fi\ndone\n\n# ── 6. API Keys NO expuestas en texto claro ──\necho \"\"\necho \"── Seguridad: API Keys ──\"\n# Verificar que no hay keys de DeepSeek en claro\nDS_KEYS=$(echo \"$HTML\" | grep -c \"REDACTED_DEEPSEEK_OLD_2026-04\")\nif [ \"$DS_KEYS\" = \"0\" ]; then green \"DeepSeek key NO expuesta en index.html\"; else red \"DeepSeek key EXPUESTA en index.html ($DS_KEYS ocurrencias)\"; fi\n\n# Verificar app-modules.js\nMODULES=$(curl -s \"$BASE_URL/app-modules.js\")\nDS_MOD=$(echo \"$MODULES\" | grep -c \"REDACTED_DEEPSEEK_OLD_2026-04\")\nif [ \"$DS_MOD\" = \"0\" ]; then green \"DeepSeek key NO expuesta en app-modules.js\"; else red \"DeepSeek key EXPUESTA en app-modules.js ($DS_MOD ocurrencias)\"; fi\n\n# ── 7. Fármacos de Urgencia ──\necho \"\"\necho \"── Fármacos de Urgencia ──\"\nif echo \"$HTML\" | grep -q \"urg-farmacos-content\"; then green \"Sección Fármacos presente\"; else red \"Sección Fármacos FALTA\"; fi\nif echo \"$HTML\" | grep -q \"URG_FARMACOS\"; then green \"Array fármacos cargado\"; else red \"Array fármacos FALTA\"; fi\n\n# ── 8. Triaje — Fix auth permanente ──\necho \"\"\necho \"── Triaje: Fix auth ──\"\nif echo \"$MODULES\" | grep -q \"firebase.auth().currentUser\"; then green \"Guard auth en triaje presente\"; else red \"Guard auth en triaje FALTA — riesgo de 'insufficient permissions'\"; fi\nif echo \"$MODULES\" | grep -q \"NO ELIMINAR\"; then green \"Comentario protección permanente OK\"; else yellow \"Comentario protección no encontrado\"; fi\n\n# ── 9. Triaje ficha ──\nHTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/triaje-ficha.html\")\nif [ \"$HTTP\" = \"200\" ]; then green \"triaje-ficha.html accesible\"; else yellow \"triaje-ficha.html no accesible (HTTP $HTTP)\"; fi\n\n# ── 10. PDFs de trípticos (muestra) ──\necho \"\"\necho \"── Recursos (muestra) ──\"\nfor pdf in \"tripticos/triptico-fibrilacion-auricular.pdf\" \"recursos-sociales-grado-dependencia.pdf\"; do\n HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" \"$BASE_URL/$pdf\")\n if [ \"$HTTP\" = \"200\" ]; then green \"$pdf OK\"; else yellow \"$pdf no accesible (HTTP $HTTP)\"; fi\ndone\n\n# ── 11. API endpoints (solo si NAS accesible) ──\necho \"\"\necho \"── API/NAS (solo red local) ──\"\nNAS_HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" --connect-timeout 3 \"http://REDACTED_INTERNAL_IP:3100/health\" 2>/dev/null)\nif [ \"$NAS_HTTP\" = \"200\" ]; then\n green \"NAS proxy accesible\"\n # Test AI endpoint\n AI_HTTP=$(curl -s -o /dev/null -w \"%{http_code}\" --connect-timeout 5 -X POST \"http://REDACTED_INTERNAL_IP:3100/ai/chat\" -H \"Content-Type: application/json\" -d '{\"messages\":[{\"role\":\"user\",\"content\":\"test\"}],\"max_tokens\":5}' 2>/dev/null)\n if [ \"$AI_HTTP\" = \"200\" ]; then green \"NAS AI endpoint responde\"; else yellow \"NAS AI endpoint: HTTP $AI_HTTP\"; fi\nelse\n yellow \"NAS no accesible (no estás en red local)\"\nfi\n\n# ── Resumen ──\necho \"\"\necho \"══════════════════════════════════════════════════\"\nTOTAL=$((PASS+FAIL+WARN))\necho \"📊 Resultados: $PASS/$TOTAL passed, $FAIL failed, $WARN warnings\"\nif [ \"$FAIL\" -gt 0 ]; then\n echo -e \"\\033[31m❌ HAY FALLOS — revisar antes de continuar\\033[0m\"\n exit 1\nelse\n echo -e \"\\033[32m✅ Todo OK — deploy seguro\\033[0m\"\n exit 0\nfi\n"}}, "docs/release-v1.0.0-evidence/smoke-test-output.txt": {"7893b214009f491a7491098a946bc2e3ff1515cf": {"t": 1785747491.721414, "n": 1485, "h": "37c3e5e81f9d5006", "c": "══════════════════════════════════════════════════\n🏥 Smoke Tests · Cartagenaeste · https://area2cartagena.es\n 2026-04-21 01:20:02\n══════════════════════════════════════════════════\n\n── 1. Carga básica ──\n\u001b[32m✓ /\u001b[0m\n\u001b[32m✓ /notebook-local.html\u001b[0m\n\u001b[32m✓ /chatbot-medicacion.html\u001b[0m\n\u001b[32m✓ /status.html\u001b[0m\n\u001b[32m✓ /privacidad.html\u001b[0m\n\u001b[32m✓ /offline.html\u001b[0m\n\u001b[32m✓ /manifest.json\u001b[0m\n\u001b[32m✓ /sw.js\u001b[0m\n\u001b[32m✓ /ai-client.js\u001b[0m\n\u001b[32m✓ /errorMessages.js\u001b[0m\n\n── 2. Manifest PWA ──\n\u001b[32m✓ manifest.json JSON válido\u001b[0m\n\n── 3. Claves API en frontend ──\n\u001b[32m✓ Patrón 'sk-[a-f0-9]\\{32\\}' ausente\u001b[0m\n\u001b[32m✓ Patrón 'sk-or-v1-[a-f0-9]\\{20,\\}' ausente\u001b[0m\n\u001b[32m✓ Patrón 'gsk_[a-zA-Z0-9]\\{20,\\}' ausente\u001b[0m\n\u001b[32m✓ Patrón 'fh_[a-zA-Z0-9]\\{10,\\}' ausente\u001b[0m\n\n── 4. URLs directas IA ──\n\u001b[32m✓ Sin api.groq.com\u001b[0m\n\u001b[32m✓ Sin api.deepseek.com\u001b[0m\n\u001b[32m✓ Sin api.mistral.ai\u001b[0m\n\u001b[31m✗ 2 refs a openrouter.ai/api\u001b[0m\n\u001b[32m✓ Sin pollinations.ai\u001b[0m\n\u001b[32m✓ Sin dashscope-intl.aliyuncs\u001b[0m\n\n── 5. IP interna ──\n\u001b[32m✓ Sin IPs internas\u001b[0m\n\n── 6. Service Worker ──\n\u001b[32m✓ sw.js v74+\u001b[0m\n\u001b[32m✓ skipWaiting presente\u001b[0m\n\n── 7. Security headers ──\n\u001b[32m✓ HSTS\u001b[0m\n\u001b[32m✓ nosniff\u001b[0m\n\u001b[32m✓ Permissions-Policy\u001b[0m\n\u001b[32m✓ CSP\u001b[0m\n\n── 8. publicMetrics ──\n\u001b[33m⚠ publicMetrics HTTP 404 (¿función no desplegada?)\u001b[0m\n\n── 9. Metadatos ──\n\u001b[32m✓ Title limpio\u001b[0m\n\u001b[32m✓ Copy 'IA docente'\u001b[0m\n\n══════════════════════════════════════════════════\n📊 Resultados: 29/31 ok, 1 fail, 1 warn\n\u001b[31m❌ HAY FALLOS\u001b[0m\n"}, "622ad0fc83d0eb07a9514b7b77f5621177d34c5e": {"t": 1785754495.593209, "n": 1485, "h": "37c3e5e81f9d5006", "c": "══════════════════════════════════════════════════\n🏥 Smoke Tests · Cartagenaeste · https://area2cartagena.es\n 2026-04-21 01:20:02\n══════════════════════════════════════════════════\n\n── 1. Carga básica ──\n\u001b[32m✓ /\u001b[0m\n\u001b[32m✓ /notebook-local.html\u001b[0m\n\u001b[32m✓ /chatbot-medicacion.html\u001b[0m\n\u001b[32m✓ /status.html\u001b[0m\n\u001b[32m✓ /privacidad.html\u001b[0m\n\u001b[32m✓ /offline.html\u001b[0m\n\u001b[32m✓ /manifest.json\u001b[0m\n\u001b[32m✓ /sw.js\u001b[0m\n\u001b[32m✓ /ai-client.js\u001b[0m\n\u001b[32m✓ /errorMessages.js\u001b[0m\n\n── 2. Manifest PWA ──\n\u001b[32m✓ manifest.json JSON válido\u001b[0m\n\n── 3. Claves API en frontend ──\n\u001b[32m✓ Patrón 'sk-[a-f0-9]\\{32\\}' ausente\u001b[0m\n\u001b[32m✓ Patrón 'sk-or-v1-[a-f0-9]\\{20,\\}' ausente\u001b[0m\n\u001b[32m✓ Patrón 'gsk_[a-zA-Z0-9]\\{20,\\}' ausente\u001b[0m\n\u001b[32m✓ Patrón 'fh_[a-zA-Z0-9]\\{10,\\}' ausente\u001b[0m\n\n── 4. URLs directas IA ──\n\u001b[32m✓ Sin api.groq.com\u001b[0m\n\u001b[32m✓ Sin api.deepseek.com\u001b[0m\n\u001b[32m✓ Sin api.mistral.ai\u001b[0m\n\u001b[31m✗ 2 refs a openrouter.ai/api\u001b[0m\n\u001b[32m✓ Sin pollinations.ai\u001b[0m\n\u001b[32m✓ Sin dashscope-intl.aliyuncs\u001b[0m\n\n── 5. IP interna ──\n\u001b[32m✓ Sin IPs internas\u001b[0m\n\n── 6. Service Worker ──\n\u001b[32m✓ sw.js v74+\u001b[0m\n\u001b[32m✓ skipWaiting presente\u001b[0m\n\n── 7. Security headers ──\n\u001b[32m✓ HSTS\u001b[0m\n\u001b[32m✓ nosniff\u001b[0m\n\u001b[32m✓ Permissions-Policy\u001b[0m\n\u001b[32m✓ CSP\u001b[0m\n\n── 8. publicMetrics ──\n\u001b[33m⚠ publicMetrics HTTP 404 (¿función no desplegada?)\u001b[0m\n\n── 9. Metadatos ──\n\u001b[32m✓ Title limpio\u001b[0m\n\u001b[32m✓ Copy 'IA docente'\u001b[0m\n\n══════════════════════════════════════════════════\n📊 Resultados: 29/31 ok, 1 fail, 1 warn\n\u001b[31m❌ HAY FALLOS\u001b[0m\n"}}, "functions/test/getApiBalances.test.ts": {"7893b214009f491a7491098a946bc2e3ff1515cf": {"t": 1786068368.8204772, "n": 10565, "h": "41f6890d62b45e60", "c": "import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';\n\n// ── Mocks Firebase admin (no hay emulador en unit tests) ──────────────\nvi.mock('firebase-admin/app', () => ({ getApp: vi.fn(() => ({})) }));\nvi.mock('firebase-admin/auth', () => ({\n getAuth: vi.fn(() => ({\n verifyIdToken: vi.fn().mockResolvedValue({ email: 'carlosgalera2roman@gmail.com' }),\n })),\n}));\nvi.mock('firebase-functions/params', () => ({\n defineSecret: vi.fn((name: string) => ({ value: () => `fake-${name}` })),\n}));\nvi.mock('firebase-functions/v2/https', () => ({\n onRequest: vi.fn((_opts: unknown, handler: unknown) => handler),\n}));\nvi.mock('firebase-functions/v2', () => ({\n logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },\n}));\n\n// ── Importar DESPUÉS de los mocks ──────────────────────────────────────\n// Las funciones internas de fetch que queremos testear están encapsuladas\n// en el módulo. Las testeamos a través de un helper exportado\n// explícitamente para tests, o probando el comportamiento observable.\n// Aquí testeamos la lógica de detección de saldo bajo aislando fetch.\n\nconst LOW_BALANCE_USD = 5.0;\n\n// ── Helper: simula DeepSeek balance API ───────────────────────────────\nfunction makeDeepSeekResponse(currency: string, total_balance: string): Response {\n return new Response(\n JSON.stringify({\n is_available: true,\n balance_infos: [{ currency, total_balance }],\n }),\n { status: 200, headers: { 'Content-Type': 'application/json' } },\n );\n}\n\nfunction makeOpenRouterResponse(total_credits: number, total_usage: number): Response {\n return new Response(\n JSON.stringify({ data: { total_credits, total_usage } }),\n { status: 200, headers: { 'Content-Type': 'application/json' } },\n );\n}\n\nfunction makeErrorResponse(status: number): Response {\n return new Response('{}', { status });\n}\n\n// ── Lógica extraída para unit tests (mirror de getApiBalances.ts) ──────\n// Replicar las funciones puras aquí permite testearlas sin montar la CF.\n\nfunction calcDeepSeekUsd(infos: Array<{ currency: string; total_balance: string }>): number {\n const usd = infos.find((b) => b.currency === 'USD');\n const cny = infos.find((b) => b.currency === 'CNY');\n // Usa || '0' + || 0 para tratar cadena vacía como 0 (parseFloat('') = NaN)\n const balUsd = usd ? (parseFloat(usd.total_balance || '0') || 0) : null;\n const balCny = cny ? (parseFloat(cny.total_balance || '0') || 0) : null;\n return balUsd ?? (balCny !== null ? balCny / 7.2 : 0);\n}\n\nfunction calcOpenRouterUsd(totalCredits: number, totalUsage: number): number {\n return totalCredits - totalUsage;\n}\n\n// ── Tests ─────────────────────────────────────────────────────────────\n\ndescribe('DeepSeek balance — cálculo de saldo USD', () => {\n it('extrae el saldo directamente si la currency es USD', () => {\n const usd = calcDeepSeekUsd([{ currency: 'USD', total_balance: '12.50' }]);\n expect(usd).toBeCloseTo(12.5);\n });\n\n it('convierte CNY→USD (÷7.2) si no hay registro USD', () => {\n const usd = calcDeepSeekUsd([{ currency: 'CNY', total_balance: '72.00' }]);\n expect(usd).toBeCloseTo(10.0, 1);\n });\n\n it('prefiere USD sobre CNY si ambos están presentes', () => {\n const usd = calcDeepSeekUsd([\n { currency: 'USD', total_balance: '8.00' },\n { currency: 'CNY', total_balance: '720.00' },\n ]);\n expect(usd).toBeCloseTo(8.0);\n });\n\n it('devuelve 0 si la lista está vacía', () => {\n expect(calcDeepSeekUsd([])).toBe(0);\n });\n\n it('devuelve 0 si total_balance es cadena vacía', () => {\n expect(calcDeepSeekUsd([{ currency: 'USD', total_balance: '' }])).toBe(0);\n });\n});\n\ndescribe('DeepSeek balance — detección de saldo bajo', () => {\n it('lowBalance=true cuando USD < umbral ($5)', () => {\n const rem = calcDeepSeekUsd([{ currency: 'USD', total_balance: '3.99' }]);\n expect(rem < LOW_BALANCE_USD).toBe(true);\n });\n\n it('lowBalance=false cuando USD >= umbral ($5)', () => {\n const rem = calcDeepSeekUsd([{ currency: 'USD', total_balance: '5.00' }]);\n expect(rem < LOW_BALANCE_USD).toBe(false);\n });\n\n it('lowBalance=false exactamente en el umbral', () => {\n const rem = calcDeepSeekUsd([{ currency: 'USD', total_balance: '5.0000' }]);\n expect(rem < LOW_BALANCE_USD).toBe(false);\n });\n\n it('lowBalance=true cuando saldo CNY convertido < $5', () => {\n // CNY 28 → ~$3.89 USD\n const rem = calcDeepSeekUsd([{ currency: 'CNY', total_balance: '28.00' }]);\n expect(rem < LOW_BALANCE_USD).toBe(true);\n });\n});\n\ndescribe('OpenRouter balance — cálculo y detección', () => {\n it('calcula saldo restante correctamente', () => {\n expect(calcOpenRouterUsd(50, 42.5)).toBeCloseTo(7.5);\n });\n\n it('devuelve 0 cuando credits === usage', () => {\n expect(calcOpenRouterUsd(10, 10)).toBe(0);\n });\n\n it('lowBalance=true cuando remaining < umbral', () => {\n const rem = calcOpenRouterUsd(10, 7.5); // 2.5 restante\n expect(rem < LOW_BALANCE_USD).toBe(true);\n });\n\n it('lowBalance=false cuando remaining >= umbral', () => {\n const rem = calcOpenRouterUsd(20, 10); // 10 restante\n expect(rem < LOW_BALANCE_USD).toBe(false);\n });\n\n it('lowBalance=true con saldo negativo (gasto excede créditos)', () => {\n const rem = calcOpenRouterUsd(5, 6); // -1\n expect(rem < LOW_BALANCE_USD).toBe(true);\n });\n});\n\ndescribe('fetch de balances — respuestas HTTP simuladas', () => {\n let fetchMock: ReturnType<typeof vi.fn>;\n\n beforeEach(() => {\n fetchMock = vi.fn();\n vi.stubGlobal('fetch', fetchMock);\n });\n\n afterEach(() => {\n vi.unstubAllGlobals();\n });\n\n it('DeepSeek: respuesta 200 USD correctamente parseada', async () => {\n fetchMock.mockResolvedValueOnce(makeDeepSeekResponse('USD', '8.50'));\n const r = await fetch('https://api.deepseek.com/user/balance', {\n headers: { Authorization: 'Bearer k' },\n });\n const data = (await r.json()) as { balance_infos: Array<{ currency: string; total_balance: string }> };\n const usd = calcDeepSeekUsd(data.balance_infos ?? []);\n expect(usd).toBeCloseTo(8.5);\n expect(usd < LOW_BALANCE_USD).toBe(false);\n });\n\n it('DeepSeek: respuesta 200 con saldo bajo en USD', async () => {\n fetchMock.mockResolvedValueOnce(makeDeepSeekResponse('USD', '1.20'));\n const r = await fetch('https://api.deepseek.com/user/balance', {\n headers: { Authorization: 'Bearer k' },\n });\n const data = (await r.json()) as { balance_infos: Array<{ currency: string; total_balance: string }> };\n const usd = calcDeepSeekUsd(data.balance_infos ?? []);\n expect(usd).toBeCloseTo(1.2);\n expect(usd < LOW_BALANCE_USD).toBe(true);\n });\n\n it('DeepSeek: HTTP 401 → marca como error sin lanzar excepción', async () => {\n fetchMock.mockResolvedValueOnce(makeErrorResponse(401));\n const r = await fetch('https://api.deepseek.com/user/balance', {\n headers: { Authorization: 'Bearer k' },\n });\n expect(r.ok).toBe(false);\n expect(r.status).toBe(401);\n });\n\n it('OpenRouter: respuesta 200 correctamente parseada', async () => {\n fetchMock.mockResolvedValueOnce(makeOpenRouterResponse(100, 92.5));\n const r = await fetch('https://openrouter.ai/api/v1/credits', {\n headers: { Authorization: 'Bearer k' },\n });\n const data = (await r.json()) as { data: { total_credits: number; total_usage: number } };\n const rem = calcOpenRouterUsd(data.data.total_credits, data.data.total_usage);\n expect(rem).toBeCloseTo(7.5);\n expect(rem < LOW_BALANCE_USD).toBe(false);\n });\n\n it('OpenRouter: saldo bajo correctamente detectado', async () => {\n fetchMock.mockResolvedValueOnce(makeOpenRouterResponse(10, 8.0));\n const r = await fetch('https://openrouter.ai/api/v1/credits', {\n headers: { Authorization: 'Bearer k' },\n });\n const data = (await r.json()) as { data: { total_credits: number; total_usage: number } };\n const rem = calcOpenRouterUsd(data.data.total_credits, data.data.total_usage);\n expect(rem).toBeCloseTo(2.0);\n expect(rem < LOW_BALANCE_USD).toBe(true);\n });\n\n it('OpenRouter: HTTP 429 → respuesta no ok', async () => {\n fetchMock.mockResolvedValueOnce(makeErrorResponse(429));\n const r = await fetch('https://openrouter.ai/api/v1/credits', {\n headers: { Authorization: 'Bearer k' },\n });\n expect(r.ok).toBe(false);\n expect(r.status).toBe(429);\n });\n\n it('anyLow=true cuando ambos proveedores tienen saldo bajo', () => {\n const dsRem = calcDeepSeekUsd([{ currency: 'USD', total_balance: '2.00' }]);\n const orRem = calcOpenRouterUsd(5, 4.5);\n const anyLow = dsRem < LOW_BALANCE_USD || orRem < LOW_BALANCE_USD;\n expect(anyLow).toBe(true);\n });\n\n it('anyLow=false cuando ambos tienen saldo suficiente', () => {\n const dsRem = calcDeepSeekUsd([{ currency: 'USD', total_balance: '20.00' }]);\n const orRem = calcOpenRouterUsd(50, 10);\n const anyLow = dsRem < LOW_BALANCE_USD || orRem < LOW_BALANCE_USD;\n expect(anyLow).toBe(false);\n });\n\n it('anyLow=true cuando solo OpenRouter está bajo', () => {\n const dsRem = calcDeepSeekUsd([{ currency: 'USD', total_balance: '20.00' }]);\n const orRem = calcOpenRouterUsd(5, 4.8); // 0.20 restante\n const anyLow = dsRem < LOW_BALANCE_USD || orRem < LOW_BALANCE_USD;\n expect(anyLow).toBe(true);\n });\n\n it('anyLow=true cuando solo DeepSeek está bajo', () => {\n const dsRem = calcDeepSeekUsd([{ currency: 'USD', total_balance: '0.50' }]);\n const orRem = calcOpenRouterUsd(100, 10);\n const anyLow = dsRem < LOW_BALANCE_USD || orRem < LOW_BALANCE_USD;\n expect(anyLow).toBe(true);\n });\n});\n\ndescribe('autenticación — verificación de token', () => {\n it('rechaza peticiones sin header Authorization', () => {\n const authHeader = '';\n expect(authHeader.startsWith('Bearer ')).toBe(false);\n });\n\n it('rechaza peticiones con esquema incorrecto (Basic)', () => {\n const authHeader = 'Basic abc123';\n expect(authHeader.startsWith('Bearer ')).toBe(false);\n });\n\n it('acepta peticiones con Bearer token', () => {\n const authHeader = 'Bearer eyJhbGciOiJSUzI1NiJ9.test';\n expect(authHeader.startsWith('Bearer ')).toBe(true);\n const token = authHeader.slice(7);\n expect(token).toBe('eyJhbGciOiJSUzI1NiJ9.test');\n });\n\n it('rechaza email no autorizado', () => {\n const VIEWERS = ['carlosgalera2roman@gmail.com'];\n expect(VIEWERS.includes('attacker@evil.com')).toBe(false);\n });\n\n it('acepta email del propietario (case-insensitive)', () => {\n const VIEWERS = ['carlosgalera2roman@gmail.com'];\n const email = 'CarlosGalera2Roman@Gmail.Com'.toLowerCase();\n expect(VIEWERS.includes(email)).toBe(true);\n });\n});\n"}}}}