Hub

2026-05-15

viernes · 15 de mayo de 2026

2026-05-15 (viernes)

Sesión mediodía — electro-ia: audio bidireccional en Telegram

Pidió Sergio: validar que el bot entienda audios y responda con audios. Dejar pendiente ANTHROPIC_API_KEY (sigue sin conseguirse, pero es ortogonal a audio).

Hice — Fase 1.5 cerrada en una sesión (commit f3e6f08 en laptop-ia:~/electroia/electro-ia):

  • STT (audio in) — reuso completo de projects-hub:

    • src/transcribe.js portado (transcribeBuffer adaptado a layout de electro-ia).
    • updateInboundAudio en db.js.
    • Reemplazado el stub kind !== 'text' en index.js: descarga vía telegram.getFileInfo + downloadFile, transcribe contra faster-whisper :18081 (mismo servicio que ya estaba corriendo, sin tocarlo), persiste audio_path + body y sigue por el flujo normal con el transcript como inputText.
  • TTS (audio out) — pieza nueva (projects-hub nunca lo hizo):

    • Piper TTS local, voz es_MX-claude-high (nombre coincidente con Anthropic, es voz mexicana del repo rhasspy/piper-voices). Instalado en ~/piper/ del usuario electroia.
    • src/tts.js: spawn piper + ffmpeg con pipes nativos (sin disco, sin deps npm). WAV → OGG/Opus 32 kbps.
    • sendVoice + sendVoiceAndLog en telegram.js con multipart/form-data manual (Bot API no acepta JSON para uploads).
    • Sanitización: quita footer técnico, code fences, URLs, emojis y marcadores markdown antes de pasarle el texto a piper, para no leer backticks/emojis en voz.
  • Trigger: mirror modality (audio in → audio out + texto) + prefix /voz para forzar voz desde texto. Texto sin prefix sigue respondiendo solo en texto (sin regresión).

  • Smoke interno: 92 chars → 295 KB WAV → 26 KB OGG en 512 ms; archivo válido Ogg/Opus mono 24 kHz. Servicio reiniciado vía SIGKILL (systemd Restart=on-failure lo regresa en 5 s). Log muestra tts enabled al primer uso.

Falta del lado de Sergio (E2E, < 5 min):

  1. Manda audio al bot desde tu Telegram → ver transcript en logs + respuesta texto + respuesta audio.
  2. /voz qué hora es → respuesta hablada aunque input fue texto.
  3. Texto plano sin prefix → respuesta solo en texto (regresión).

Heads-up: piper a veces pronuncia mal palabras técnicas (paths, hostnames). El bot manda texto Y voz cuando aplica, así nada se pierde.

Iteración E2E + deuda técnica descubierta

Primer intento desde el Telegram de Sergio: bot contestó "No pude transcribir tu audio". Tres bloqueos en cadena:

  1. Whisper no podía atravesar /home/electroia (mode 700) → moví el storage a /tmp/electro-ia/audio/ con ELECTROIA_AUDIO_DIR configurable. Commit e3a95a3.
  2. PrivateTmp=true del systemd unit de electro-ia → el /tmp del servicio es un namespace privado; whisper (que corre fuera) no ve los .ogg. Resuelto con drop-in /etc/systemd/system/electro-ia.service.d/override.conf con PrivateTmp=false. Sergio corrió el sudo.
  3. Archivos guardados con mode 0o600 → whisper (gchavira) tampoco podía leer pese a tener el path correcto. Cambié writeFile mode de 0o6000o644. Test directo contra :18081 confirmó: transcribió un audio anterior ("Aquí te doy un audio de prueba, ¿me escuchas bien?") en 0.74 s.

E2E real validado 2026-05-15 13:23: audio 6.5 s → transcript "hola qué tal me escuchas bien" en 0.66 s; TTS 164 chars → 44 KB OGG en 661 ms; Sergio recibió texto + voz.

Deuda técnica documentada en projects/electro-ia/README.md Fase 1.5: PrivateTmp desactivado, .ogg legibles por todos los users locales en /tmp. Salida limpia (Fase 2): whisper con user propio + systemd unit, o cambiar el endpoint a body binario para eliminar la coupling de filesystem.

