Auditoría de parrilla + clock de 15 min — 2026-09-07

Auditoría con evidencia de sandbox-sep-6/parrilla/ + surtido/scripts/radio_parrilla.py, corrida real del cron tanda_radio.sh de 2026-09-06 19:30.

1. El error grave: contenido stale al aire

tanda_radio.sh:11-12 corre dos ensambladores en secuencia:

.venv/bin/python scripts/radio_parrilla.py      # genera b1-*, b2-* frescos + parrilla-7min.mp3
bash .../parrilla/arma_parrilla.sh              # SOBREESCRIBE parrilla-7min.mp3

arma_parrilla.sh hardcodea inputs que radio_parrilla.py nunca regenera:

archivomtimeestado
b1-mx/int/mx2/int2.mp319:33fresco
b1-mx3.mp314:34STALE — nadie lo genera
b2-tech1-c … cierre-id-c.mp314:35STALE — todo el bloque 2
b2-tech1/2/3.mp319:33fresco pero descartado

Verificación por duración (no por suposición):

parrilla-7min.mp3 real        = 329.74s
simulado radio_parrilla.py    = 311.66s   (delta 18.08)
simulado arma_parrilla.sh     = 329.78s   (delta 0.04)  ← GANADOR

Conclusión: la edición al aire es bloque 1 de las 19:33 + bloque 2 completo de las 14:35. Nueve llamadas a Google TTS de las 19:33 se tiraron a la basura. radio_push.sh subió los 34 archivos al Linode, stale incluidos.

Fix: una sola fuente de verdad. Borrar arma_parrilla.sh de tanda_radio.sh, o reescribirlo para que lea playlist.m3u (que sí genera radio_parrilla.py).

Corolario: playlist.m3u (orden de radio_parrilla.py) no coincide con parrilla-7min.mp3 (orden de arma_parrilla.sh). Quien consuma el .m3u oye otro programa. E INTEGRACION.md documenta un tercer orden (13 segmentos con b1-mx3/b2-tech4).

2. Errores editoriales audibles (confirmados parseando el preview real)

Fuente: reporte/2026-09-06-preview.md. Secciones: MUNDO 2, EEUU 1, BILATERAL/FRONTERA 1, MÉXICO 4, CIENCIA·TECH·MEDIOAMBIENTE 1, TECNOLOGÍA · CHINA Y EL MUNDO 5.

2.1 Fuga tech → internacional (substring match)

pick(secs,"MUNDO",...) hace if name in k. La sección "TECNOLOGÍA · CHINA Y EL MUNDO" contiene "MUNDO" → sus 5 items entran al bloque Internacional.

lo que se oyó en "Internacional":
  - Hebrón (MUNDO)             ✓
  - ultraderecha alemana       ✓
  - GPT-6 / Alibaba            ✗ TECH
  - SpaceX / empresa china     ✗ TECH
  - Zheng Qinwen US Open       ✗ DEPORTE

Fix: match por orden explícito + marcar secciones consumidas, nunca substring.

2.2 Duplicado por URL distinta de título

Zenwalk aparece 2× con el mismo URL (phoronix.com/news/Zenwalk-2026-Current), una en español y otra en inglés. pick() deduplica por t[0][:40] → no los atrapa. Se lee dos veces.

Fix: dedup por URL normalizada (quitar query/fragment/trailing slash), no por prefijo de título.

2.3 Eco título == “Qué pasó”

En los items de QbitAI / TechNode / Zhihu / Phoronix-EN el campo Qué pasó es una copia literal del título. bullet() hace f"{title}. {que}" → la misma frase dos veces seguidas:

“GPT-6 con Transformer de bucle caliente, Alibaba ya lo ha planeado. GPT-6 con Transformer de bucle caliente, Alibaba ya lo ha planeado, en qbitai.com.”

Fix: si similitud(title, que) alta → usar solo porque. Afectó a 4 de 14 bullets hoy.

2.4 Truncación silenciosa a 620 caracteres

cap(normalizar(text), 620) corta a mitad de frase y pega un ”.“. Medido en el log de la corrida:

bloquetexto crudoemitidopérdida
b1-mx759c610c3ª noticia de México nunca se oyó
b1-int2740c619ctruncado
b2-tech1686c616ctruncado

Fix: presupuesto por bloque — agregar bullets uno a uno mientras quepan; si sobran, abrir b1-mx3/b2-tech4 de verdad (generados, no stale). Nunca cortar a ciegas.

2.5 Bloques raquíticos / degenerados

