{"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((_, 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((_, 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\n\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\n\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"}}}}