La infografía de Socorro salió con «Lt. Colonel Hector Quintanilla» cuando el artículo dice «Major» las cinco veces que lo nombra. El rango NO lo inventó el modelo que maqueta: venía en el guion, y el verificador lo dejó pasar por dos motivos independientes. 1. RE_ARRANQUE veía el punto de «Lt.» y daba por hecho que «Colonel» iba en mayúscula por empezar frase, así que la eximía de comprobar. La única palabra capaz de cazar el error era justo la que se saltaba. Ahora una lista de abreviaturas conocidas anula esa exención. 2. _en_articulo comparaba por SUBCADENA: «Lt» daba positivo por estar dentro de built, adults y consultant. Pasa a exigir frontera de palabra por la izquierda — se conserva la laxitud por la derecha para que «Zamora's» del artículo siga casando con «Zamora» del guion. Y se añade la comprobación que faltaba, cargos_fuera(): los cargos y rangos se verifican en FRASE ENTERA. El fallo real no fue una palabra inventada sino una COMBINACIÓN falsa de dos palabras que sí estaban por separado, y eso un verificador token a token no puede verlo nunca. Medido antes de dejarlo puesto, sobre los DOS guiones reales de hoy (156 líneas): caza la avería, deja pasar la versión corregida, y cero falsos positivos de cargo. Por el camino de producción, filtra() tira solo las dos frases que llevan el rango malo y no toca el resto. Once tests. Incluye los dos lados: que pode lo que debe y que NO pode lo que no debe, porque un verificador que se pasa deja el guion cojo y obliga a rehacerlo a mano. También: hreflang-parejas.md con los slugs nuevos de los dos remates de hoy (socorro-1964-zamora-ufo-landing y cash-landrum-1980-demanda-ovni-tribunal-federal), corregidos por el propio agente del remate al leer la nota que lo pedía. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
473 lines
20 KiB
Python
Executable File
473 lines
20 KiB
Python
Executable File
#!/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", "host": "theexclusionzone.com", "idioma": "inglés",
|
||
"fecha": "November 12, 1976", "decimal": "punto"},
|
||
"es": {"cli": "ghst-es", "host": "zonadeexclusion.com", "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 _post_list(cli, extra="", timeout=300):
|
||
"""Vuelca `ghst-* post list` a fichero y lo parsea.
|
||
|
||
A fichero y no a PIPE, copiando el patrón de seo_watch/remate_watch: un PIPE
|
||
se trunca a 64 KB en silencio, y con --formats html los cuerpos son largos.
|
||
"""
|
||
fd, path = tempfile.mkstemp(suffix=".json", prefix="infografia_")
|
||
os.close(fd)
|
||
try:
|
||
r = sh(f"{cli} post list --limit all {extra} --json > {path}", timeout=timeout)
|
||
if r.returncode != 0:
|
||
raise RuntimeError(f"{cli} post list falló: {r.stderr.strip()[:200]}")
|
||
with open(path) as f:
|
||
data = json.load(f)
|
||
return data.get("posts", data) if isinstance(data, dict) else data
|
||
finally:
|
||
os.unlink(path)
|
||
|
||
|
||
def fetch(slug, cli):
|
||
"""Baja el post por el wrapper ghst-*, igual que seo_finish.fetch."""
|
||
for p in _post_list(cli, "--formats html"):
|
||
if p.get("slug") == slug:
|
||
return p
|
||
return None
|
||
|
||
|
||
def candidatos(sites=("es", "en")):
|
||
"""Borradores de los dos sitios, del más recién tocado al más viejo.
|
||
|
||
Solo borradores: un post publicado ya tiene su imagen, y pedir el guion de
|
||
algo publicado es la excepción (para eso está pasar el slug a mano).
|
||
"""
|
||
out = []
|
||
for s in sites:
|
||
try:
|
||
posts = _post_list(SITES[s]["cli"],
|
||
'--filter "status:draft" --fields id,slug,title,status,feature_image,updated_at')
|
||
except Exception as e:
|
||
print(f" (aviso: no pude listar {s}: {e})")
|
||
continue
|
||
for p in posts:
|
||
if p.get("status") == "draft":
|
||
p["_site"] = s
|
||
out.append(p)
|
||
return sorted(out, key=lambda p: p.get("updated_at") or "", reverse=True)
|
||
|
||
|
||
def de_url(texto):
|
||
"""Saca (site, slug) de una URL pegada del navegador. None si no lo es."""
|
||
m = re.match(r"https?://(?:www\.)?([\w.-]+)/([\w-]+)/?", texto.strip())
|
||
if not m:
|
||
return None
|
||
host, slug = m.group(1), m.group(2)
|
||
for s, cfg in SITES.items():
|
||
if cfg["host"] in host:
|
||
return s, slug
|
||
return None
|
||
|
||
|
||
def elige(arg_slug, arg_site):
|
||
"""Resuelve qué post hay que procesar. Devuelve (site, slug).
|
||
|
||
Tres formas de pedirlo, de más cómoda a más explícita:
|
||
· sin argumentos → el borrador más recién tocado de los dos sitios
|
||
· una URL pegada → el sitio sale del dominio
|
||
· slug [--site] → si no se da el sitio, se busca en los dos
|
||
"""
|
||
if arg_slug:
|
||
url = de_url(arg_slug)
|
||
if url:
|
||
return url
|
||
if arg_site:
|
||
return arg_site, arg_slug
|
||
# Slug suelto: mirar en los dos sitios. Los slugs no se repiten entre
|
||
# blogs, así que si aparece en uno solo no hay ambigüedad posible.
|
||
hallados = [s for s in SITES if fetch(arg_slug, SITES[s]["cli"])]
|
||
if len(hallados) == 1:
|
||
return hallados[0], arg_slug
|
||
if not hallados:
|
||
sys.exit(f"ERROR: el slug '{arg_slug}' no está ni en ES ni en EN")
|
||
sys.exit(f"ERROR: '{arg_slug}' existe en {hallados}; desambigua con --site")
|
||
|
||
borradores = candidatos([arg_site] if arg_site else ("es", "en"))
|
||
if not borradores:
|
||
sys.exit("No hay borradores. Pasa un slug o una URL si quieres el guion de un publicado.")
|
||
p = borradores[0]
|
||
print(f" sin slug: cojo el borrador más reciente → [{p['_site'].upper()}] {p['title'][:60]}")
|
||
if len(borradores) > 1:
|
||
print(f" (hay {len(borradores)} borradores; `infografia --lista` para ver los demás)")
|
||
return p["_site"], p["slug"]
|
||
|
||
|
||
def cmd_lista(site):
|
||
borradores = candidatos([site] if site else ("es", "en"))
|
||
if not borradores:
|
||
print("No hay borradores en ninguno de los dos sitios.")
|
||
return
|
||
print(f"─── borradores ({len(borradores)}), del más recién tocado ───")
|
||
for p in borradores:
|
||
img = "con imagen" if p.get("feature_image") else "SIN imagen"
|
||
print(f" [{p['_site'].upper()}] {str(p.get('updated_at'))[:16].replace('T',' ')} {img:11} {p['title'][:58]}")
|
||
print(f" {p['slug']}")
|
||
|
||
|
||
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*$")
|
||
# …salvo que el punto sea el de una ABREVIATURA. «Lt. Colonel» no es una frase
|
||
# nueva que empiece en «Colonel»: es un cargo. Sin esta excepción, el punto de
|
||
# la abreviatura eximía de comprobación a la palabra siguiente, y por ahí entró
|
||
# «Lt. Colonel Quintanilla» en la infografía de Socorro el 2026-07-30, cuando el
|
||
# artículo dice «Major» las cinco veces que lo nombra. La palabra que habría
|
||
# cazado el error («Colonel») era justo la que se saltaba.
|
||
RE_ABREV = re.compile(
|
||
r"\b(?:Lt|Col|Gen|Sgt|Capt|Cmdr|Maj|Dr|Mr|Mrs|Ms|St|Jr|Sr|Prof|Rev|Hon"
|
||
r"|EE|UU|EEUU|Ud|Uds|Sta|Sto|Ing|Lic)\.\s*$", re.I)
|
||
|
||
# Cargos y rangos. Se comprueban en FRASE, no palabra a palabra: «Colonel» puede
|
||
# estar en el artículo (otro oficial) y «Quintanilla» también, y aun así
|
||
# «Colonel Quintanilla» ser falso. Un token a token no puede ver eso nunca.
|
||
RE_CARGO = re.compile(
|
||
r"\b(?:Lt\.?\s*Col(?:onel)?|Colonel|Lieutenant|Major|Captain|Cmdr\.?|Commander"
|
||
r"|Sergeant|Sgt\.?|General|Admiral|Corporal|Airman|Special Agent|Agent"
|
||
r"|Officer|Chief|Detective|Judge|Dr\.?|Professor|Prof\.?|Senator|Congressman"
|
||
r"|Coronel|Teniente|Comandante|Capitán|Capitan|Sargento|Cabo|Almirante"
|
||
r"|Juez|Jueza|Fiscal|Doctor|Doctora|Profesor|Profesora|Agente|Inspector"
|
||
r"|Senador|Diputado|Ministro|Presidente|Alcalde)\.?"
|
||
r"(?:\s+[A-ZÁÉÍÓÚÑ][\wÁÉÍÓÚÑáéíóúñ'’\-]*\.?){1,3}")
|
||
|
||
|
||
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):
|
||
"""¿Aparece el token en el artículo, como PALABRA?
|
||
|
||
Era una comparación por subcadena y eso regalaba pases absurdos: «Lt» daba
|
||
positivo por estar dentro de *built*, *adults* y *consultant*. Cuanto más
|
||
corto el token, más fácil esconderse dentro de otra palabra — justo al revés
|
||
de lo que interesa, porque los tokens cortos son las abreviaturas de cargo.
|
||
|
||
Se conserva la comparación laxa por los extremos (plurales, genitivos: el
|
||
artículo dice «Zamora's» y el guion «Zamora»), exigiendo solo que el token
|
||
empiece en frontera de palabra.
|
||
"""
|
||
t = pliega(token).strip(string.punctuation + "’'")
|
||
if not t:
|
||
return True
|
||
return re.search(r"(?<![\w])" + re.escape(t), articulo) is not None
|
||
|
||
|
||
def cargos_fuera(texto, articulo):
|
||
"""Cargos y rangos del guion que NO aparecen LITERALMENTE en el artículo.
|
||
|
||
Es la comprobación que faltaba, y es de frase entera a propósito: el fallo
|
||
real no fue una palabra inventada sino una COMBINACIÓN falsa de dos palabras
|
||
que sí estaban. Se normalizan los espacios y el punto de la abreviatura para
|
||
que «Lt. Col.» y «Lt Col» no cuenten como distintos.
|
||
"""
|
||
fuera = []
|
||
for m in RE_CARGO.finditer(texto):
|
||
frase = " ".join(m.group(0).split())
|
||
plana = pliega(frase).replace(".", "")
|
||
patron = r"\s*".join(re.escape(p) for p in plana.split())
|
||
if not re.search(patron, articulo.replace(".", "")):
|
||
fuera.append(frase)
|
||
return fuera
|
||
|
||
|
||
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()
|
||
previo = texto[:m.start()] or " "
|
||
if propio and RE_ARRANQUE.search(previo) and not RE_ABREV.search(previo):
|
||
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
|
||
# Un cargo falso se trata como un nombre forastero y tira la viñeta: no
|
||
# se puede degradar «Lt. Colonel Quintanilla» a «Quintanilla» sin
|
||
# reescribir la frase, y reescribir es inventar.
|
||
cargos = cargos_fuera(texto, articulo_low)
|
||
if cargos:
|
||
informe["caidas"].append(
|
||
f"{donde}: cargo/rango {cargos} no aparece así en el 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", nargs="?", help="slug o URL; si se omite, el borrador más reciente")
|
||
ap.add_argument("--site", choices=("en", "es"), help="normalmente se deduce solo")
|
||
ap.add_argument("--lista", action="store_true", help="enumerar los borradores y salir")
|
||
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()
|
||
|
||
if a.lista:
|
||
return cmd_lista(a.site)
|
||
|
||
site, slug = elige(a.slug, a.site)
|
||
a.site, a.slug = site, slug
|
||
cfg = SITES[site]
|
||
post = fetch(slug, cfg["cli"])
|
||
if not post:
|
||
sys.exit(f"ERROR: no encuentro el post '{slug}' en {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()
|