Slices sin guarda: b1-mx2 = mx[3:] salió con 1 noticia (17s: “Más de México.” + una frase). b2-tech3 = tech[5:] → 1 noticia (18s). Si un día el preview trae ≤3 items de México, el segmento dice “Más de México.” y se calla. Fix: si el slice sale vacío, no emitir el segmento.

2.6 Inglés leído con voz es-US

"Better Models Aren't Enough: China's AI Race Turns to Cost, Products and Paying Users" entra al bloque tech y se sintetiza con es-US-Neural2-C. El propio preview advierte: “Titulares chinos traducidos por LLM local — revisar antes de enviar.” Ya existe traducciones.py + reporte/traducciones.json (22KB, regenerado 19:32) y no se usa en la cadena de radio. Fix: detectar idioma → traducir o descartar.

2.7 Deportes sin sección

NFL/Djokovic/béisbol cayó en EEUU → bloque Internacional. US Open/Zheng Qinwen cayó en TECNOLOGÍA · CHINA Y EL MUNDO → bloque Tech. Fix: sección DEPORTES propia, o filtro.

2.8 Atribución degenerada

dominio() sobre x.com/<handle>/status/... devuelve siempre x.com. Hoy 5 bullets dijeron ”, en x.com” — muletilla, y pierde la fuente real (Julio Astillero, Ríodoce, AJ English). Peor: zurl.co/FHXbC (acortador) se citó como fuente de un video de PeriodistasU. Fix: para x.com/twitter.com usar el handle; lista negra de acortadores → fallback a src.

2.9 Pulso = 9 segundos de puros números

b2-pulso lee “Muestra de 46 publicaciones: 27 neutras, 6 negativas y 13 positivas, balance 0.152”. Cero contenido, y pulso.json trae posts reales con texto (Jornada, bluesky). Además desincronizado: pulso.json dice muestra 46 (13/27/6) y el preview.md dice 61 posts (39% neg / 41% neu / 20% pos). Dos corridas distintas conviviendo.

2.10 Dos secciones del preview que se tiran enteras

parse_preview() solo captura bullets con - **título** — fuente. Estas dos secciones usan viñetas sin negrita → se parsean a 0 items:

  • En 60 segundos (6 bullets) = el bloque Titulares / Daily Recap gratis
  • PARA SEGUIR (4 bullets) = el bloque Tomorrow gratis

Es contenido ya escrito por el LLM que nadie lee. Fix: segundo regex para viñeta simple.

2.11 fallback-loop.mp3 es copia idéntica

cp parrilla-7min.mp3 fallback-loop.mp3. En Liquidsoap/AzuraCast, cuando cae la fuente loopeas el mismo audio bit a bit: el oyente escucha las mismas 14 noticias en ciclo infinito. Ese es exactamente el anti-patrón de la rotación (ver §4).

2.12 Menores

  • Cron one-shot 30 19 6 9 * no se autodesinstaló: crontab: Operation not permitted. Sigue vivo.
  • _concat aplica atempo=0.96 a todo menos al último archivo, incluida la cortinilla 8-bit.
  • playlist.m3u ES usa rutas absolutas; playlist-en.m3u usa relativas.

3. De la parrilla de 60 min propuesta: qué sirve y qué no

Aritmética de la oferta (medida, no estimada)

retrieval por ronda:   feed.json 173 + feed-tech.json 105  = 278 items crudos
destilado a preview:   14 bullets únicos
audio resultante:      5:29  (329.74s verificados)
banco sonoro total:    6:00  (3:00 son debates, resto IDs de 2-15s)

60 minutos de radio = 7,000–9,000 palabras ≈ 150–180 bullets. Hoy produces 14. Factor 11×. Una ronda de retrieval no da para una hora: da para cinco minutos y medio.

Sirve, y ya tienes la fuente de datos

bloque propuestofuente real que ya existeestado
Headlines / Daily Recapsección En 60 segundosse tira (§2.10)
Brief World / National / Techb1-int / b1-mx / b2-techexiste, mal ruteado (§2.1)
Focus (“why this matters”)campo Por qué importabullet() lo descarta; solo lo usa el debate
Signalspulso.json con posts realessolo lees conteos (§2.9)
Tomorrowsección PARA SEGUIRse tira (§2.10)
Midshow ID / Outrocierre-id, intro-8bit, tun-tun, stingersexiste
separar news de analysisque vs porque ya vienen separadosgratis

El campo Por qué importa es el hallazgo más caro: análisis ya escrito que la cadena tira.