Sergio preguntó si whisper debería correr como electroia. Le dije que no: solo mueve la asimetría (cuando reactive projects-hub, mismo problema invertido). Whisper es infraestructura compartida, debe tener user propio o aceptar buffer.


Sesión mediodía continuación — electro-ia: arranque Fase 2 (onboarding de usuarios)

Pidió Sergio: empezar Fase 2 de electro-ia. Le surfacé los 4 frentes posibles (tools de análisis, deuda audio, dashboard, agregar usuarios). Eligió agregar usuarios (jefe + ingenieros).

Hice — endpoints admin para onboarding (sin reiniciar la BD, sin SQL manual):

  • src/identity.js parametrizado por rol: nuevo registerUser({id, display_name, role, telegram_user_id?, language_pref?}) con validaciones (role ∈ admin|engineer|viewer, slug ^[a-z][a-z0-9_-]{0,30}$, display_name 1-80 chars). registerAdmin ahora es un thin wrapper sobre registerUser. Nuevo bindTelegramUserId({identity_id, telegram_user_id}) que detecta colisión con otra identidad y setea verified_at si no estaba.
  • src/index.js con dos endpoints loopback-only:
    • POST /api/admin/register-user — crear identidad (opcionalmente con tg_id en un solo paso).
    • POST /api/admin/bind-telegram — vincular tg_id a slug pre-creado.
    • Body JSON, max 8 KB, sin auth porque el bind a loopback + tener shell en la laptop ya implica más poder que estos endpoints.
  • Mensaje al usuario desconocido ahora también queda en log como warn unknown user requested onboarding con telegram_user_id y first_name — Sergio puede hacer tail -n 30 ~/electro-ia/run.log | grep "unknown user" para capturar IDs.

Smoke test de validaciones: empty body → invalid_id; "Bad Slug" → invalid_id; role "superuser" → invalid_role; tg_id "abc" → invalid_telegram_user_id. Happy path queda para el primer alta real.

Lo que necesito de Sergio para terminar Fase 2 paso 1:

  1. Decidir slugs + display_name + rol de cada usuario nuevo (sugerencia: jefe_apellido_mx admin; ing1_apellido_mx engineer; etc.).
  2. Confirmar si el jefe va como admin (acceso ssh_exec a oxidized + write_file) o engineer (sin esos).
  3. Pedirle a cada persona que mande un mensaje al bot → Sergio captura telegram_user_id y lanza el curl documentado en el README sección "Onboarding de usuarios".

Continuación: jefe vinculado + comportamiento del modelo

Sergio confirmó que dio de alta a su jefe Gustavo Chavira (gustavo_chavira_mx, rol admin, sin ingenieros adicionales por ahora). El jefe mandó un audio: transcripción OK, pero el modelo respondió disculpándose porque llamó read_chat_file (un stub) en vez de simplemente contestar.

Causa: llama 3.1 8B llamaba stubs especulativamente (incluso para audios, que ya vienen transcritos). Las tools-stub read_chat_file y search aparecían en el schema con descripción "Stub — pendiente"; el modelo no lee descriptions con cuidado.

Fix (commit 28e561d): removí read_chat_file y search del schema (src/tools/index.js) — si no está implementado, no se expone. También actualicé prompts/system.md con regla explícita: "el contenido de los audios del usuario ya viene transcrito como su último mensaje user; NO requiere ninguna tool para leerlo". Reglas equivalentes para documentos cuando se implementen.

Continuación: jefe quiso usar curl, agregué web_fetch

Siguiente mensaje del jefe: pidió hacer curl https://www.ipchicken.com. Bash lo rechazó (allowlist excluye curl/wget). Sergio preguntó si podía dar permiso.

Surfacé alternativas (challenge-assumptions): A) agregar curl al allowlist (abre SSRF, exfil), B) tool dedicado web_fetch, C) flujo de elevación de permisos. Recomendé B; Sergio aceptó.

