infografía: el verificador no veía un cargo falso, y por ahí salió a la imagen

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>
This commit is contained in:
ChemaVX
2026-07-30 13:36:34 +00:00
co-authored by Claude Opus 5
parent 91d2de7c4f
commit 1f07e88c12
3 changed files with 215 additions and 5 deletions
+64 -2
View File
@@ -204,6 +204,27 @@ RE_URL = re.compile(r"(?:https?://|www\.)\S+|\b[\w\-]+\.(?:com|es|org|net|gov|br
# 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):
@@ -218,7 +239,39 @@ def pliega(s):
def _en_articulo(token, articulo):
return pliega(token).strip(string.punctuation + "'") in 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):
@@ -236,7 +289,8 @@ def revisa(texto, articulo):
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 " "):
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
@@ -266,6 +320,14 @@ def filtra(guion, articulo_low, informe):
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"):