No sirve hoy

  • The Big Question / Closing Commentary — requieren generación editorial (LLM), no retrieval. Es el paso a Groq/Ollama que aún no está cableado.
  • World→Business / Tech Radar — no hay fuentes de economía/mercados ni de releases separadas; “tech” es un solo bucket.
  • 60 min corridos — ver aritmética arriba.
  • Orpheus local — la cadena actual es Google Cloud TTS (project-323ffb8d-90a3-4bc3-971) con voces ya calibradas: es-US-News-D, es-US-Neural2-C, más Neural2-B / es-ES-Standard-E/F para el debate. Orpheus es inglés-first y tu estación es español (decidido en 2026-09-06-radio-en-optin.md: /radio español, EN opt-in en /radio/en). Es otro proyecto; no lo mezcles con arreglar la parrilla.
  • La identidad en inglés de la propuesta (World news. National perspective.) — el formato sirve, el idioma no.

4. La unidad no es la hora, es el clock de 15 minutos

Radio real no programa 60 minutos de una pieza: programa un hot clock de :00/:15/:30/:45 y lo repite con variación. Eso resuelve de raíz “el retrieval solo da para una ronda”.

Clock de 15:00 — construible hoy con datos que ya tienes

:00  ID + En 60 segundos (titulares)        1:00   ← §2.10, ya escrito
:01  NACIONAL brief (3)                     2:30   ← b1-mx
:04  NACIONAL focus (1, Por qué importa)    1:30   ← campo ya existe
:05  MUNDO brief (3)                        2:00   ← b1-int con routing arreglado
:07  MUNDO focus (1)                        1:00
:08  cortinilla 8-bit                       0:15   ← descanso-8bit.mp3
:08  TECH headlines (3)                     2:00   ← b2-tech1
:10  TECH focus (1)                         1:30
:12  PULSO / Signals (2-3 posts reales)     1:00   ← §2.9
:13  PARA SEGUIR / Tomorrow (3)             1:00   ← §2.10, ya escrito
:14  debate o recap                         1:00
:15  ID de cierre                           0:15   ← cierre-id

= 15:00 clavado, sin una sola fuente nueva.

Tres palancas para crecer la oferta, en orden de costo

  1. Rendir más la misma ronda (gratis): Por qué importa + En 60 segundos + PARA SEGUIR
    • pulso con posts reales = +3:00 → 8:30 por ronda.
  2. Destilar más bullets (barato): el preview recorta por cuotas de auditoría (México 4/9, EEUU 3/9, independientes 7/9, grandes 2/9). Subir a 30–40 bullets de los 278 crudos = ~18–20 min por ronda. Aquí ya alcanza un clock de 15 sin repetir nada.
  3. Más rondas (costo real): retrieval cada 3–4 h en vez de una tanda nocturna.

Rotación: cómo repetir sin que se note

El problema no es repetir, es repetir idéntico. fallback-loop.mp3 = cp es el anti-patrón.

  • estado.json con fingerprint (URL normalizada) + timestamp de cada item emitido.
  • Cada pasada del clock toma los N bullets más frescos no emitidos en las últimas X horas.
  • Si la ronda se agota antes del siguiente fetch, degradación en 3 niveles, nunca silencio:
    1. Otro ángulo — mismo item, bloque Focus en vez de Brief. que y porque ya vienen separados: dos lecturas legítimas de la misma noticia.
    2. Otra voz — las gemelas -c (News-D ↔ Neural2-C) ya existen por rotación anti-saturación.
    3. Banco evergreen — debates anteriores, stingers.
  • Caducidad 12–18 h. Un item muere y no vuelve aunque no haya nada más. Mejor cortinilla que noticia rancia.

5. Orden de fixes (scope mínimo que cambia lo que se oye hoy)

  1. tanda_radio.sh — eliminar la doble ensamblada. Una fuente de verdad. (crítico, 1 línea)
  2. pick() — secciones por orden explícito + consumidas, no substring. (crítico)
  3. dedup por URL normalizada. (crítico)
  4. bullet() — anti-eco title/que + usar porque en bloques Focus. (crítico)
  5. parse_preview() — segundo regex para viñeta simple → recupera En 60 segundos y PARA SEGUIR.
  6. cap() — presupuesto por bloque en vez de truncar.
  7. guarda de slice vacío + filtro de idioma + dominio() con handles de x.com.
  8. fallback-loop.mp3 — que no sea copia idéntica.

Los 1–4 solos ya eliminan: tech en el bloque internacional, el Zenwalk doble, el eco de los titulares chinos, la noticia de México perdida por truncamiento y el bloque 2 de hace cinco horas.