Hice (commit 02bb4c9)src/tools/web_fetch.js:

  • GET HTTP/HTTPS solo. Sin POST/PUT/DELETE (escalamos cuando haya caso).
  • Resuelve DNS y bloquea todas las direcciones que caen en RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), link-local (169.254/16 — incluye metadata 169.254.169.254), multicast.
  • Follow redirects max 5, re-valida el host destino en cada salto (anti-DNS-rebinding básico).
  • Timeout 15 s, body truncado 64 KB default / 512 KB hard.
  • Decodifica UTF-8 si content-type es text/json/xml/etc.; binario se reporta sin volcar bytes.
  • User-Agent identificable: electro-ia/1.0 (+laptop-ia).
  • Permiso admin+engineer (igual que bash). Viewer no fetcha.
  • Decisión: bash sigue SIN curl/wget. La idea es que cuando llegue la ANTHROPIC_API_KEY y Antigravity empiece a explorar usos creativos del shell, web_fetch sea la única vía HTTP.

Smoke pasa: public URL → 200 OK; http://127.0.0.1 → forbidden_host; http://192.168.1.1 → forbidden_host; file:///etc/passwd → bad_scheme; http://169.254.169.254 → forbidden_host (metadata IP).

Continuación: análisis de archivos (PDFs)

Sergio: "sigamos avanzando mientras mi jefe prueba". Le propuse análisis de archivos (PDF/Excel) — atiende directamente el pedido del jefe del 2026-05-12 ("analizar archivos tipo Excel"). PDF primero porque es más simple (pdftotext ya instalado); Excel queda para sesión aparte.

Hice (commit 67ee606) — soporte completo de documentos:

  • src/uploads.js nuevo: persistUpload({pool, identity_id, source_channel, source_msg_id, file_name, mime_type, buffer}). Guarda en ~/electro-ia/shared/uploads/YYYY-MM/<ts>-<rand>-<safe> mode 0o600. Cap 20 MB (Telegram Bot API tope download). Persiste fila uploaded_file con prune_after = now()+90 days. No hay cross-user coupling porque solo electroia lee estos archivos (a diferencia de los audios con whisper).
  • src/index.js rama kind === 'document': descarga vía telegram.getFileInfo+downloadFile (helpers existentes del audio), persiste, actualiza message_log.body con descriptor estable [archivo subido] file_id=N nombre="X.pdf" mime=Y size=ZB, manda ack determinista con file_id visible. No pasa por el LLM.
  • src/agent.js buildHistory ahora incluye kind=document (antes los tiraba). El modelo ve el descriptor como mensaje user en el contexto.
  • src/tools/extract_pdf_text.js nuevo: input {file_id, max_chars?, first_page?, last_page?}. Lookup uploaded_file por id, valida mime application/pdf o extensión .pdf, ejecuta /usr/bin/pdftotext -layout -enc UTF-8, timeout 30 s, truncado 32 KB default / 128 KB hard. Permiso admin+engineer+viewer (read-only).
  • prompts/system.md describe el flujo completo: cuando ve [archivo subido] file_id=N, debe llamar extract_pdf_text({file_id: N}) para leer el contenido. Regla anti-alucinación: "NO inventes contenido del PDF; léelo con la tool".
  • policy/permissions.yaml agrega web_fetch y extract_pdf_text con sus roles.

Smoke E2E in-process pasa: persistUpload + extract_pdf_text sobre PDF real (/usr/share/doc/shared-mime-info/shared-mime-info-spec.pdf); rango de páginas funciona; not_found path OK. Fila de prueba limpiada de la DB.

Lo que necesito de Sergio para terminar Fase 2:

  • Smoke E2E con el jefe: que mande un PDF al bot y le pregunte algo del contenido. Si es PDF escaneado sin OCR, no va a sacar texto (limitación de pdftotext) — OCR vendría aparte.
  • analyze_excel: cuando quiera atacarlo, ataco. Hoy queda pendiente intencionalmente.

Sesión tarde — deportescampeon: bug de folios duplicados

Pidió Sergio: atacar la duplicidad de venta con MISMO folio reportada por el cliente el 2026-05-14 (folio 371 en la foto). Implementar + tests + commit (sin deploy). Acceso autorizado: SSH deportescampeon (root, /opt/deportescampeon, BD deportescampeon).

