From 8d1ffa4f6d6be440834a5476307e9cebe7d01de2 Mon Sep 17 00:00:00 2001 From: ChemaVX Date: Mon, 27 Jul 2026 14:20:15 +0000 Subject: [PATCH] =?UTF-8?q?infografia=5Fbrief:=20guion=20verificado=20para?= =?UTF-8?q?=20la=20infograf=C3=ADa=20de=20un=20post?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las tres infografías falladas del 27-jul no fallaron al dibujar, fallaron al decidir qué era cierto. ChatGPT tenía el artículo anterior en el contexto de la conversación y coló la ficha de Vilas-Boas dentro de la infografía de Talavera, y los 84 expedientes del Ejército del Aire español dentro de la de Vilas-Boas. En una llegó a inventarse un dominio, bibliotecavirtual.defesa.gov.br, que no existe: una brasileñización del español, a un clic de publicarse. La respuesta no es pedirle al modelo que se porte bien, es no dejarle decidir. El guion se extrae del artículo (claude -p, coste cero) y se VERIFICA contra el propio artículo antes de que ChatGPT vea nada. A ChatGPT le llega "maqueta este texto", no "lee esto y decide qué es cierto". Decisiones que no son de adorno: - La verificación es regex sobre el artículo, sin modelo de por medio. Un modelo autoevaluándose es exactamente lo que ya falló tres veces. - Tres severidades, sacadas de cómo falló esto en la vida real: nombre propio fuera del artículo se cae, URL fuera del artículo se cae sin excepciones, y cifra que no aparece literal solo avisa. Lo tercero porque el artículo dice "mil novecientas páginas" donde el guion escribe "1.900", y tumbar eso automáticamente daría más falsos positivos que aciertos. Los nombres propios fueron el 100% de la contaminación observada. - Los títulos de panel NO se verifican: "DATOS DEL CASO" es una etiqueta editorial, no una afirmación sobre el caso, y no tiene por qué aparecer en el artículo. Verificarlos hacía caer todos los paneles y el informe se volvía inservible. - Una mayúscula en inicio de frase no cuenta como nombre propio, o cualquier viñeta que empiece por verbo ("Llegan…", "Comienza…") sale marcada como forastera. Las siglas (SBEDV, APRO, OVNI) sí se persiguen en cualquier posición: son marcador de contaminación. - Se compara plegando minúsculas y tildes. El modelo escribe "Neron" donde el artículo dice "Nerón" y eso no es contaminación; la de verdad son palabras que no están en el artículo de ninguna forma. - El idioma lo fija --site, no se adivina del texto: es un dato duro que no se equivoca, y el idioma del guion arrastra el de la imagen. Verificado con la avería real, no con un caso de laboratorio: al guion contaminado de Talavera se le caen Vilas-Boas, Martins, Fontes, Bühler, SBEDV, Creighton, São Francisco de Sales y el dominio inventado, y se eliminan enteros los dos paneles que se quedan sin viñetas, mientras los legítimos sobreviven sin un solo falso positivo. Sobre los dos artículos publicados hoy, extrayendo de cero: 0 descartes y 0 avisos en ambos, guion inglés íntegro en inglés y español íntegro en español. Solo lectura sobre Ghost: no toca el post, no publica, no genera imágenes. El wrapper ~/.local/bin/infografia queda fuera del repo, como seo-check. Co-Authored-By: Claude Opus 5 --- infografia_brief.py | 314 +++++++++++++++++++++++++++++++++++++++++++ infografia_prompt.md | 43 ++++++ 2 files changed, 357 insertions(+) create mode 100755 infografia_brief.py create mode 100644 infografia_prompt.md diff --git a/infografia_brief.py b/infografia_brief.py new file mode 100755 index 0000000..62d75fa --- /dev/null +++ b/infografia_brief.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Guion verificado para la infografía de un post (Tool G). + +Por qué existe: las tres infografías falladas del 2026-07-27 no fallaron al +dibujar, fallaron al DECIDIR qué era cierto. ChatGPT tenía el artículo anterior +en el contexto de la conversación y coló datos de un caso en la infografía de +otro (la ficha de Vilas-Boas dentro de Talavera, los 84 expedientes españoles +dentro de Vilas-Boas) y llegó a inventarse un dominio, bibliotecavirtual.defesa +.gov.br, que no existe. + +La respuesta no es pedirle al modelo que se porte bien, es no dejarle decidir: +aquí se extrae el guion del artículo y se VERIFICA contra el propio artículo +antes de que ChatGPT vea nada. Lo que no case, no llega a la imagen. + +Severidades, calcadas de cómo falló esto en la vida real: + · nombre propio que no está en el artículo → SE CAE (fue el 100% de la + contaminación observada: São Francisco de Sales, Fontes, Bühler, O Cruzeiro) + · URL o dominio que no está en el artículo → SE CAE, sin excepciones + · cifra que no está en el artículo → AVISA, pero se queda: el + artículo puede decir "mil novecientas páginas" y el guion "1.900", y + tumbar eso automáticamente daría más falsos positivos que aciertos + +Solo lectura sobre Ghost. No publica, no toca el post, no genera imágenes. +""" + +import argparse +import json +import os +import re +import string +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +WORK = os.path.expanduser("~/.cache/infografia") +TEMPLATE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "infografia_prompt.md") + +# Mismos dos sitios que el resto de ~/seo-tools. El idioma NO se adivina del +# texto: lo fija el sitio, que es un dato duro y no se equivoca nunca. +SITES = { + "en": {"cli": "ghst-en", "idioma": "inglés", + "fecha": "November 12, 1976", "decimal": "punto"}, + "es": {"cli": "ghst-es", "idioma": "español", + "fecha": "12 de noviembre de 1976", "decimal": "coma"}, +} + +EXTRACT_PROMPT = """Lee el artículo que va al final y devuelve SOLO un objeto JSON +(sin ```, sin texto alrededor) con el guion de una infografía. + +Reglas innegociables: +- Cada dato debe salir del artículo. No uses tu conocimiento del caso. +- Copia los nombres propios, cifras y fechas TAL CUAL aparecen en el artículo. +- No inventes horas, referencias de archivo ni URLs. +- Si un bloque no tiene material suficiente en el artículo, déjalo con menos + viñetas o devuélvelo vacío. Prefiero tres viñetas ciertas que seis dudosas. +- Escribe el guion en el mismo idioma que el artículo. + +Estructura exacta: +{{ + "titular": "caso y año, máx 60 car.", + "subtitulo": "gancho corto, máx 60 car.", + "entradilla": "3-4 líneas resumiendo el artículo", + "cronologia": [{{"marca": "hora o duración literal del artículo", "texto": "qué pasó"}}], + "paneles": [{{"titulo": "...", "vinetas": ["...", "..."]}}], + "franja": [{{"titulo": "...", "vinetas": ["...", "..."]}}] +}} + +"paneles" son la secuencia de los hechos (entre 4 y 8). +"franja" son los bloques de cierre: datos del caso, lugar, argumentos a favor, +argumentos escépticos, legado y lo que el artículo sostenga. + +ARTÍCULO: +{articulo} +""" + +# Palabras que van en mayúscula por posición, no por ser nombre propio. Solo se +# usan para no dar la lata: si una de éstas no está en el artículo, es que el +# artículo es muy corto, no que haya contaminación. +RUIDO = {"el", "la", "los", "las", "un", "una", "y", "de", "del", "en", "the", "a", "an"} + + +def sh(cmd, timeout=600): + return subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout) + + +def fetch(slug, cli): + """Baja el post por el wrapper ghst-*, igual que seo_finish.fetch.""" + fd, path = tempfile.mkstemp(suffix=".json", prefix="infografia_") + os.close(fd) + try: + sh(f"{cli} post list --limit 100 --formats html --json > {path}") + with open(path) as f: + data = json.load(f) + posts = data.get("posts", data) if isinstance(data, dict) else data + finally: + os.unlink(path) + for p in posts: + if p.get("slug") == slug: + return p + sys.exit(f"ERROR: no encuentro el post '{slug}' en {cli}") + + +def plano(html): + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html or "")).strip() + + +# ── verificación ────────────────────────────────────────────────────────────── +# Nada de esto usa un modelo: son regex sobre el texto del artículo. Un modelo +# comprobándose a sí mismo es justo lo que ya falló tres veces. + +RE_TOKEN = re.compile(r"[A-Za-zÁÉÍÓÚÑÜÀÂÃÇáéíóúñüàâãç][\wÁÉÍÓÚÑÜÀÂÃÇáéíóúñüàâãç'’\-]*") +RE_CIFRA = re.compile(r"\d[\d.,:]*") +RE_URL = re.compile(r"(?:https?://|www\.)\S+|\b[\w\-]+\.(?:com|es|org|net|gov|br|gob)\b\S*", re.I) +# Un token en mayúscula que va detrás de uno de estos signos lo está por +# posición, no por ser nombre propio. +RE_ARRANQUE = re.compile(r"[.:;!?¡¿—–\-(\[«\"]\s*$") + + +def pliega(s): + """Minúsculas y sin tildes. + + Comparar con tildes generaba falsos positivos tontos (el modelo escribe + «Neron» donde el artículo dice «Nerón») sin aportar nada: la contaminación + de verdad son palabras que NO están en el artículo de ninguna forma. + """ + tabla = str.maketrans("áéíóúüàâãçñÁÉÍÓÚÜÀÂÃÇÑ", "aeiouuaaacnAEIOUUAAACN") + return s.translate(tabla).lower() + + +def _en_articulo(token, articulo): + return pliega(token).strip(string.punctuation + "’'") in articulo + + +def revisa(texto, articulo): + """Devuelve (nombres_fuera, cifras_fuera, urls_fuera) de una cadena del guion. + + Solo se persiguen los tokens que parecen nombre propio de verdad: + · mayúscula inicial que NO esté al principio de la frase + · siglas en versalitas en cualquier posición (SBEDV, APRO, OVNI) + Sin lo primero, cualquier viñeta que empiece por un verbo («Llegan…», + «Comienza…») se marcaba como forastera y el informe se volvía inservible. + """ + urls = [u for u in RE_URL.findall(texto) if not _en_articulo(u, articulo)] + nombres = [] + for m in RE_TOKEN.finditer(texto): + t = m.group() + sigla = len(t) > 1 and t.isupper() + propio = t[:1].isupper() and not t.isupper() + if propio and RE_ARRANQUE.search(texto[:m.start()] or " "): + continue # va en mayúscula por ir detrás de punto + if propio and m.start() == 0: + continue # ídem, principio de la cadena + if not (sigla or propio) or t.lower() in RUIDO: + continue + if not _en_articulo(t, articulo): + nombres.append(t) + cifras = [c for c in RE_CIFRA.findall(texto) + if len(c.strip(".,:")) > 1 and not _en_articulo(c, articulo)] + return nombres, cifras, urls + + +def filtra(guion, articulo_low, informe): + """Poda el guion en sitio. Devuelve el guion limpio. + + Una viñeta con un nombre propio o una URL forastera se cae entera: no se + puede "arreglar" media viñeta sin inventar, y ése es justo el pecado que + esta herramienta existe para evitar. + """ + def cadena_ok(texto, donde): + nombres, cifras, urls = revisa(texto, articulo_low) + for c in cifras: + informe["avisos"].append(f"{donde}: cifra «{c}» no aparece literal en el artículo") + if urls: + informe["caidas"].append(f"{donde}: URL forastera {urls} → «{texto[:70]}»") + return False + if nombres: + informe["caidas"].append(f"{donde}: nombre(s) {nombres} fuera del artículo → «{texto[:70]}»") + return False + return True + + for campo in ("titular", "subtitulo", "entradilla"): + if guion.get(campo) and not cadena_ok(guion[campo], campo): + guion[campo] = "" + + # Marca y texto se revisan por separado: concatenarlos le quitaba a la + # primera palabra del texto su condición de inicio de frase, y cualquier + # entrada que empezara por un verbo salía marcada como forastera. + guion["cronologia"] = [ + h for h in guion.get("cronologia", []) + if cadena_ok(h.get("marca", ""), "cronología/marca") + and cadena_ok(h.get("texto", ""), "cronología") + ] + + for bloque in ("paneles", "franja"): + limpios = [] + for p in guion.get(bloque, []): + # Los títulos de panel NO se verifican: son etiquetas editoriales + # («DATOS DEL CASO», «ARGUMENTOS ESCÉPTICOS»), no afirmaciones sobre + # el caso, y no tienen por qué aparecer en el artículo. + titulo = p.get("titulo", "") + p["vinetas"] = [v for v in p.get("vinetas", []) + if cadena_ok(v, f"{bloque}/«{titulo[:24]}»")] + if p["vinetas"]: + limpios.append(p) + else: + informe["caidas"].append(f"{bloque}: panel «{titulo[:30]}» se queda sin viñetas y se elimina") + guion[bloque] = limpios + return guion + + +# ── guion a texto ───────────────────────────────────────────────────────────── + +def formatea(guion): + out = [f"TITULAR: {guion.get('titular','')}", + f"SUBTÍTULO: {guion.get('subtitulo','')}", + f"ENTRADILLA: {guion.get('entradilla','')}", ""] + if guion.get("cronologia"): + out.append("CRONOLOGÍA") + for h in guion["cronologia"]: + out.append(f" {h.get('marca','')} — {h.get('texto','')}") + out.append("") + for bloque, etiqueta in (("paneles", "PANELES (secuencia)"), ("franja", "FRANJA INFERIOR")): + if not guion.get(bloque): + continue + out.append(etiqueta) + for i, p in enumerate(guion[bloque], 1): + marca = f"{i:02d}. " if bloque == "paneles" else " · " + out.append(f"{marca}{p['titulo']}") + for v in p["vinetas"]: + out.append(f" - {v}") + out.append("") + return "\n".join(out).strip() + + +def main(): + ap = argparse.ArgumentParser(description="Guion verificado para la infografía de un post") + ap.add_argument("slug") + ap.add_argument("--site", choices=("en", "es"), required=True) + ap.add_argument("--out", help="guardar el prompt final aquí (además de imprimirlo)") + ap.add_argument("--claude-bin", default=os.environ.get("CLAUDE_BIN", "claude")) + ap.add_argument("--guion-json", help="saltarse el modelo y usar este guion ya extraído") + a = ap.parse_args() + + cfg = SITES[a.site] + post = fetch(a.slug, cfg["cli"]) + articulo = plano(post.get("html")) + if len(articulo.split()) < 200: + sys.exit(f"ERROR: el artículo tiene {len(articulo.split())} palabras; ¿seguro que es el slug bueno?") + + print(f"═══ [{a.site.upper()}] {a.slug}") + print(f" {post.get('status')} · {len(articulo.split())} palabras · idioma: {cfg['idioma']}") + + if a.guion_json: + with open(a.guion_json) as f: + guion = json.load(f) + else: + print(" extrayendo el guion con claude -p (coste cero, plan Pro)…") + fd, pfile = tempfile.mkstemp(suffix=".txt", prefix="infografia_prompt_") + os.close(fd) + with open(pfile, "w") as f: + f.write(EXTRACT_PROMPT.format(articulo=articulo)) + try: + r = sh(f'{a.claude_bin} -p < "{pfile}"') + finally: + os.unlink(pfile) + if r.returncode != 0: + sys.exit(f"ERROR: claude -p falló ({r.returncode}): {r.stderr[:300]}") + crudo = r.stdout.strip() + m = re.search(r"\{.*\}", crudo, re.S) + if not m: + sys.exit(f"ERROR: no encuentro JSON en la respuesta del modelo:\n{crudo[:400]}") + try: + guion = json.loads(m.group()) + except json.JSONDecodeError as e: + sys.exit(f"ERROR: JSON inválido del modelo ({e}):\n{m.group()[:400]}") + + os.makedirs(WORK, exist_ok=True) + with open(os.path.join(WORK, f"{a.site}-{a.slug[:40]}-crudo.json"), "w") as f: + json.dump(guion, f, ensure_ascii=False, indent=1) + + informe = {"caidas": [], "avisos": []} + antes = json.dumps(guion, ensure_ascii=False) + guion = filtra(guion, pliega(articulo), informe) + + print(f"\n─── verificación contra el artículo ───") + print(f" descartado: {len(informe['caidas'])} | avisos: {len(informe['avisos'])}") + for c in informe["caidas"]: + print(f" ✗ {c}") + for v in informe["avisos"]: + print(f" ⚠ {v}") + if not informe["caidas"] and not informe["avisos"]: + print(" ✓ todo el guion está anclado al artículo") + if len(json.dumps(guion, ensure_ascii=False)) < len(antes) * 0.5: + print("\n ⚠️ se ha caído más de la mitad del guion: revísalo antes de usarlo") + + with open(TEMPLATE) as f: + plantilla = string.Template(f.read()) + prompt = plantilla.safe_substitute( + idioma_nombre=cfg["idioma"], formato_fecha=cfg["fecha"], + formato_decimal=cfg["decimal"], guion=formatea(guion), + ) + + destino = a.out or os.path.join(WORK, f"{a.site}-{a.slug[:40]}-prompt.txt") + with open(destino, "w") as f: + f.write(prompt) + print(f"\n─── prompt listo para pegar en ChatGPT ───") + print(f" {destino}\n") + print(prompt) + + +if __name__ == "__main__": + main() diff --git a/infografia_prompt.md b/infografia_prompt.md new file mode 100644 index 0000000..fa92072 --- /dev/null +++ b/infografia_prompt.md @@ -0,0 +1,43 @@ +Eres diseñador de infografías editoriales. Vas a maquetar UNA infografía a +partir del guion que te doy más abajo. + +════════ REGLA 0 ════════ +El guion ya está cerrado y verificado contra el artículo original. Tu trabajo es +MAQUETARLO, no completarlo ni corregirlo. + +· No añadas ningún dato, nombre, fecha, cifra, institución ni URL que no esté + en el guion. Ni de tu conocimiento del caso, ni de mensajes anteriores de + esta conversación. +· No "mejores" ni "completes" un panel que te parezca corto. Si tiene tres + viñetas, lleva tres. +· No inventes horas intermedias en la cronología. Si el guion salta de 01:45 al + amanecer, la imagen salta igual. +· Prohibidas las URLs, los logotipos institucionales y las referencias de + archivo que no aparezcan literalmente en el guion. + +════════ IDIOMA ════════ +Todo el texto de la imagen va en $idioma_nombre, sin una sola palabra del otro +idioma: titular, subtítulos, títulos de panel, viñetas, notas y pies. +Formatos en $idioma_nombre: $formato_fecha, decimales con $formato_decimal. + +════════ ESTILO ════════ +· 1200 × 800 px, horizontal. +· Fondo oscuro, paneles rectangulares con borde tenue, tipografía de palo seco, + un color de acento coherente con el tema. Sobrio y documental: esto acompaña a + un artículo de investigación, no a un titular sensacionalista. +· Cabecera: titular en dos líneas, entradilla de 3-4 líneas e ilustración de la + escena central. +· Cuerpo: los paneles numerados en orden, la cronología en una columna lateral, + y la franja inferior con el resto de bloques. +· Si un panel no cabe con holgura, redúcelo de tamaño o quítalo entero — nunca + recortes el texto a mitad de frase. + +════════ COMPROBACIÓN FINAL ════════ +Antes de entregar, confirma por escrito: +1. ¿Todo el texto está en $idioma_nombre? +2. ¿Aparece algún dato que NO esté en el guion? +3. ¿Se contradice algún panel con otro de la misma imagen? +4. ¿Hay alguna URL, logo o referencia oficial que no venga en el guion? + +════════ GUION ════════ +$guion