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:
+7
-3
@@ -208,7 +208,7 @@ sim 0.824
|
|||||||
sim 0.746
|
sim 0.746
|
||||||
EN cash-landrum-1980-ufo-federal-lawsuit
|
EN cash-landrum-1980-ufo-federal-lawsuit
|
||||||
Cash-Landrum 1980: The Only UFO Case That Went to Federal Court — and Lost
|
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
|
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
|
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
|
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
|
decision: NO
|
||||||
|
|
||||||
sim n/a año 1964 socorro zamora
|
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
|
When a Police Officer Encountered an Egg-Shaped Craft in Socorro's Desert
|
||||||
ES socorro-1964-ovni-lonnie-zamora
|
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
|
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,
|
«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
|
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
|
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
|
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Í.
|
(«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
|
decision: SI
|
||||||
|
|||||||
+64
-2
@@ -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
|
# Un token en mayúscula que va detrás de uno de estos signos lo está por
|
||||||
# posición, no por ser nombre propio.
|
# posición, no por ser nombre propio.
|
||||||
RE_ARRANQUE = re.compile(r"[.:;!?¡¿—–\-(\[«\"]\s*$")
|
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):
|
def pliega(s):
|
||||||
@@ -218,7 +239,39 @@ def pliega(s):
|
|||||||
|
|
||||||
|
|
||||||
def _en_articulo(token, articulo):
|
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):
|
def revisa(texto, articulo):
|
||||||
@@ -236,7 +289,8 @@ def revisa(texto, articulo):
|
|||||||
t = m.group()
|
t = m.group()
|
||||||
sigla = len(t) > 1 and t.isupper()
|
sigla = len(t) > 1 and t.isupper()
|
||||||
propio = t[:1].isupper() and not 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
|
continue # va en mayúscula por ir detrás de punto
|
||||||
if propio and m.start() == 0:
|
if propio and m.start() == 0:
|
||||||
continue # ídem, principio de la cadena
|
continue # ídem, principio de la cadena
|
||||||
@@ -266,6 +320,14 @@ def filtra(guion, articulo_low, informe):
|
|||||||
if nombres:
|
if nombres:
|
||||||
informe["caidas"].append(f"{donde}: nombre(s) {nombres} fuera del artículo → «{texto[:70]}»")
|
informe["caidas"].append(f"{donde}: nombre(s) {nombres} fuera del artículo → «{texto[:70]}»")
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
for campo in ("titular", "subtitulo", "entradilla"):
|
for campo in ("titular", "subtitulo", "entradilla"):
|
||||||
|
|||||||
@@ -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 sí 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)
|
||||||
Reference in New Issue
Block a user