Diagnóstico (read-only en código + BD prod):

  • SalesController::store calculaba fiscal_folio = MAX+1 por sucursal sin lock ni UNIQUE. La columna sólo tenía KEY (branch_id, fiscal_folio), no UNIQUE.
  • Doble click → 2 POSTs simultáneos → ambos leen el mismo MAX → INSERT con folio idéntico.
  • Por qué antes el doble click producía folios distintos: antes el "folio" era el id auto-increment de Laravel (atómico). La migración del 29-dic-2025 introdujo fiscal_folio calculado en código → cambio de comportamiento.
  • Doble click facilitado por Sales/Create.vue:266 — botón submit sin :disabled="form.processing".
  • Alcance histórico en BD prod: 1 solo caso en 4.5 meses (folio 371 sucursal 6, 2026-01-20 19:23:04). Ya estaba limpio: id=106270 soft-deleted desde 19:38:12 del mismo día. La captura del cliente la sacó la cajera a las 19:33 (timestamp Windows en la barra), nos la reenvió ahora porque al revisar histórico se dio cuenta que los folios eran idénticos.

Hice (2 commits a master, sin push, sin deploy):

  • 7aefde6 infra L11 — la suite de tests estaba rota desde la upgrade a Laravel 11. Mínimo necesario para correr tests: TestCase.php sin CreatesApplication (trait removido), phpunit.xml con sqlite :memory:, UserFactory sin email_verified_at (columna inexistente), 2 migraciones de seed de variables con DB::table (el modelo usa LogsActivity y activity_log se crea después).
  • b4ab0f2 fix — migración que purga el row 106270 (con sale_products + payments) + reemplaza KEY por UNIQUE; SalesController::store con retry loop catchando UniqueConstraintViolationException (tope 5), el MAX ahora cuenta soft-deleted para no reutilizar folios emitidos; Sales/Create.vue con :disabled="form.processing" y label "Procesando…"; 3 tests nuevos (sequential per branch / UNIQUE bloquea duplicado / store reintenta y termina).

Tests: mis 3 nuevos pasan. SalesTest 5/8 verdes; los 3 que fallan son pre-existentes (mismatch payload id vs product_id en tests viejos + Inertia view), no relacionados al fix.

Falta del lado de Sergio:

  1. Revisar diff + git push desde local.
  2. En el server: pull + php artisan migrate + recompilar assets si aplica.
  3. Responder al cliente con el resumen (sí era bug real distinto al doble-click viejo; solo pasó 1 vez; ya estaba limpio en BD; fix aplicado).

Deploy y trampa de la migración (tarde-noche)

Sergio hizo push + pull + migrate. La migración falló con Duplicate entry '6-2561': durante las horas en que estuve escribiendo el fix, ocurrió otro doble click en prod (sucursal 6, folio 2561, 12:25:30 UTC = 06:25 local) que mi scan inicial no vio.

Peor: el primer migrate ya había dropeado el index sales_branch_id_fiscal_folio_index (MySQL auto-commits DDL) antes de fallar en el ADD UNIQUE. El segundo migrate quedó colgado porque mi migración usaba self-join s1 ⋈ s2 ON (branch_id, fiscal_folio) que sin el index es O(N²) sobre 110k filas.

Resolución:

  1. Soft-delete vía Eloquent del id=110693 (sucursal 6, folio 2561, $75 — el id=110692 quedó como la venta buena). Esto restituyó inventario del product 7124 (de -8 a -7) vía el listener static::deleted del modelo Sale.
  2. Reescribí la migración: GROUP BY (branch_id, fiscal_folio) HAVING COUNT > 1 (O(N) con hash) en lugar del self-join; dropIndex en try/catch para self-heal de runs previos a medias; falla rápida y clara si encuentra dos rows ACTIVOS con mismo par (en vez de adivinar cuál conservar). Commit c9311d5.
  3. Push + pull + migrate → 792ms, success. Batch 13.

Estado final en prod verificado:

  • UNIQUE sales_branch_id_fiscal_folio_unique (branch_id, fiscal_folio) activo.
  • Cero pares duplicados.
  • id=106270 hard-deleted (caso histórico de enero).
  • id=110693 hard-deleted (caso de hoy 06:25).

Aprendizaje (para próximos fixes de race condition que requieran UNIQUE + cleanup):

  • MySQL auto-commits DDL → migraciones de "drop index + add unique" no son atómicas. Si el ADD falla, el DROP queda → estado inconsistente.
  • Self-join sin index = O(N²). En tablas de 100k+ usar GROUP BY o crear el index temporal primero.
  • Más casos del bug pueden aparecer entre el scan inicial y el deploy si el bug sigue activo. Validar pares duplicados justo antes de migrar.

