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
+7 -3
View File
@@ -208,7 +208,7 @@ sim 0.824
sim 0.746
EN cash-landrum-1980-ufo-federal-lawsuit
Cash-Landrum 1980: The Only UFO Case That Went to Federal Court — and Lost
ES el-incidente-cash-landrum-la-unica-demanda-ovni-contra-el-gobierno-estadounidense-que-llego-a-un-tribunal-federal
ES cash-landrum-1980-demanda-ovni-tribunal-federal
El incidente Cash-Landrum: la única demanda OVNI contra el gobierno estadounidense que
NOTA (2026-07-30): antes decía NO porque el espejo ES no existía al escanear — se
generó hoy. Mismo caso, mismo año, mismo nombre propio en los dos títulos, y las dos
@@ -368,10 +368,10 @@ sim n/a bases nucleares
decision: NO
sim n/a año 1964 socorro zamora
EN when-a-police-officer-encountered-an-egg-shaped-craft-in-socorros-desert
EN socorro-1964-zamora-ufo-landing
When a Police Officer Encountered an Egg-Shaped Craft in Socorro's Desert
ES socorro-1964-ovni-lonnie-zamora
Socorro 1964: el OVNI que dejó marcas y un policía que no se retractó
Socorro 1964: el policía, el objeto posado y las marcas que quedaron en el suelo
NOTA (2026-07-30): pareja nueva, curada a mano el mismo día que se generó el EN. Sin
«sim» porque no pasó por el escaneo de vectores — y da igual: mismo caso, 24-abr-1964,
el mismo Lonnie Zamora y las mismas marcas en el suelo. El TÍTULO del EN no lleva la
@@ -380,4 +380,8 @@ sim n/a año 1964 socorro zamora
BORRADOR y sin programar, así que la pareja no se puede declarar hasta que se publiquen
los dos. ⚠️ Si al rematar el EN se le cambia el slug para meterle la firma
(«socorro-1964-…», que sería lo coherente), hay que actualizarlo AQUÍ.
ACTUALIZADO (2026-07-30, remate del EN): el slug EN pasó a «socorro-1964-zamora-ufo-landing»
y aquí queda ya corregido; el título ES también, que había cambiado al rematarlo. El EN
sigue en borrador: cuando se publique (el ES sale el 11-ago), hreflang-watch declara la
pareja solo.
decision: SI
+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"):
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Tests de infografia_brief.py — que el guion no contradiga al artículo.
Todo lo de aquí es la cicatriz de un fallo REAL del 2026-07-30: la infografía
de Socorro salió publicando «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 la imagen: venía en el guion, y el verificador lo dejó pasar por DOS
motivos independientes, así que hay dos redes que probar.
python3 -m pytest test_infografia_brief.py -q
python3 test_infografia_brief.py # sin pytest
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import infografia_brief as I
# Artículo de mentira con la forma del real: nombra a Quintanilla como Major, y
# contiene «consultant» y «built», que es donde se escondía «Lt» por subcadena.
ART = I.pliega(
"The Socorro UFO Landing of 1964 left four depressions and no explanation. "
"Project Blue Book chief, Major Hector Quintanilla, dispatched his "
"scientific consultant, Dr. J. Allen Hynek, to the site. Sergeant Sam "
"Chavez reached the arroyo first. The shack had been built years earlier. "
"Zamora's patrol car was parked on the ridge. Captain Richard T. Holder "
"of White Sands Missile Range arrived that evening."
)
# ────────────── red 1: la abreviatura ya no regala el pase ──────────────
def test_el_punto_de_una_abreviatura_no_exime_a_la_palabra_siguiente():
"""«Lt. Colonel»: RE_ARRANQUE veía el punto de «Lt.» y daba por hecho que
«Colonel» iba en mayúscula por empezar frase, así que NO lo comprobaba. La
única palabra capaz de cazar el error era justo la que se saltaba."""
nombres, _, _ = I.revisa("Blue Book chief Lt. Colonel Hector Quintanilla spoke", ART)
assert "Colonel" in nombres, nombres
def test_un_punto_de_verdad_sigue_eximiendo():
"""La excepción no puede cargarse la regla: detrás de un punto de final de
frase, una mayúscula es posicional y no debe marcarse."""
nombres, _, _ = I.revisa("The object left. Nothing else was found.", ART)
assert nombres == [], nombres
# ────────────── red 2: comparar por palabra, no por subcadena ──────────────
def test_un_token_corto_no_se_esconde_dentro_de_otra_palabra():
"""«Lt» daba positivo por estar dentro de *built*, *adults* y *consultant*.
Cuanto más corto el token más fácil colarse y los tokens cortos son
justamente las abreviaturas de cargo."""
assert not I._en_articulo("Lt", ART)
assert "built" in ART and "consultant" in ART # la trampa sigue ahí
def test_los_plurales_y_genitivos_siguen_valiendo():
"""La comparación se ata solo por la IZQUIERDA a propósito: el artículo dice
«Zamora's» y el guion «Zamora», y eso no es contaminación."""
assert I._en_articulo("Zamora", ART)
assert I._en_articulo("Quintanilla", ART)
# ────────────── red 3: el cargo se comprueba en FRASE ──────────────
def test_un_cargo_falso_se_caza_aunque_sus_palabras_esten_sueltas():
"""El fallo real no fue una palabra inventada sino una COMBINACIÓN falsa.
Token a token no se puede ver nunca: hay que comparar la frase entera."""
assert I.cargos_fuera("Lt. Colonel Hector Quintanilla said so", ART) == \
["Lt. Colonel Hector Quintanilla"]
def test_el_cargo_correcto_no_salta():
assert I.cargos_fuera("Major Hector Quintanilla said so", ART) == []
def test_otros_cargos_reales_del_articulo_no_saltan():
"""Si marcara cargos legítimos, el informe se volvería inservible y se
dejaría de leer que es como se pierde el aviso de verdad."""
for frase in ("Sergeant Sam Chavez reached the site",
"Captain Richard T. Holder of White Sands",
"his scientific consultant, Dr. J. Allen Hynek"):
assert I.cargos_fuera(frase, ART) == [], frase
def test_la_abreviatura_no_cuenta_como_cargo_distinto():
"""«Lt. Col.» y «Lt Col» son el mismo cargo: si el punto los separase,
cualquier artículo que abrevie distinto daría falso positivo."""
art = I.pliega("Lt. Col. Hector Quintanilla ran the program.")
assert I.cargos_fuera("Lt Col Hector Quintanilla ran it", art) == []
# ────────────── camino de producción: filtra() poda lo justo ──────────────
def _guion(rango):
return {
"titular": "Socorro UFO Landing, 1964",
"subtitulo": "Four depressions and no explanation",
"entradilla": "Sergeant Sam Chavez reached the arroyo first.",
"cronologia": [
{"marca": "within 24 hours",
"texto": f"Blue Book chief {rango} Hector Quintanilla dispatched Hynek."},
{"marca": "within minutes",
"texto": "Sergeant Sam Chavez reached the arroyo first."}],
"paneles": [{"titulo": "The witness", "vinetas": [
"Zamora's patrol car was parked on the ridge",
f"{rango} Hector Quintanilla classified it"]}],
"franja": [],
}
def test_filtra_tira_solo_las_frases_con_el_cargo_falso():
inf = {"avisos": [], "caidas": []}
out = I.filtra(_guion("Lt. Colonel"), ART, inf)
assert len(out["cronologia"]) == 1, out["cronologia"]
assert sum(len(p["vinetas"]) for p in out["paneles"]) == 1
assert out["titular"], "no debía tocar el titular"
assert any("Colonel" in c for c in inf["caidas"]), inf["caidas"]
def test_filtra_no_toca_nada_si_el_cargo_es_el_bueno():
"""La otra mitad: un verificador que poda de más es tan inútil como uno que
no poda deja el guion cojo y obliga a rehacerlo a mano."""
inf = {"avisos": [], "caidas": []}
out = I.filtra(_guion("Major"), ART, inf)
assert len(out["cronologia"]) == 2, inf["caidas"]
assert sum(len(p["vinetas"]) for p in out["paneles"]) == 2, inf["caidas"]
assert inf["caidas"] == [], inf["caidas"]
if __name__ == "__main__":
fallos = 0
for nombre, fn in sorted(globals().items()):
if not nombre.startswith("test_"):
continue
try:
fn()
print(f"{nombre}")
except AssertionError as exc:
fallos += 1
print(f"{nombre}: {exc}")
print(f"\n{'✓ todo pasa' if not fallos else f'{fallos} fallo(s)'}")
sys.exit(1 if fallos else 0)