Sesión tarde — jm-contabilidad: borrar corte de cobrador folio 707

Pidió Sergio: la admin de Joyerías Meza pidió por WhatsApp borrar el corte de cobrador folio 707 del 13-mayo. No puede hacerlo ella sola por UI.

Diagnóstico (read-only en código + tinker prod jmeza@joyeriameza.com:~/gastos.joyeriameza.com):

  • "Folio" en la UI = cortes_cobradores.id (ver show.blade.php:21). 707 = id 707.
  • Corte 707: 2026-05-13 09:08, cobrador "Jorge de la Rosa" (id 3), creado por usuario 4 (cajera), comisiones $310. Gasto asociado id 15801 ($310 Comisiones). Sin movimiento_caja. 6 filas en pivot cobranzas_cortes_cobradores.
  • Por qué la admin no puede borrarlo (policy CorteCobradorPolicy::eliminar): doble bloqueo — no lo creó ella, y existe el corte 708 posterior (09:20, mismo cobrador, mismo día) que la deja sin la condición "último corte".
  • Lectura del incidente: la cajera creó 707, se dio cuenta que asignó la cobranza_cobrador #8 en vez de la #7, creó 708 con la corrección. Pero la cobranza_cobrador #10 quedó duplicada en ambos cortes → $310 contado dos veces como gasto. Borrar 707 deja la #8 disponible para futuro corte y elimina el gasto duplicado.

Gotchas del borrado:

  • El modelo CorteCobrador::boot() tiene event deleted que cascadea a movimiento_caja y gasto, pero NO limpia el pivot cobranzas_cortes_cobradores (sin FK CASCADE en la migración). Para una limpieza completa hay que borrar las filas del pivot manualmente antes/después del delete().
  • Confirmado con Sergio: limpiar pivot también.

Hice (tinker prod, transacción atómica):

  1. Snapshot JSON del corte + pivot + gasto en jmeza:/tmp/corte_707_backup_20260515_152134.json por si.
  2. DELETE FROM cobranzas_cortes_cobradores WHERE corte_cobrador_id=707 → 6 filas.
  3. CorteCobrador::find(707)->delete() → cascadea y borra gasto 15801.
  4. Verificado post: corte 707 no existe, gasto 15801 no existe, pivot remaining=0, mc_remaining=0.
  5. Cortes recientes del cobrador 3 ahora arrancan en 708 (correcto).

Falta del lado de Sergio: confirmarle a la admin por WhatsApp que ya está hecho.


Sesión tarde-noche — medicinas GHA fix + aprende-ingles deploy completo

Pidió Sergio: (1) renombrar SSH alias paginas-web → val-soft ("mis proyectos personales viven en val-soft, aunque infra es de Electrosystems"), (2) arreglar el GHA de medicinas que estaba fallando desde 2026-04-27, (3) replicar el mismo setup en aprende-ingles, (4) ayudar con diagnóstico del upload de fotos en medicinas.

Hice:

  • SSH alias renombrado en ~/.ssh/config local de Sergio (paginas-web → val-soft, hostname queda igual).

  • Medicinas — GHA fixed (commit e232a2b):

    • Diagnóstico: el workflow viejo usaba appleboy/ssh-action con proxy_host apuntando a una IP privada de la LAN de Electrosystems — el runner público de GitHub no llegaba. Lleva fallando 18 días, 8 runs seguidos.
    • Fix: self-hosted runner en val-soft (~/actions-runner-medicinas), workflow corre git fetch + reset --hard directamente en /var/www/medicinas (sin SSH, sin proxy). trap 'artisan up' EXIT para evitar dejar el sitio en maintenance si algo falla. Concurrency group.
    • Primer deploy verde en 33s. PlanCard.vue de medicinas finalmente llegó a prod.
  • Medicinas — upload de fotos: PHP-FPM default Ubuntu (upload_max_filesize=2M, post_max_size=8M). Sergio aplicó override en /etc/php/8.3/fpm/conf.d/99-uploads.ini (50M/60M/256M) + /etc/nginx/conf.d/uploads.conf (50M). Límite efectivo: 10M (que aún manda el site block de nginx). Fotos de celular pasan ahora.

  • Aprende-ingles — primer deploy E2E:

    • Repo nuevo sevaor/aprende-ingles (privado) + push del código local (commit dded769 login mágico ya estaba listo desde sesión anterior).
    • Deploy key dedicada (id_ed25519_aprende_ingles en val-soft) registrada en el repo. GitHub bloquea reusar la misma deploy key entre repos, así que cada proyecto necesita la suya.
    • Runner val-soft-aprende-ingles registrado bajo systemd.
    • Postgres: DB aprende_ingles + user aprende_ingles_user.
    • /var/www/aprende-ingles con clone (vía GIT_SSH_COMMAND + core.sshCommand persistido en el local del server).
    • .env de prod con allowlist real de Sergio + esposa + hijo, MAIL reusado del .env de medicinas.
    • nginx site copiado del template medicinas (TLS termina en reverse-proxy).
    • reverse-proxy: Sergio configuró DNS A ingles.val-soft.com → 201.218.172.3 (Porkbun) + certbot + server block.
    • Primer GHA verde en 29s (commit 0db0145 workflow self-hosted).

Falta para que el hijo entre: validar que el correo del magic link llega a Gmail desde prod (POST /login con levaflo15@gmail.com desde browser real). Todo lo demás está listo.

Gotchas encontrados, anotados también en projects/aprende-ingles.md:

  1. .env.example Laravel 13 tiene las DB_* comentadas. Cualquier sed -i 's|^DB_HOST=.*|...|' falla silenciosamente. Usar cat >> para configuración inicial.
  2. GitHub deploy keys son únicas por repo. Generar key dedicada por proyecto + core.sshCommand per-clone.
  3. svc.sh install del runner debe correrse antes de start; si saltas el install, start falla con "Unit not found".
  4. PHP-FPM corre como www-data en Ubuntu por default. Si storage/ queda con grupo electrosystems 775, www-data no escribe y todo da 500. Fix: chown -R electrosystems:www-data en storage y bootstrap/cache.
  5. Para diagnosticar TLS de un subdominio, openssl s_client -servername X -connect Y:443 y revisar subjectAltName. Si responde el cert default del proxy (ej. amadeus.electrosystemsnet.com), falta server block en reverse-proxy.

Pendiente offered: sudoers fragment limitado para que yo (o futuros agentes) pueda hacer estos deploys sin pedir copy/paste a Sergio. No aplicado hoy — preferí que Sergio mismo aplicara el bootstrap one-shot, no aporta valor recurrente todavía.

Sesión noche — electro-ia Fase 2.5: modelo local grande

El jefe (Gustavo Chavira) pidió a Sergio cambiar el modelo local por algo más grande, "aunque se tarde", para reducir alucinaciones que vio en el smoke vespertino. Decisión: subir de llama3.1:8b a qwen2.5:32b-instruct. Qwen2.5 tiene tool-calling más sólido y 32B es el sweet spot en el RTX 4060 de laptop-ia (8 GB VRAM) — cabe con offload parcial a RAM (~3.5 tok/s, vs 1-2 tok/s que daría un 70B).

Lo que se hizo:

  • Inventario de laptop-ia por SSH: 8 GB VRAM (RTX 4060 Laptop), 62 GB RAM, 1.3 TB libres. Models ya pulled: llama3.1:8b, qwen2.5:7b/14b, gemma4:26b, bge-m3. Pull de qwen2.5:32b-instruct (19 GB) en background a 33 MB/s, 9 min.
  • Patch en staging /tmp/electro-ia-patch/src/backends/ollama.js: default model qwen2.5:14b-instructqwen2.5:32b-instruct. Timeout 180 s → 600 s (parametrizado por OLLAMA_TIMEOUT_MS). Nuevo num_ctx: 8192 (parametrizado por OLLAMA_NUM_CTX) porque el default ollama 2048 queda corto con system prompt + history + tools.
  • NOPASSWD para sergio@laptop-ia agregado por Sergio mid-sesión: me permitió hacer scp + sudo install -o electroia + sudo systemctl restart electro-ia directamente, sin copia-pega manual.
  • Trampa diagnosticada: el cambio de default en código no surtía efecto porque .env tenía OLLAMA_CHAT_MODEL=llama3.1:8b hardcoded — la env var pisa el default. Edit en .env con backup .bak.<timestamp>, restart, confirmado por el log: model: qwen2.5:32b-instruct.
  • Pre-carga: ~6.4 s de load + respuesta corta en 11 s. VRAM: 6.9 GB de 8 GB (mejor de lo esperado — casi todo cabe en GPU, offload mínimo).

Smoke parcial (Sergio):

  • Audio "ipchicken.com IP" → tool_calls: 0 en 74 s. Sospecha de regresión (modelo no llamó web_fetch). Pendiente revisar qué respondió.
  • Audio "readme.md" → pidió ruta (esperado).
  • Audio dictado "es /home/electroia/electro-ia/readme.md" → llamó read_file, respuesta de 503 chars en turn final. ✓
  • PDF "Comprobante_domicilio_Electro.pdf" subido (file_id=3) + "extrae el texto" → llamó extract_pdf_text en 10 s + turn final. ✓

Latencia observada: 40-75 s por turno, lo que el jefe aceptó explícitamente.

Deuda nueva: decidir si el system prompt necesita un ejemplo más explícito para web_fetch ante preguntas de tipo "cuál es mi IP / dame el contenido de X URL". Esperando feedback de Sergio sobre el body de la respuesta de ipchicken para distinguir alucinación de prompt insuficiente.

Operacional: durante el smoke, el bot tuvo 2-3 telegram audio download failed (fetch failed en api.telegram.org). Conectividad sana al revisar (0% packet loss, ~200ms ping). Blip transitorio — no se reproduce.

Backups conservados en laptop-ia:/home/electroia/electro-ia/{src/backends/ollama.js,.env}.bak.<timestamp> por si hay que revertir.


Sesión tarde-noche — monitoring-homologation: Fase 1A casi cerrada en una sola sesión

Pidió Sergio: avanzar con monitoring-homologation (estaba paused desde la reconciliación del 2026-05-13).

Hice (alto nivel — detalle técnico en la bitácora del proyecto, sección 2026-05-15):

  1. Cleanup masivo en dispositivos (139 soft-deletes en bloques): 113 duplicados auto-seguros + 10 Tier A ya en UISP + 3 manuales (RT2→Cerro Pinturas x2, Hermosillo Netonix→WiTek) + 13 Tier B rezago (Bocoyna/Creel/San Juanito/etc.) + 2 Loma Linda (sitio inexistente). 279 → 138 vivos. SQL plan validado read-only contra dispositivo_enlace (cero low-ids tenían enlace, soft-delete sin riesgo).
  2. Trampa operacional descubierta: scripts SQL con START TRANSACTION; UPDATE; -- COMMIT; (comentado) corridos vía mysql ... < file.sql hacen rollback automático al cerrar sesión. Re-corrida con COMMIT explícito resolvió.
  3. Schema migration deployada (commits e2c19e4 + 0ddec4f en es-antenas-new): 3 columnas en dispositivos (origen enum, uisp_id varchar(36) unique, administrado boolean) + cast en modelo Eloquent. Tropezón: daily log de Laravel pertenece a www-data 0644 y user electrosystems no escribe — migration se quedó a medias. Fix permanente: 'permission' => 0664 en config/logging.php. Memoria nueva en hub para los otros proyectos Laravel.
  4. Aclaración importante mid-sesión: los "sitios" en es-antenas-new son regiones (Chihuahua, Caborca, etc.), no sitios físicos. UISP usa sitios físicos con prefijo [COD] para la región ([CHI] Capellina). CSV inicial de sitios descartado. Implementé Estrategia C: reporte de match-candidates con validación previa.
  5. Audit de UISP via token API: 108 sitios, 9 prefijos [COD] (CHI 45, PAR 18, ELP 17, CAB 10, JRZ 9, NOG 3, NAM 3, COL 1, VIL 1). Mapeo región→prefix confirmado.
  6. Reporte de match-candidates (match-candidates.py): clasificó 138 vivos en 98 ya en UISP, 2 sugerencia alta, 38 sin candidato.
  7. Decisiones de Sergio sobre los 38 sin candidato: Hércules + Torreón completas son del partner Mario → administrado=false en bloque (21 devices). Veracruz no se monitorea más → soft-delete del device + del enlace huérfano (sitio ya muerto desde abril). Resto (Barretal/Colonia del Valle/Jacala/Nexpa/Tepehuanes) son suyos → crear sitios físicos en UISP.
  8. CSV de sitios físicos generado y aceptado por UISP: uisp-sites-fisicos-2026-05-15.csv con 12 sitios nuevos. Trampa: UISP requiere lat/long obligatorios; placeholder cerca de Cd. Juárez con offsets micro y prefijo [PLACEHOLDER GPS] en note para filtrar y actualizar después con coords reales.

Estado final al pausar:

  • 137 dispositivos vivos = 116 administrado=true (98 ya en UISP + 18 a importar) + 21 administrado=false (partner Mario).
  • 12 sitios físicos creados en UISP con coords placeholder pendientes de actualizar.
  • Bloqueante de Fase 1A cerrado. Próximo al retomar: devices CSV generator + sync UISP→es-antenas-new.

Memoria nueva: reference_laravel_daily_log_perms.md — gotcha del daily log con fix permission=>0664. Aplica a todos los proyectos Laravel del hub (medicinas, holbox, jm-checador, deportescampeon, aprende-ingles).

Sesión noche tardía — electro-ia: tool analyze_excel deployado, alucinación del modelo bloqueando smoke

Sergio quería terminar el día con soporte de Excel. El tool analyze_excel(file_id, sheet?, max_rows?, max_chars?) quedó deployado y funciona perfecto en llamada directa, pero al integrarlo con qwen2.5:32b vía el agente, el modelo alucina al 100% — inventa hojas "Hoja1/Datos" con datos genéricos de "pagos por consumo" cuando el archivo real es un Anexo de validación con 1 sola hoja "ACTA DE VALIDACION".

Lo que se hizo:

  • Tool analyze_excel con SheetJS, soporta XLSX/XLS/CSV/ODS/TSV. Render compacto que filtra cols/filas vacías. Output con "HECHOS REALES" explícitos al inicio + important_note.
  • Supply chain: xlsx@0.18.5 en npm registry tiene 2 CVEs sin fix. Instalado xlsx@0.20.3 desde CDN de SheetJS — npm audit reporta 0 vulnerabilidades.
  • Ack determinista del upload handler actualizado: detecta xlsx/csv/ods por mime+ext, sugiere preguntas concretas en vez de mentir "solo PDFs".
  • 4 mitigaciones contra la alucinación: (1) "HECHOS REALES" + important_note en output, (2) render compacto removiendo cols vacías (28→14), (3) regla inviolable nueva en prompt prohibiendo inventar nombres "típicos", (4) agent.js::buildHistory ahora strippea el footer técnico de los assistant messages para evitar que modelos chicos lo imiten como texto.
  • Comando /reset: inserta marcador body='__RESET__' en message_log; fetchRecentMessages lo respeta como cutoff. Audit log intacto. Primera versión tuvo bug del check_constraint de kind ('reset_marker' no permitido), fixeado a 'text'.

Hallazgo clave: el footer técnico 🔧 tool(args) → ... que aparece en respuestas previas en el history era leído por el modelo como ejemplo a imitar. Resultado: el modelo escribía el footer como texto en respuestas nuevas SIN llamar la tool. Stripping del footer en buildHistory destrabó esto, pero el problema mayor (modelo repitiendo respuestas alucinadas previas porque ya están en su history como assistant messages) requirió el comando /reset.

Pendiente para próxima sesión: mandar /reset (debe responder "🧹 Listo, contexto reseteado"), subir Excel, preguntar "qué hojas tiene". Si el log muestra tool_calls: 1 y la respuesta cita "ACTA DE VALIDACION", problema resuelto. Si sigue tool_calls: 0 con history limpio, qwen2.5:32b no aguanta tool-use con descriptor de archivo y toca: (a) probar qwen2.5:14b-instruct (los chicos a veces son mejores con tools), (b) reformatear el descriptor [archivo subido] file_id=N que va al history, o (c) esperar ANTHROPIC_API_KEY.

Aprendizaje meta: el caso ipchicken.com en la sesión tarde fue señal temprana de exactamente este problema (modelo respondió con tool_calls: 0 la primera vez también) — Sergio dijo "sí respondió bien" porque el modelo recuperó en una segunda iteración, pero la patología ya estaba ahí. Con archivos donde el modelo "sabe" qué tipo de datos esperar (un Excel), la alucinación se vuelve catastrófica. Documentado para referencia.